release/v1.3.7 #127
48 changed files with 2062 additions and 596 deletions
|
|
@ -42,72 +42,3 @@ jobs:
|
|||
|
||||
- name: Tests
|
||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||
|
||||
- name: Parse coverage
|
||||
if: github.event_name == 'pull_request'
|
||||
id: coverage
|
||||
run: |
|
||||
COVERAGE=$(php -r '
|
||||
$xml = simplexml_load_file("coverage.xml");
|
||||
if ($xml === false || !isset($xml->project->metrics)) {
|
||||
echo "0";
|
||||
exit;
|
||||
}
|
||||
$metrics = $xml->project->metrics;
|
||||
$statements = (int) $metrics["statements"];
|
||||
$covered = (int) $metrics["coveredstatements"];
|
||||
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
|
||||
')
|
||||
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment coverage on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
COVERAGE: ${{ steps.coverage.outputs.percentage }}
|
||||
REPO: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
|
||||
MARKER="<!-- ffr-ci-coverage-report -->"
|
||||
|
||||
BODY="${MARKER}
|
||||
## Code Coverage Report
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Line Coverage** | ${COVERAGE}% |
|
||||
|
||||
_Updated by CI — commit ${COMMIT_SHA}_"
|
||||
|
||||
# Find existing coverage comment
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${API_URL}?limit=50" | \
|
||||
php -r '
|
||||
$comments = json_decode(file_get_contents("php://stdin"), true);
|
||||
if (!is_array($comments)) exit;
|
||||
foreach ($comments as $c) {
|
||||
if (str_contains($c["body"], "<!-- ffr-ci-coverage-report -->")) {
|
||||
echo $c["id"];
|
||||
exit;
|
||||
}
|
||||
}
|
||||
' || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# Update existing comment
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
|
||||
else
|
||||
# Create new comment
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${API_URL}" > /dev/null
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ php artisan db:seed --force || echo "Seeders failed or already run"
|
|||
# Start Horizon in the background
|
||||
php artisan horizon &
|
||||
|
||||
# Start the scheduler in the background
|
||||
php artisan schedule:work &
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ npm run dev &
|
|||
# Start Horizon (queue worker) in background
|
||||
php artisan horizon &
|
||||
|
||||
# Scheduler left off in dev on purpose; run schedule:work by hand when needed.
|
||||
# php artisan schedule:work &
|
||||
|
||||
# Start FrankenPHP
|
||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
class CreateChannelAction
|
||||
{
|
||||
public function execute(string $name, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
|
||||
public function execute(string $name, int $communityId, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
|
||||
{
|
||||
$platformInstance = PlatformInstance::findOrFail($platformInstanceId);
|
||||
|
||||
|
|
@ -22,10 +22,10 @@ public function execute(string $name, int $platformInstanceId, ?int $languageId
|
|||
throw new RuntimeException('No active platform accounts found for this instance. Please create a platform account first.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($name, $platformInstanceId, $languageId, $description, $activeAccounts) {
|
||||
return DB::transaction(function () use ($name, $communityId, $platformInstanceId, $languageId, $description, $activeAccounts) {
|
||||
$channel = PlatformChannel::create([
|
||||
'platform_instance_id' => $platformInstanceId,
|
||||
'channel_id' => $name,
|
||||
'channel_id' => $communityId,
|
||||
'name' => $name,
|
||||
'display_name' => ucfirst($name),
|
||||
'description' => $description,
|
||||
|
|
|
|||
107
app/Actions/PublishRouteArticleAction.php
Normal file
107
app/Actions/PublishRouteArticleAction.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use App\Services\Publishing\PublishOutcome;
|
||||
use Exception;
|
||||
|
||||
class PublishRouteArticleAction
|
||||
{
|
||||
public function __construct(
|
||||
private ArticleFetcher $articleFetcher,
|
||||
private ArticlePublishingService $publishingService,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function execute(RouteArticle $routeArticle): PublishOutcome
|
||||
{
|
||||
$article = $routeArticle->article;
|
||||
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$extractedData = $this->articleFetcher->fetchArticleData($article);
|
||||
$outcome = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
} catch (Exception $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
match (true) {
|
||||
$outcome->succeeded() => $this->recordPublished($routeArticle),
|
||||
$outcome->wasSkipped() => $this->recordSkipped($routeArticle, $outcome),
|
||||
default => $this->recordFailed($routeArticle, $outcome),
|
||||
};
|
||||
|
||||
return $outcome;
|
||||
}
|
||||
|
||||
private function recordPublished(RouteArticle $routeArticle): void
|
||||
{
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
||||
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $routeArticle->article->id,
|
||||
'title' => $routeArticle->article->title,
|
||||
]);
|
||||
}
|
||||
|
||||
private function recordSkipped(RouteArticle $routeArticle, PublishOutcome $outcome): void
|
||||
{
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::SKIPPED]);
|
||||
|
||||
ActionPerformed::dispatch('Skipped publishing article', LogLevelEnum::INFO, [
|
||||
'article_id' => $routeArticle->article->id,
|
||||
'title' => $routeArticle->article->title,
|
||||
'reason' => $outcome->reason,
|
||||
]);
|
||||
}
|
||||
|
||||
private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcome): void
|
||||
{
|
||||
$article = $routeArticle->article;
|
||||
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'reason' => $outcome->reason,
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
$outcome->reason ?? 'No publication was created for this article.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,6 @@ enum PublishStatusEnum: string
|
|||
case UNPUBLISHED = 'unpublished';
|
||||
case PUBLISHING = 'publishing';
|
||||
case PUBLISHED = 'published';
|
||||
case SKIPPED = 'skipped';
|
||||
case ERROR = 'error';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
use App\Http\Resources\PlatformChannelResource;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Services\Platform\CommunityDirectory;
|
||||
use Exception;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -40,8 +42,12 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
|
|||
try {
|
||||
$validated = $request->validated();
|
||||
|
||||
$instance = PlatformInstance::query()->findOrFail((int) $validated['platform_instance_id']);
|
||||
$name = app(CommunityDirectory::class)->name($instance, (int) $validated['channel_id']);
|
||||
|
||||
$channel = $createChannelAction->execute(
|
||||
$validated['name'],
|
||||
$name,
|
||||
(int) $validated['channel_id'],
|
||||
$validated['platform_instance_id'],
|
||||
$validated['language_id'] ?? null,
|
||||
$validated['description'] ?? null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Services\Platform\CommunityDirectory;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -17,19 +20,22 @@ public function authorize(): bool
|
|||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
try {
|
||||
$communityRules = [
|
||||
Rule::in($this->communityIds()),
|
||||
Rule::unique('platform_channels', 'channel_id')
|
||||
->where('platform_instance_id', $this->input('platform_instance_id')),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
// Falling through to Rule::in([]) would report the community as non-existent
|
||||
// when the truth is we never reached the instance to check.
|
||||
$message = 'Could not reach this instance to list its communities: '.$e->getMessage();
|
||||
$communityRules = [fn ($attribute, $value, $fail) => $fail($message)];
|
||||
}
|
||||
|
||||
return [
|
||||
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||
// name doubles as the Lemmy community slug (CreateChannelAction copies it
|
||||
// verbatim into channel_id for community lookup at publish time), so it must
|
||||
// be slug format and unique per instance — matching the Livewire create form.
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
'regex:/^[a-z0-9_]+$/',
|
||||
Rule::unique('platform_channels', 'name')
|
||||
->where('platform_instance_id', $this->input('platform_instance_id')),
|
||||
],
|
||||
'channel_id' => ['required', 'integer', ...$communityRules],
|
||||
'language_id' => 'nullable|exists:languages,id',
|
||||
'description' => 'nullable|string',
|
||||
];
|
||||
|
|
@ -41,8 +47,24 @@ public function rules(): array
|
|||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
|
||||
'name.unique' => 'A channel with this name already exists for this instance.',
|
||||
'channel_id.in' => 'That community does not exist on the selected instance.',
|
||||
'channel_id.unique' => 'A channel for this community already exists.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function communityIds(): array
|
||||
{
|
||||
$instance = PlatformInstance::query()->find((int) $this->input('platform_instance_id'));
|
||||
|
||||
if (! $instance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(app(CommunityDirectory::class)->forInstance($instance))
|
||||
->pluck('id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
|
@ -38,7 +33,7 @@ public function __construct()
|
|||
*
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void
|
||||
public function handle(PublishRouteArticleAction $publishRouteArticle): void
|
||||
{
|
||||
$interval = Setting::getArticlePublishingInterval();
|
||||
|
||||
|
|
@ -72,52 +67,6 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
|
|||
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
|
||||
]);
|
||||
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
|
||||
if ($publication) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publication was created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (PublishException $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
$publishRouteArticle->execute($routeArticle);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,9 +68,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
|||
$api = $this->makeApiService($this->channel->platformInstance->url);
|
||||
$token = $this->getAuthToken($api, $account);
|
||||
|
||||
$communityId = $api->resolveCommunityId($this->channel->channel_id, $token);
|
||||
|
||||
$api->syncChannelPosts($token, $communityId, $this->channel->name);
|
||||
$api->syncChannelPosts($token, $this->channel, $this->channel->channel_id);
|
||||
|
||||
$logSaver->info('Channel posts synced successfully', $this->channel);
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,8 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
|
|
@ -19,9 +12,7 @@ class PublishApprovedArticleListener implements ShouldQueue
|
|||
public string $queue = 'publishing';
|
||||
|
||||
public function __construct(
|
||||
private ArticleFetcher $articleFetcher,
|
||||
private ArticlePublishingService $publishingService,
|
||||
private NotificationService $notificationService,
|
||||
private PublishRouteArticleAction $publishRouteArticle,
|
||||
) {}
|
||||
|
||||
public function handle(RouteArticleApproved $event): void
|
||||
|
|
@ -29,7 +20,6 @@ public function handle(RouteArticleApproved $event): void
|
|||
$routeArticle = $event->routeArticle;
|
||||
$article = $routeArticle->article;
|
||||
|
||||
// Skip if already published to this channel
|
||||
if ($article->articlePublications()
|
||||
->where('platform_channel_id', $routeArticle->platform_channel_id)
|
||||
->exists()
|
||||
|
|
@ -37,50 +27,10 @@ public function handle(RouteArticleApproved $event): void
|
|||
return;
|
||||
}
|
||||
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$extractedData = $this->articleFetcher->fetchArticleData($article);
|
||||
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
|
||||
if ($publication) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
||||
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publication was created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
$this->publishRouteArticle->execute($routeArticle);
|
||||
} catch (Exception) {
|
||||
// The action has already recorded the failure and notified.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Services\Platform\CommunityDirectory;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -19,10 +21,15 @@ class Channels extends Component
|
|||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public string $newName = '';
|
||||
public ?int $newCommunityId = null;
|
||||
|
||||
public ?int $newPlatformInstanceId = null;
|
||||
|
||||
/** @var array<int, array{id: int, name: string, title: string}> */
|
||||
public array $availableCommunities = [];
|
||||
|
||||
public ?string $communityLoadError = null;
|
||||
|
||||
public ?int $newLanguageId = null;
|
||||
|
||||
public string $newDescription = '';
|
||||
|
|
@ -36,11 +43,44 @@ public function toggle(int $channelId): void
|
|||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']);
|
||||
$this->reset(['newCommunityId', 'newPlatformInstanceId', 'newLanguageId', 'newDescription', 'availableCommunities', 'communityLoadError']);
|
||||
$this->resetErrorBag();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function updatedNewPlatformInstanceId(?int $value): void
|
||||
{
|
||||
$this->reset(['newCommunityId', 'availableCommunities', 'communityLoadError']);
|
||||
|
||||
if (! $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$instance = PlatformInstance::find($value);
|
||||
|
||||
if (! $instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance);
|
||||
} catch (Exception $e) {
|
||||
$this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshCommunities(): void
|
||||
{
|
||||
$instance = $this->newPlatformInstanceId ? PlatformInstance::find($this->newPlatformInstanceId) : null;
|
||||
|
||||
if (! $instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(CommunityDirectory::class)->forget($instance);
|
||||
$this->updatedNewPlatformInstanceId($this->newPlatformInstanceId);
|
||||
}
|
||||
|
||||
public function closeCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
|
|
@ -49,36 +89,33 @@ public function closeCreateModal(): void
|
|||
public function createChannel(CreateChannelAction $action): void
|
||||
{
|
||||
$this->validate([
|
||||
// name doubles as the Lemmy community slug (used verbatim as channel_id for
|
||||
// community lookup at publish time), so it must be lowercase slug format.
|
||||
'newName' => [
|
||||
'newCommunityId' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
'regex:/^[a-z0-9_]+$/',
|
||||
Rule::unique('platform_channels', 'name')
|
||||
'integer',
|
||||
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
|
||||
Rule::unique('platform_channels', 'channel_id')
|
||||
->where('platform_instance_id', $this->newPlatformInstanceId),
|
||||
],
|
||||
'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id',
|
||||
'newLanguageId' => 'nullable|integer|exists:languages,id',
|
||||
], [
|
||||
'newName.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
|
||||
'newName.unique' => 'A channel with this name already exists for this instance.',
|
||||
'newCommunityId.in' => 'Select a community from this instance.',
|
||||
'newCommunityId.unique' => 'A channel for this community already exists.',
|
||||
]);
|
||||
|
||||
$name = collect($this->availableCommunities)->firstWhere('id', $this->newCommunityId)['name'] ?? null;
|
||||
|
||||
try {
|
||||
$action->execute(
|
||||
$this->newName,
|
||||
$name,
|
||||
$this->newCommunityId,
|
||||
$this->newPlatformInstanceId,
|
||||
$this->newLanguageId,
|
||||
// Blade textarea binds an empty string when blank; the action expects null for "no description".
|
||||
$this->newDescription !== '' ? $this->newDescription : null,
|
||||
);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Unreachable via this form (the unique rule above catches duplicates first),
|
||||
// but the (platform_instance_id, channel_id) index can still fire if channel_id
|
||||
// ever drifts from name. Surface it as a field error instead of a 500.
|
||||
$this->addError('newName', 'A channel with this name already exists for this instance.');
|
||||
$this->addError('newCommunityId', 'A channel for this community already exists.');
|
||||
|
||||
return;
|
||||
} catch (RuntimeException $e) {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use App\Services\OnboardingService;
|
||||
use App\Services\Platform\CommunityDirectory;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Validation\Rule;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
|
@ -49,7 +51,12 @@ class Onboarding extends Component
|
|||
public string $feedDescription = '';
|
||||
|
||||
// Channel form
|
||||
public string $channelName = '';
|
||||
public ?int $channelCommunityId = null;
|
||||
|
||||
/** @var array<int, array{id: int, name: string, title: string}> */
|
||||
public array $availableCommunities = [];
|
||||
|
||||
public ?string $communityLoadError = null;
|
||||
|
||||
public ?int $platformInstanceId = null;
|
||||
|
||||
|
|
@ -117,10 +124,11 @@ public function mount(): void
|
|||
// Pre-fill channel form if exists
|
||||
$channel = PlatformChannel::where('is_active', true)->first();
|
||||
if ($channel) {
|
||||
$this->channelName = $channel->name;
|
||||
$this->platformInstanceId = $channel->platform_instance_id;
|
||||
$this->channelLanguageId = $channel->language_id;
|
||||
$this->channelDescription = $channel->description ?? '';
|
||||
$this->loadCommunities();
|
||||
$this->channelCommunityId = $channel->channel_id;
|
||||
}
|
||||
|
||||
// Pre-fill route form if exists
|
||||
|
|
@ -252,16 +260,61 @@ public function createFeed(): void
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedPlatformInstanceId(?int $value): void
|
||||
{
|
||||
$this->reset(['channelCommunityId', 'availableCommunities', 'communityLoadError']);
|
||||
|
||||
if ($value) {
|
||||
$this->loadCommunities();
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshCommunities(): void
|
||||
{
|
||||
$instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null;
|
||||
|
||||
if (! $instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(CommunityDirectory::class)->forget($instance);
|
||||
$this->loadCommunities();
|
||||
}
|
||||
|
||||
private function loadCommunities(): void
|
||||
{
|
||||
$this->availableCommunities = [];
|
||||
$this->communityLoadError = null;
|
||||
|
||||
$instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null;
|
||||
|
||||
if (! $instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance);
|
||||
} catch (Exception $e) {
|
||||
$this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function createChannel(): void
|
||||
{
|
||||
$this->formErrors = [];
|
||||
$this->isLoading = true;
|
||||
|
||||
$this->validate([
|
||||
'channelName' => 'required|string|max:255',
|
||||
'channelCommunityId' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
|
||||
],
|
||||
'platformInstanceId' => 'required|exists:platform_instances,id',
|
||||
'channelLanguageId' => 'required|exists:languages,id',
|
||||
'channelDescription' => 'nullable|string|max:1000',
|
||||
], [
|
||||
'channelCommunityId.in' => 'Select a community from this instance.',
|
||||
]);
|
||||
|
||||
// If language changed, reset feed form
|
||||
|
|
@ -274,11 +327,14 @@ public function createChannel(): void
|
|||
}
|
||||
$this->previousChannelLanguageId = $this->channelLanguageId;
|
||||
|
||||
$name = collect($this->availableCommunities)->firstWhere('id', $this->channelCommunityId)['name'] ?? null;
|
||||
|
||||
try {
|
||||
$channel = $this->createChannelAction->execute(
|
||||
$this->channelName,
|
||||
$this->platformInstanceId,
|
||||
$this->channelLanguageId,
|
||||
$name,
|
||||
(int) $this->channelCommunityId,
|
||||
(int) $this->platformInstanceId,
|
||||
$this->channelLanguageId !== null ? (int) $this->channelLanguageId : null,
|
||||
$this->channelDescription ?: null,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
* @property int $id
|
||||
* @property int $platform_instance_id
|
||||
* @property PlatformInstance $platformInstance
|
||||
* @property string $channel_id
|
||||
* @property int $channel_id
|
||||
* @property string $name
|
||||
* @property int $language_id
|
||||
* @property Language|null $language
|
||||
|
|
@ -40,6 +40,7 @@ class PlatformChannel extends Model
|
|||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'channel_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @method static where(string $string, PlatformEnum $platform)
|
||||
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
|
||||
*/
|
||||
class PlatformChannelPost extends Model
|
||||
|
|
@ -17,9 +16,7 @@ class PlatformChannelPost extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'platform',
|
||||
'channel_id',
|
||||
'channel_name',
|
||||
'platform_channel_id',
|
||||
'post_id',
|
||||
'url',
|
||||
'title',
|
||||
|
|
@ -33,26 +30,24 @@ protected function casts(): array
|
|||
{
|
||||
return [
|
||||
'posted_at' => 'datetime',
|
||||
'platform' => PlatformEnum::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function urlExists(PlatformEnum $platform, string $channelId, string $url): bool
|
||||
/**
|
||||
* @return BelongsTo<PlatformChannel, $this>
|
||||
*/
|
||||
public function platformChannel(): BelongsTo
|
||||
{
|
||||
return self::where('platform', $platform)
|
||||
->where('channel_id', $channelId)
|
||||
->where('url', $url)
|
||||
->exists();
|
||||
return $this->belongsTo(PlatformChannel::class);
|
||||
}
|
||||
|
||||
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
|
||||
public static function duplicateExists(PlatformChannel $channel, ?string $url, ?string $title): bool
|
||||
{
|
||||
if (! $url && ! $title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::where('platform', $platform)
|
||||
->where('channel_id', $channelId)
|
||||
return self::where('platform_channel_id', $channel->id)
|
||||
->where(function ($query) use ($url, $title) {
|
||||
if ($url) {
|
||||
$query->orWhere('url', $url);
|
||||
|
|
@ -64,16 +59,14 @@ public static function duplicateExists(PlatformEnum $platform, string $channelId
|
|||
->exists();
|
||||
}
|
||||
|
||||
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
|
||||
public static function storePost(PlatformChannel $channel, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
|
||||
{
|
||||
return self::updateOrCreate(
|
||||
[
|
||||
'platform' => $platform,
|
||||
'channel_id' => $channelId,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'post_id' => $postId,
|
||||
],
|
||||
[
|
||||
'channel_name' => $channelName,
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'posted_at' => $postedAt ?? now(),
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ public function isRejected(): bool
|
|||
|
||||
public function approve(): void
|
||||
{
|
||||
if ($this->isApproved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
||||
|
||||
event(new RouteArticleApproved($this));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Modules\Lemmy\Services;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformChannelPost;
|
||||
use App\Modules\Lemmy\LemmyRequest;
|
||||
use Exception;
|
||||
|
|
@ -84,18 +84,35 @@ public function login(string $username, string $password): ?string
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve a PlatformChannel.channel_id to a numeric Lemmy community id.
|
||||
*
|
||||
* channel_id holds either a community slug (the usual case — CreateChannelAction
|
||||
* copies `name` into it) or an already-numeric community id. Callers that need the
|
||||
* numeric id should use this rather than reimplementing the check, so the two forms
|
||||
* stay handled identically everywhere.
|
||||
* @return array<int, array{id: int, name: string, title: string}>
|
||||
*/
|
||||
public function resolveCommunityId(string $channelId, string $token): int
|
||||
public function listCommunities(?string $token = null): array
|
||||
{
|
||||
return is_numeric($channelId)
|
||||
? (int) $channelId
|
||||
: $this->getCommunityId($channelId, $token);
|
||||
$request = new LemmyRequest($this->instance, $token);
|
||||
$response = $request->get('community/list', [
|
||||
'type_' => 'Local',
|
||||
'limit' => 50,
|
||||
'sort' => 'TopAll',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('Failed to list communities: '.$response->status());
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, mixed>> $communities */
|
||||
$communities = $response->json('communities') ?? [];
|
||||
|
||||
return collect($communities)
|
||||
->pluck('community')
|
||||
->reject(fn ($community) => ($community['removed'] ?? false) || ($community['deleted'] ?? false))
|
||||
->map(fn ($community) => [
|
||||
'id' => (int) $community['id'],
|
||||
'name' => (string) $community['name'],
|
||||
'title' => (string) ($community['title'] ?? $community['name']),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getCommunityId(string $communityName, string $token): int
|
||||
|
|
@ -117,12 +134,12 @@ public function getCommunityId(string $communityName, string $token): int
|
|||
}
|
||||
}
|
||||
|
||||
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void
|
||||
public function syncChannelPosts(string $token, PlatformChannel $channel, int $communityId): void
|
||||
{
|
||||
try {
|
||||
$request = new LemmyRequest($this->instance, $token);
|
||||
$response = $request->get('post/list', [
|
||||
'community_id' => $platformChannelId,
|
||||
'community_id' => $communityId,
|
||||
'limit' => 50,
|
||||
'sort' => 'New',
|
||||
]);
|
||||
|
|
@ -130,7 +147,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
|
|||
if (! $response->successful()) {
|
||||
logger()->warning('Failed to sync channel posts', [
|
||||
'status' => $response->status(),
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
'platform_channel_id' => $channel->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
|
|
@ -143,9 +160,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
|
|||
$post = $postData['post'];
|
||||
|
||||
PlatformChannelPost::storePost(
|
||||
PlatformEnum::LEMMY,
|
||||
(string) $platformChannelId,
|
||||
$communityName,
|
||||
$channel,
|
||||
(string) $post['id'],
|
||||
$post['url'] ?? null,
|
||||
$post['name'] ?? null,
|
||||
|
|
@ -154,14 +169,14 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
|
|||
}
|
||||
|
||||
logger()->info('Synced channel posts', [
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'posts_count' => count($posts),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Exception while syncing channel posts', [
|
||||
'error' => $e->getMessage(),
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
'platform_channel_id' => $channel->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,13 +54,11 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
|||
{
|
||||
$languageId = $extractedData['language_id'] ?? null;
|
||||
|
||||
$communityId = $this->api->resolveCommunityId($channel->channel_id, $token);
|
||||
|
||||
return $this->api->createPost(
|
||||
$token,
|
||||
$extractedData['title'] ?? 'Untitled',
|
||||
$extractedData['description'] ?? '',
|
||||
$communityId,
|
||||
$channel->channel_id,
|
||||
$article->url,
|
||||
$extractedData['thumbnail'] ?? null,
|
||||
$languageId
|
||||
|
|
|
|||
51
app/Services/Platform/CommunityDirectory.php
Normal file
51
app/Services/Platform/CommunityDirectory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Platform;
|
||||
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CommunityDirectory
|
||||
{
|
||||
private const TTL_SECONDS = 86400;
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, name: string, title: string}>
|
||||
*/
|
||||
public function forInstance(PlatformInstance $instance): array
|
||||
{
|
||||
return Cache::remember(
|
||||
self::cacheKey($instance),
|
||||
self::TTL_SECONDS,
|
||||
fn () => $this->makeApi($instance->url)->listCommunities()
|
||||
);
|
||||
}
|
||||
|
||||
public function forget(PlatformInstance $instance): void
|
||||
{
|
||||
Cache::forget(self::cacheKey($instance));
|
||||
}
|
||||
|
||||
public function has(PlatformInstance $instance, int $communityId): bool
|
||||
{
|
||||
return collect($this->forInstance($instance))
|
||||
->contains(fn (array $community) => $community['id'] === $communityId);
|
||||
}
|
||||
|
||||
public function name(PlatformInstance $instance, int $communityId): ?string
|
||||
{
|
||||
return collect($this->forInstance($instance))
|
||||
->firstWhere('id', $communityId)['name'] ?? null;
|
||||
}
|
||||
|
||||
protected function makeApi(string $instanceUrl): LemmyApiService
|
||||
{
|
||||
return new LemmyApiService($instanceUrl);
|
||||
}
|
||||
|
||||
private static function cacheKey(PlatformInstance $instance): string
|
||||
{
|
||||
return "platform:communities:{$instance->id}";
|
||||
}
|
||||
}
|
||||
|
|
@ -12,10 +12,16 @@
|
|||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use RuntimeException;
|
||||
|
||||
class ArticlePublishingService
|
||||
{
|
||||
private const LOCK_TTL_SECONDS = 180;
|
||||
|
||||
private const LOCK_WAIT_SECONDS = 15;
|
||||
|
||||
public function __construct(private LogSaver $logSaver) {}
|
||||
|
||||
/**
|
||||
|
|
@ -33,7 +39,7 @@ protected function makePublisher(mixed $account): LemmyPublisher
|
|||
*
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
|
||||
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): PublishOutcome
|
||||
{
|
||||
$article = $routeArticle->article;
|
||||
$channel = $routeArticle->platformChannel;
|
||||
|
|
@ -54,7 +60,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
|
|||
'route_article_id' => $routeArticle->id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
return PublishOutcome::failure('No active account for channel');
|
||||
}
|
||||
|
||||
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
||||
|
|
@ -63,24 +69,51 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
|
|||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
|
||||
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
|
||||
{
|
||||
$lock = Cache::lock("publish:{$article->id}:{$channel->id}", self::LOCK_TTL_SECONDS);
|
||||
|
||||
try {
|
||||
return $lock->block(self::LOCK_WAIT_SECONDS, function () use ($article, $extractedData, $channel, $account) {
|
||||
$alreadyPublished = ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyPublished) {
|
||||
$this->logSaver->info('Skipping duplicate: already published to channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return PublishOutcome::skipped('Already published to this channel');
|
||||
}
|
||||
|
||||
return $this->doPublishToChannel($article, $extractedData, $channel, $account);
|
||||
});
|
||||
} catch (LockTimeoutException $e) {
|
||||
$this->logSaver->info('Skipping publish: another worker holds the lock', $channel, [
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return PublishOutcome::skipped('Another worker is publishing this article');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function doPublishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
|
||||
{
|
||||
try {
|
||||
// Check if this URL or title was already posted to this channel
|
||||
$title = $extractedData['title'] ?? $article->title;
|
||||
if (PlatformChannelPost::duplicateExists(
|
||||
$channel->platformInstance->platform,
|
||||
(string) $channel->channel_id,
|
||||
$article->url,
|
||||
$title
|
||||
)) {
|
||||
if (PlatformChannelPost::duplicateExists($channel, $article->url, $title)) {
|
||||
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'url' => $article->url,
|
||||
'title' => $title,
|
||||
]);
|
||||
|
||||
return null;
|
||||
return PublishOutcome::skipped('URL or title already posted to this channel');
|
||||
}
|
||||
|
||||
$publisher = $this->makePublisher($account);
|
||||
|
|
@ -100,14 +133,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
|
|||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return $publication;
|
||||
return PublishOutcome::published($publication);
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
return PublishOutcome::failure($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
app/Services/Publishing/PublishOutcome.php
Normal file
49
app/Services/Publishing/PublishOutcome.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Publishing;
|
||||
|
||||
use App\Models\ArticlePublication;
|
||||
|
||||
/**
|
||||
* Publishing has three outcomes, not two: it can succeed, be deliberately
|
||||
* skipped, or fail. Returning a bare null for the last two made every skip
|
||||
* surface as a publish failure (#123).
|
||||
*/
|
||||
class PublishOutcome
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ArticlePublication $publication,
|
||||
public readonly bool $skipped,
|
||||
public readonly ?string $reason = null,
|
||||
) {}
|
||||
|
||||
public static function published(ArticlePublication $publication): self
|
||||
{
|
||||
return new self($publication, false);
|
||||
}
|
||||
|
||||
public static function skipped(string $reason): self
|
||||
{
|
||||
return new self(null, true, $reason);
|
||||
}
|
||||
|
||||
public static function failure(string $reason): self
|
||||
{
|
||||
return new self(null, false, $reason);
|
||||
}
|
||||
|
||||
public function succeeded(): bool
|
||||
{
|
||||
return $this->publication !== null;
|
||||
}
|
||||
|
||||
public function wasSkipped(): bool
|
||||
{
|
||||
return $this->skipped;
|
||||
}
|
||||
|
||||
public function failed(): bool
|
||||
{
|
||||
return ! $this->succeeded() && ! $this->skipped;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ public function definition(): array
|
|||
{
|
||||
return [
|
||||
'platform_instance_id' => PlatformInstance::factory(),
|
||||
'channel_id' => $this->faker->slug(2),
|
||||
'channel_id' => $this->faker->unique()->numberBetween(1, 999999),
|
||||
'name' => $this->faker->words(2, true),
|
||||
'display_name' => $this->faker->words(2, true),
|
||||
'language_id' => Language::factory(),
|
||||
|
|
@ -39,7 +39,6 @@ public function community(?string $name = null): static
|
|||
$communityName = $name ?: $this->faker->word();
|
||||
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'channel_id' => strtolower($communityName),
|
||||
'name' => $communityName,
|
||||
'display_name' => ucfirst($communityName),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The mirror was written with Lemmy's numeric community id and read with the
|
||||
* community slug, so duplicate detection never matched (#123). Keying on the
|
||||
* local platform_channels.id removes the ambiguity and the per-instance
|
||||
* collision that both remote identifiers share.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->dropUnique('channel_post_unique');
|
||||
$table->dropIndex(['platform', 'channel_id', 'url']);
|
||||
$table->dropIndex(['platform', 'channel_id', 'title']);
|
||||
$table->unsignedBigInteger('platform_channel_id')->nullable()->after('id');
|
||||
});
|
||||
|
||||
// A name shared by two instances is ambiguous; those rows stay unmapped.
|
||||
DB::table('platform_channel_posts')->orderBy('id')->chunkById(200, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$matches = DB::table('platform_channels')
|
||||
->where('name', $row->channel_name)
|
||||
->orWhere('channel_id', $row->channel_name)
|
||||
->pluck('id');
|
||||
|
||||
if ($matches->count() !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('platform_channel_posts')
|
||||
->where('id', $row->id)
|
||||
->update(['platform_channel_id' => $matches->first()]);
|
||||
}
|
||||
});
|
||||
|
||||
// Unmappable rows are discarded rather than guessed: the mirror is a
|
||||
// cache SyncChannelPostsJob rebuilds every ten minutes.
|
||||
DB::table('platform_channel_posts')->whereNull('platform_channel_id')->delete();
|
||||
|
||||
Schema::table('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('platform_channel_id')->nullable(false)->change();
|
||||
$table->dropColumn(['platform', 'channel_id', 'channel_name']);
|
||||
});
|
||||
|
||||
Schema::table('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->foreign('platform_channel_id')->references('id')->on('platform_channels')->onDelete('cascade');
|
||||
$table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique');
|
||||
$table->index(['platform_channel_id', 'url']);
|
||||
$table->index(['platform_channel_id', 'title']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->dropForeign(['platform_channel_id']);
|
||||
$table->dropUnique('channel_post_unique');
|
||||
$table->dropIndex(['platform_channel_id', 'url']);
|
||||
$table->dropIndex(['platform_channel_id', 'title']);
|
||||
$table->string('platform')->default('lemmy');
|
||||
$table->string('channel_id')->default('');
|
||||
$table->string('channel_name')->nullable();
|
||||
});
|
||||
|
||||
DB::table('platform_channel_posts')->update([
|
||||
'channel_id' => DB::raw('platform_channel_id'),
|
||||
]);
|
||||
|
||||
Schema::table('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->dropColumn('platform_channel_id');
|
||||
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
|
||||
$table->index(['platform', 'channel_id', 'url']);
|
||||
$table->index(['platform', 'channel_id', 'title']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Publishing can be deliberately skipped (already published, duplicate in the
|
||||
* channel, another worker holds the lock). Those were recorded as errors (#123).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('route_articles', function (Blueprint $table) {
|
||||
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'skipped', 'error'])
|
||||
->default('unpublished')
|
||||
->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('route_articles')
|
||||
->where('publish_status', 'skipped')
|
||||
->update(['publish_status' => 'unpublished']);
|
||||
|
||||
Schema::table('route_articles', function (Blueprint $table) {
|
||||
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])
|
||||
->default('unpublished')
|
||||
->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* channel_id held a community slug, resolved to Lemmy's numeric id on every
|
||||
* publish and every sync. It now holds that id directly; `name` remains the slug.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Every id is resolved before any DDL runs: these lookups hit the live
|
||||
// instance and MariaDB will not roll back a schema change if one fails.
|
||||
$resolved = DB::table('platform_channels')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->mapWithKeys(fn (object $channel) => [$channel->id => $this->resolve($channel)]);
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropUnique('platform_channels_channel_id_unique');
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id');
|
||||
});
|
||||
|
||||
foreach ($resolved as $id => $communityId) {
|
||||
DB::table('platform_channels')
|
||||
->where('id', $id)
|
||||
->update(['remote_community_id' => $communityId]);
|
||||
}
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropColumn('channel_id');
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->renameColumn('remote_community_id', 'channel_id');
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('channel_id')->nullable(false)->change();
|
||||
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropUnique('platform_channels_channel_id_unique');
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->string('channel_id')->change();
|
||||
});
|
||||
|
||||
DB::table('platform_channels')->update(['channel_id' => DB::raw('name')]);
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
|
||||
});
|
||||
}
|
||||
|
||||
private function resolve(object $channel): int
|
||||
{
|
||||
if (is_numeric($channel->channel_id)) {
|
||||
return (int) $channel->channel_id;
|
||||
}
|
||||
|
||||
$instance = DB::table('platform_instances')->find($channel->platform_instance_id);
|
||||
|
||||
if (! $instance) {
|
||||
throw new RuntimeException("Channel {$channel->id} has no platform instance; cannot resolve its community id.");
|
||||
}
|
||||
|
||||
$account = PlatformAccount::where('instance_url', $instance->url)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $account) {
|
||||
throw new RuntimeException("No active account for {$instance->url}; cannot resolve community '{$channel->channel_id}'.");
|
||||
}
|
||||
|
||||
$api = new LemmyApiService($instance->url);
|
||||
$token = $api->login($account->username, $account->password);
|
||||
|
||||
if (! $token) {
|
||||
throw new RuntimeException("Could not authenticate against {$instance->url} to resolve community '{$channel->channel_id}'.");
|
||||
}
|
||||
|
||||
return $api->getCommunityId($channel->channel_id, $token);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
# Check required Lemmy environment variables
|
||||
if [ -z "$LEMMY_INSTANCE" ] || [ -z "$LEMMY_USERNAME" ] || [ -z "$LEMMY_PASSWORD" ] || [ -z "$LEMMY_COMMUNITY" ]; then
|
||||
echo "ERROR: Missing required Lemmy configuration variables:"
|
||||
echo " LEMMY_INSTANCE=${LEMMY_INSTANCE:-'(not set)'}"
|
||||
echo " LEMMY_USERNAME=${LEMMY_USERNAME:-'(not set)'}"
|
||||
echo " LEMMY_PASSWORD=${LEMMY_PASSWORD:-'(not set)'}"
|
||||
echo " LEMMY_COMMUNITY=${LEMMY_COMMUNITY:-'(not set)'}"
|
||||
echo "Please set all required environment variables before starting the application."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database connection..."
|
||||
until php /docker/wait-for-db.php > /dev/null 2>&1; do
|
||||
echo "Database not ready, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Database connection established."
|
||||
|
||||
# Wait for Redis to be ready
|
||||
echo "Waiting for Redis connection..."
|
||||
until php /docker/wait-for-redis.php > /dev/null 2>&1; do
|
||||
echo "Redis not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Redis connection established."
|
||||
|
||||
# Substitute environment variables in .env file
|
||||
echo "Configuring environment variables..."
|
||||
envsubst < .env > .env.tmp && mv .env.tmp .env
|
||||
|
||||
# Run migrations and initial setup
|
||||
echo "Running database migrations..."
|
||||
php artisan migrate --force
|
||||
|
||||
echo "Dispatching initial sync job..."
|
||||
php artisan tinker --execute="App\\Jobs\\SyncChannelPostsJob::dispatchForLemmy();"
|
||||
|
||||
# Start all services in single container
|
||||
echo "Starting web server, scheduler, and Horizon..."
|
||||
php artisan schedule:work &
|
||||
php artisan horizon &
|
||||
php artisan serve --host=0.0.0.0 --port=8000 &
|
||||
|
||||
# Wait for any process to exit
|
||||
wait
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
APP_NAME="Lemmy Poster"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=$DB_DATABASE
|
||||
DB_USERNAME=$DB_USERNAME
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# LEMMY SETTINGS
|
||||
LEMMY_INSTANCE=
|
||||
LEMMY_USERNAME=
|
||||
LEMMY_PASSWORD=
|
||||
LEMMY_COMMUNITY=
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
'mysql:host=mysql;port=3306;dbname=' . getenv('DB_DATABASE'),
|
||||
getenv('DB_USERNAME'),
|
||||
getenv('DB_PASSWORD')
|
||||
);
|
||||
echo 'Connected';
|
||||
exit(0);
|
||||
} catch (Exception $e) {
|
||||
exit(1);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
try {
|
||||
$redis = new Redis();
|
||||
$redis->connect('redis', 6379);
|
||||
echo 'Connected';
|
||||
exit(0);
|
||||
} catch (Exception $e) {
|
||||
exit(1);
|
||||
}
|
||||
|
|
@ -162,22 +162,11 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado
|
|||
@if ($showCreateModal)
|
||||
<x-form-modal title="Add Channel" close="closeCreateModal">
|
||||
<form wire:submit="createChannel" class="space-y-4">
|
||||
<div>
|
||||
<label for="new-channel-name" class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-channel-name"
|
||||
wire:model="newName"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
/>
|
||||
@error('newName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-channel-instance" class="block text-sm font-medium text-gray-700">Platform Instance</label>
|
||||
<select
|
||||
id="new-channel-instance"
|
||||
wire:model="newPlatformInstanceId"
|
||||
wire:model.live="newPlatformInstanceId"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select an instance</option>
|
||||
|
|
@ -186,8 +175,37 @@ class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:borde
|
|||
@endforeach
|
||||
</select>
|
||||
@error('newPlatformInstanceId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
@if ($communityLoadError)
|
||||
<p class="mt-1 text-sm text-red-600">{{ $communityLoadError }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($availableCommunities)
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="new-channel-community" class="block text-sm font-medium text-gray-700">Community</label>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="refreshCommunities"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
id="new-channel-community"
|
||||
wire:model="newCommunityId"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a community</option>
|
||||
@foreach ($availableCommunities as $community)
|
||||
<option value="{{ $community['id'] }}">{{ $community['title'] }} ({{ $community['name'] }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newCommunityId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label for="new-channel-language" class="block text-sm font-medium text-gray-700">Language <span class="text-gray-400">(optional)</span></label>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -196,29 +196,13 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label for="channelName" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Community Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="channelName"
|
||||
wire:model="channelName"
|
||||
placeholder="technology"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
|
||||
@error('channelName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="platformInstanceId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Platform Instance
|
||||
</label>
|
||||
<select
|
||||
id="platformInstanceId"
|
||||
wire:model="platformInstanceId"
|
||||
wire:model.live="platformInstanceId"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
|
|
@ -228,8 +212,40 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
|
|||
@endforeach
|
||||
</select>
|
||||
@error('platformInstanceId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
@if ($communityLoadError)
|
||||
<p class="text-red-600 text-sm mt-1">{{ $communityLoadError }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($availableCommunities)
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="channelCommunityId" class="block text-sm font-medium text-gray-700">
|
||||
Community
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="refreshCommunities"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
id="channelCommunityId"
|
||||
wire:model="channelCommunityId"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select a community</option>
|
||||
@foreach ($availableCommunities as $community)
|
||||
<option value="{{ $community['id'] }}">{{ $community['title'] }} ({{ $community['name'] }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('channelCommunityId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label for="channelLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Language
|
||||
|
|
|
|||
82
tests/Feature/CommunityDirectoryTest.php
Normal file
82
tests/Feature/CommunityDirectoryTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Services\Platform\CommunityDirectory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CommunityDirectoryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private PlatformInstance $instance;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||||
|
||||
Http::fake(['*/api/v3/community/list*' => Http::response([
|
||||
'communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
||||
['community' => ['id' => 42, 'name' => '42', 'title' => 'Forty Two']],
|
||||
],
|
||||
])]);
|
||||
}
|
||||
|
||||
private function directory(): CommunityDirectory
|
||||
{
|
||||
return app(CommunityDirectory::class);
|
||||
}
|
||||
|
||||
public function test_it_fetches_communities_for_an_instance(): void
|
||||
{
|
||||
$this->assertSame([42, 8], collect($this->directory()->forInstance($this->instance))->pluck('id')->all());
|
||||
}
|
||||
|
||||
public function test_it_only_calls_the_instance_once_per_cache_window(): void
|
||||
{
|
||||
$this->directory()->forInstance($this->instance);
|
||||
$this->directory()->forInstance($this->instance);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_forget_causes_a_refetch(): void
|
||||
{
|
||||
$this->directory()->forInstance($this->instance);
|
||||
$this->directory()->forget($this->instance);
|
||||
$this->directory()->forInstance($this->instance);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_it_caches_per_instance(): void
|
||||
{
|
||||
$other = PlatformInstance::factory()->create(['url' => 'https://other.test']);
|
||||
|
||||
$this->directory()->forInstance($this->instance);
|
||||
$this->directory()->forInstance($other);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_has_reports_membership(): void
|
||||
{
|
||||
$this->assertTrue($this->directory()->has($this->instance, 8));
|
||||
$this->assertFalse($this->directory()->has($this->instance, 4242));
|
||||
}
|
||||
|
||||
public function test_a_numerically_named_community_keeps_its_own_id(): void
|
||||
{
|
||||
// The community is named "42" but its id is also 42 by coincidence; the
|
||||
// name must never be read as an id.
|
||||
$this->assertSame('42', $this->directory()->name($this->instance, 42));
|
||||
$this->assertTrue($this->directory()->has($this->instance, 42));
|
||||
}
|
||||
}
|
||||
231
tests/Feature/DuplicatePublishTest.php
Normal file
231
tests/Feature/DuplicatePublishTest.php
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Listeners\PublishApprovedArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Reproduces #123: one article reaching a Lemmy community twice.
|
||||
*
|
||||
* These drive the real listener and the real publishing service, faking only
|
||||
* the Lemmy boundary, so the guard and the lock are actually exercised.
|
||||
*/
|
||||
class DuplicatePublishTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @var array{RouteArticle, PlatformChannel, Article} */
|
||||
private array $fixture;
|
||||
|
||||
private int $remoteCalls = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]);
|
||||
$account = PlatformAccount::factory()->create();
|
||||
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
]);
|
||||
|
||||
$channel->platformAccounts()->attach($account->id, ['is_active' => true, 'priority' => 50]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->create([
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
$this->fixture = [$routeArticle, $channel, $article];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Real publishing service with only the Lemmy call faked, counting how many
|
||||
* posts would actually be created remotely.
|
||||
*/
|
||||
private function makeListener(): PublishApprovedArticleListener
|
||||
{
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldReceive('publishToChannel')
|
||||
->andReturnUsing(function () {
|
||||
$this->remoteCalls++;
|
||||
|
||||
return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]];
|
||||
});
|
||||
|
||||
$service = Mockery::mock(
|
||||
ArticlePublishingService::class,
|
||||
[app(LogSaver::class)]
|
||||
)->makePartial();
|
||||
$service->shouldAllowMockingProtectedMethods();
|
||||
$service->shouldReceive('makePublisher')->andReturn($publisher);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
|
||||
|
||||
return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
|
||||
}
|
||||
|
||||
public function test_clicking_approve_twice_dispatches_only_one_event(): void
|
||||
{
|
||||
Event::fake([RouteArticleApproved::class]);
|
||||
|
||||
[$routeArticle] = $this->fixture;
|
||||
|
||||
// The double-click: approve() must not dispatch a second time.
|
||||
$routeArticle->approve();
|
||||
$routeArticle->approve();
|
||||
|
||||
Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
|
||||
}
|
||||
|
||||
public function test_clicking_approve_twice_creates_only_one_remote_post(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
$listener = $this->makeListener();
|
||||
|
||||
$routeArticle->approve();
|
||||
$listener->handle(new RouteArticleApproved($routeArticle->fresh()));
|
||||
|
||||
$routeArticle->approve();
|
||||
$listener->handle(new RouteArticleApproved($routeArticle->fresh()));
|
||||
|
||||
$this->assertSame(1, $this->remoteCalls, 'A second approval must not post to Lemmy again.');
|
||||
|
||||
$this->assertSame(1, ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_two_queued_listeners_create_only_one_remote_post(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
// Two listeners already in flight. Running them back to back would not
|
||||
// reproduce anything — the second would see the first's publication row
|
||||
// and stop. The real race interleaves: the second listener reaches its
|
||||
// duplicate check while the first is still inside its Lemmy call, before
|
||||
// any row exists. That window is what the lock has to close.
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldReceive('publishToChannel')
|
||||
->andReturnUsing(function () use ($routeArticle) {
|
||||
$this->remoteCalls++;
|
||||
|
||||
if ($this->remoteCalls === 1) {
|
||||
$this->makeListener()->handle(new RouteArticleApproved($routeArticle->fresh()));
|
||||
}
|
||||
|
||||
return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]];
|
||||
});
|
||||
|
||||
$service = Mockery::mock(
|
||||
ArticlePublishingService::class,
|
||||
[app(LogSaver::class)]
|
||||
)->makePartial();
|
||||
$service->shouldAllowMockingProtectedMethods();
|
||||
$service->shouldReceive('makePublisher')->andReturn($publisher);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
|
||||
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertSame(1, $this->remoteCalls, 'Two listeners must not both post to Lemmy.');
|
||||
|
||||
$this->assertSame(1, ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the #123 mechanism. Before b2d504f, approve() had no isApproved()
|
||||
* guard, so two calls dispatched two events. Two stale instances of the same
|
||||
* row reproduce that: each still reads PENDING, so the guard cannot fire and
|
||||
* the pre-fix code path runs.
|
||||
*/
|
||||
public function test_without_the_approved_guard_two_approvals_dispatch_two_events(): void
|
||||
{
|
||||
Event::fake([RouteArticleApproved::class]);
|
||||
|
||||
[$routeArticle] = $this->fixture;
|
||||
|
||||
$first = RouteArticle::find($routeArticle->id);
|
||||
$second = RouteArticle::find($routeArticle->id);
|
||||
|
||||
$this->assertTrue($second->isPending(), 'Both instances must start pending for the race to be reproduced.');
|
||||
|
||||
$first->approve();
|
||||
$second->approve();
|
||||
|
||||
Event::assertDispatchedTimes(RouteArticleApproved::class, 2);
|
||||
}
|
||||
|
||||
public function test_two_stale_approvals_still_create_only_one_remote_post(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
$first = RouteArticle::find($routeArticle->id);
|
||||
$second = RouteArticle::find($routeArticle->id);
|
||||
|
||||
$first->approve();
|
||||
$second->approve();
|
||||
|
||||
$listener = $this->makeListener();
|
||||
$listener->handle(new RouteArticleApproved($first));
|
||||
$listener->handle(new RouteArticleApproved($second));
|
||||
|
||||
$this->assertSame(1, $this->remoteCalls, 'The lock must hold even when two events get through.');
|
||||
|
||||
$this->assertSame(1, ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_a_single_approval_publishes_exactly_once(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
$this->makeListener()->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertSame(1, $this->remoteCalls);
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
||||
$this->assertSame(1, ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->count());
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,32 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PlatformChannelsControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private bool $instanceReachable = true;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
// A stub registered here cannot be overridden by a later Http::fake() for the
|
||||
// same pattern, so tests toggle the outcome through $instanceReachable instead.
|
||||
Http::fake(['*/api/v3/community/list*' => fn () => $this->instanceReachable
|
||||
? Http::response([
|
||||
'communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'test_channel', 'title' => 'Test Channel']],
|
||||
['community' => ['id' => 9, 'name' => 'tech_news', 'title' => 'Tech News']],
|
||||
],
|
||||
])
|
||||
: Http::response([], 503)]);
|
||||
}
|
||||
|
||||
public function test_index_returns_successful_response(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
|
@ -56,7 +76,7 @@ public function test_store_creates_platform_channel_successfully(): void
|
|||
|
||||
$data = [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'test_channel',
|
||||
'channel_id' => 8,
|
||||
'description' => 'A test channel',
|
||||
];
|
||||
|
||||
|
|
@ -85,8 +105,7 @@ public function test_store_creates_platform_channel_successfully(): void
|
|||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'channel_id' => 'test_channel',
|
||||
'name' => 'test_channel',
|
||||
'channel_id' => 8,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -95,14 +114,14 @@ public function test_store_validates_required_fields(): void
|
|||
$response = $this->postJson('/api/v1/platform-channels', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['platform_instance_id', 'name']);
|
||||
->assertJsonValidationErrors(['platform_instance_id', 'channel_id']);
|
||||
}
|
||||
|
||||
public function test_store_validates_platform_instance_exists(): void
|
||||
{
|
||||
$data = [
|
||||
'platform_instance_id' => 999,
|
||||
'name' => 'Test Channel',
|
||||
'channel_id' => 8,
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', $data);
|
||||
|
|
@ -111,44 +130,62 @@ public function test_store_validates_platform_instance_exists(): void
|
|||
->assertJsonValidationErrors(['platform_instance_id']);
|
||||
}
|
||||
|
||||
public function test_store_rejects_non_slug_name(): void
|
||||
public function test_store_rejects_community_not_on_instance(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
||||
// name is copied verbatim into channel_id and used as the Lemmy community
|
||||
// reference, so non-slug values must be rejected at the API boundary too.
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'Tech News',
|
||||
'channel_id' => 4242,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name']);
|
||||
->assertJsonValidationErrors(['channel_id']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_store_rejects_duplicate_name_for_same_instance(): void
|
||||
public function test_store_distinguishes_an_unreachable_instance_from_a_missing_community(): void
|
||||
{
|
||||
$this->instanceReachable = false;
|
||||
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => $instance->url,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'channel_id' => 8,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$this->assertStringContainsString('Could not reach this instance', $response->json('message'));
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_store_rejects_duplicate_community_for_same_instance(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 'tech_news',
|
||||
'channel_id' => 9,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 9,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name']);
|
||||
->assertJsonValidationErrors(['channel_id']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 1);
|
||||
}
|
||||
|
||||
public function test_store_allows_same_name_on_different_instance(): void
|
||||
public function test_store_allows_same_community_on_different_instance(): void
|
||||
{
|
||||
$instanceA = PlatformInstance::factory()->create();
|
||||
$instanceB = PlatformInstance::factory()->create();
|
||||
|
|
@ -161,12 +198,12 @@ public function test_store_allows_same_name_on_different_instance(): void
|
|||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceA->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 'tech_news',
|
||||
'channel_id' => 9,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 9,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
|
|
|||
130
tests/Feature/KeyPlatformChannelPostsMigrationTest.php
Normal file
130
tests/Feature/KeyPlatformChannelPostsMigrationTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformChannelPost;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class KeyPlatformChannelPostsMigrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2024_01_01_000013_key_platform_channel_posts_by_local_channel.php');
|
||||
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
private function restoreLegacyTable(): void
|
||||
{
|
||||
Schema::dropIfExists('platform_channel_posts');
|
||||
|
||||
Schema::create('platform_channel_posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('platform');
|
||||
$table->string('channel_id');
|
||||
$table->string('channel_name')->nullable();
|
||||
$table->string('post_id');
|
||||
$table->string('title')->nullable();
|
||||
$table->string('url')->nullable();
|
||||
$table->timestamp('posted_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
|
||||
$table->index(['platform', 'channel_id', 'url']);
|
||||
$table->index(['platform', 'channel_id', 'title']);
|
||||
});
|
||||
}
|
||||
|
||||
private function seedLegacyRow(string $channelId, ?string $channelName, string $postId): void
|
||||
{
|
||||
DB::table('platform_channel_posts')->insert([
|
||||
'platform' => 'lemmy',
|
||||
'channel_id' => $channelId,
|
||||
'channel_name' => $channelName,
|
||||
'post_id' => $postId,
|
||||
'url' => "https://news.test/{$postId}",
|
||||
'title' => "Post {$postId}",
|
||||
'posted_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_maps_rows_to_the_local_channel_by_name(): void
|
||||
{
|
||||
$this->restoreLegacyTable();
|
||||
|
||||
$channel = PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
|
||||
$this->seedLegacyRow('217', 'newsbottest', '1');
|
||||
$this->seedLegacyRow('217', 'newsbottest', '2');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(2, DB::table('platform_channel_posts')
|
||||
->where('platform_channel_id', $channel->id)->count());
|
||||
}
|
||||
|
||||
public function test_drops_rows_that_match_no_channel(): void
|
||||
{
|
||||
$this->restoreLegacyTable();
|
||||
|
||||
PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
|
||||
$this->seedLegacyRow('999', 'a-community-that-no-longer-exists', '1');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(0, DB::table('platform_channel_posts')->count());
|
||||
}
|
||||
|
||||
public function test_drops_rows_whose_channel_name_is_ambiguous_across_instances(): void
|
||||
{
|
||||
$this->restoreLegacyTable();
|
||||
|
||||
$first = PlatformInstance::factory()->create(['url' => 'https://one.test']);
|
||||
$second = PlatformInstance::factory()->create(['url' => 'https://two.test']);
|
||||
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $first->id,
|
||||
'name' => 'news',
|
||||
'channel_id' => 'news',
|
||||
]);
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $second->id,
|
||||
'name' => 'news',
|
||||
'channel_id' => 'news-two',
|
||||
]);
|
||||
|
||||
$this->seedLegacyRow('8', 'news', '1');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(0, DB::table('platform_channel_posts')->count());
|
||||
}
|
||||
|
||||
public function test_leaves_a_schema_the_new_code_can_use(): void
|
||||
{
|
||||
$this->restoreLegacyTable();
|
||||
|
||||
$channel = PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
|
||||
$this->seedLegacyRow('217', 'newsbottest', '1');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertTrue(Schema::hasColumn('platform_channel_posts', 'platform_channel_id'));
|
||||
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'channel_id'));
|
||||
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'channel_name'));
|
||||
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'platform'));
|
||||
|
||||
$this->assertTrue(
|
||||
PlatformChannelPost::duplicateExists($channel, 'https://news.test/1', 'Post 1')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Feature\Listeners;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use App\Services\Publishing\PublishOutcome;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
|
|
@ -53,7 +55,7 @@ public function test_exception_during_publishing_creates_error_notification(): v
|
|||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
|
|
@ -82,9 +84,9 @@ public function test_no_publication_created_creates_warning_notification(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
->andReturn(PublishOutcome::failure('No publication created'));
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
|
|
@ -112,9 +114,9 @@ public function test_successful_publish_does_not_create_notification(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
|
|
@ -135,7 +137,7 @@ public function test_skips_already_published_to_channel(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -146,4 +148,12 @@ protected function tearDown(): void
|
|||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePublication(): ArticlePublication
|
||||
{
|
||||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->make();
|
||||
|
||||
return $publication;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Factory;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -15,6 +18,23 @@ class ChannelsTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->fakeCommunities();
|
||||
}
|
||||
|
||||
private function fakeCommunities(): void
|
||||
{
|
||||
Http::fake(['*/api/v3/community/list*' => Http::response([
|
||||
'communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'tech_community', 'title' => 'Tech Community']],
|
||||
['community' => ['id' => 9, 'name' => 'other_community', 'title' => 'Other Community']],
|
||||
],
|
||||
])]);
|
||||
}
|
||||
|
||||
private function instanceWithActiveAccount(): PlatformInstance
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
|
|
@ -50,7 +70,7 @@ public function test_open_create_modal_shows_modal(): void
|
|||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_name(): void
|
||||
public function test_create_channel_requires_community(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
|
|
@ -58,14 +78,44 @@ public function test_create_channel_requires_name(): void
|
|||
->call('openCreateModal')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'required']);
|
||||
->assertHasErrors(['newCommunityId' => 'required']);
|
||||
}
|
||||
|
||||
public function test_selecting_an_instance_loads_its_communities(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->assertSet('communityLoadError', null)
|
||||
->assertCount('availableCommunities', 2);
|
||||
}
|
||||
|
||||
public function test_unreachable_instance_surfaces_an_error_and_no_communities(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://unreachable.test']);
|
||||
PlatformAccount::factory()->create(['instance_url' => 'https://unreachable.test', 'is_active' => true]);
|
||||
Cache::flush();
|
||||
|
||||
// Http::fake() merges stubs, so setUp's success stub would still win —
|
||||
// swap the whole fake out instead.
|
||||
app()->forgetInstance(Factory::class);
|
||||
Http::swap(new Factory);
|
||||
Http::fake(['*' => Http::response('nope', 500)]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->assertSet('availableCommunities', [])
|
||||
->assertNotSet('communityLoadError', null);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_platform_instance(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newCommunityId', 8)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newPlatformInstanceId' => 'required']);
|
||||
}
|
||||
|
|
@ -77,8 +127,8 @@ public function test_create_channel_succeeds_and_attaches_account(): void
|
|||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newCommunityId', 8)
|
||||
->set('newLanguageId', $language->id)
|
||||
->set('newDescription', 'A tech community')
|
||||
->call('createChannel')
|
||||
|
|
@ -104,8 +154,8 @@ public function test_create_channel_leaves_description_null_when_blank(): void
|
|||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newCommunityId', 8)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
|
|
@ -115,40 +165,41 @@ public function test_create_channel_leaves_description_null_when_blank(): void
|
|||
]);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_non_slug_name(): void
|
||||
public function test_create_channel_rejects_a_community_not_on_the_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newCommunityId', 4242)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'regex']);
|
||||
->assertHasErrors(['newCommunityId' => 'in']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_duplicate_name_on_same_instance(): void
|
||||
public function test_create_channel_rejects_duplicate_community_on_same_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_community',
|
||||
'channel_id' => 8,
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newCommunityId', 8)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'unique'])
|
||||
->assertHasErrors(['newCommunityId' => 'unique'])
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 1);
|
||||
}
|
||||
|
||||
public function test_create_channel_allows_same_name_on_different_instance(): void
|
||||
public function test_create_channel_allows_same_community_on_different_instance(): void
|
||||
{
|
||||
$instanceA = $this->instanceWithActiveAccount();
|
||||
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
|
||||
|
|
@ -159,12 +210,13 @@ public function test_create_channel_allows_same_name_on_different_instance(): vo
|
|||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'name' => 'tech_community',
|
||||
'channel_id' => 8,
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instanceA->id)
|
||||
->set('newCommunityId', 8)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
|
|
@ -178,8 +230,8 @@ public function test_create_channel_surfaces_no_active_accounts_error(): void
|
|||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newCommunityId', 8)
|
||||
->call('createChannel')
|
||||
->assertHasErrors('newPlatformInstanceId')
|
||||
->assertSet('showCreateModal', true);
|
||||
|
|
|
|||
202
tests/Feature/Livewire/OnboardingTest.php
Normal file
202
tests/Feature/Livewire/OnboardingTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Jobs\SyncChannelPostsJob;
|
||||
use App\Livewire\Onboarding;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Factory;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OnboardingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
$this->fakeCommunities();
|
||||
}
|
||||
|
||||
private function fakeCommunities(): void
|
||||
{
|
||||
Http::fake(['*/api/v3/community/list*' => Http::response([
|
||||
'communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'tech_community', 'title' => 'Tech Community']],
|
||||
['community' => ['id' => 9, 'name' => 'other_community', 'title' => 'Other Community']],
|
||||
],
|
||||
])]);
|
||||
}
|
||||
|
||||
private function instanceWithActiveAccount(): PlatformInstance
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => 'https://lemmy.world',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function test_selecting_an_instance_loads_its_communities(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->assertSet('communityLoadError', null)
|
||||
->assertCount('availableCommunities', 2);
|
||||
}
|
||||
|
||||
public function test_changing_the_instance_clears_the_previous_selection(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$other = PlatformInstance::factory()->create(['url' => 'https://other.test']);
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelCommunityId', 8)
|
||||
->set('platformInstanceId', $other->id)
|
||||
->assertSet('channelCommunityId', null);
|
||||
}
|
||||
|
||||
public function test_unreachable_instance_surfaces_an_error_and_no_communities(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://unreachable.test']);
|
||||
PlatformAccount::factory()->create(['instance_url' => 'https://unreachable.test', 'is_active' => true]);
|
||||
Cache::flush();
|
||||
|
||||
// Http::fake() merges stubs, so setUp's success stub would still win —
|
||||
// swap the whole fake out instead.
|
||||
app()->forgetInstance(Factory::class);
|
||||
Http::swap(new Factory);
|
||||
Http::fake(['*' => Http::response('nope', 500)]);
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->assertSet('availableCommunities', [])
|
||||
->assertNotSet('communityLoadError', null);
|
||||
}
|
||||
|
||||
public function test_refresh_communities_refetches_from_the_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->call('refreshCommunities')
|
||||
->assertCount('availableCommunities', 2);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_a_community(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelLanguageId', $language->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['channelCommunityId' => 'required']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_a_community_not_on_the_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelCommunityId', 4242)
|
||||
->set('channelLanguageId', $language->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['channelCommunityId' => 'in']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_create_channel_stores_the_numeric_id_and_the_community_name(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelCommunityId', 8)
|
||||
->set('channelLanguageId', $language->id)
|
||||
->set('channelDescription', 'A tech community')
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'channel_id' => 8,
|
||||
'name' => 'tech_community',
|
||||
'description' => 'A tech community',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_channel_advances_the_wizard_and_syncs_existing_posts(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('step', 3)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelCommunityId', 8)
|
||||
->set('channelLanguageId', $language->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('step', 4);
|
||||
|
||||
Queue::assertPushed(SyncChannelPostsJob::class);
|
||||
}
|
||||
|
||||
public function test_create_channel_reports_when_the_instance_has_no_active_account(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->set('platformInstanceId', $instance->id)
|
||||
->set('channelCommunityId', 8)
|
||||
->set('channelLanguageId', $language->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('formErrors.general', 'No active platform accounts found for this instance. Please create a platform account first.');
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_mount_prefills_the_community_select_from_an_existing_channel(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'channel_id' => 8,
|
||||
'name' => 'tech_community',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(Onboarding::class)
|
||||
->assertSet('platformInstanceId', $instance->id)
|
||||
->assertSet('channelCommunityId', 8)
|
||||
->assertCount('availableCommunities', 2);
|
||||
}
|
||||
}
|
||||
197
tests/Feature/MirrorDuplicateDetectionTest.php
Normal file
197
tests/Feature/MirrorDuplicateDetectionTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Listeners\PublishApprovedArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformChannelPost;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MirrorDuplicateDetectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @var array{RouteArticle, PlatformChannel, Article} */
|
||||
private array $fixture;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||||
$channel = PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'channel_id' => 'news',
|
||||
'name' => 'news',
|
||||
]);
|
||||
$account = PlatformAccount::factory()->create(['instance_url' => 'https://lemmy.test']);
|
||||
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
]);
|
||||
|
||||
$channel->platformAccounts()->attach($account->id, ['is_active' => true, 'priority' => 50]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://news.test/already-posted',
|
||||
]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
$this->fixture = [$routeArticle, $channel, $article];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function service(?LemmyPublisher $publisher = null): ArticlePublishingService
|
||||
{
|
||||
$service = Mockery::mock(ArticlePublishingService::class, [app(LogSaver::class)])->makePartial();
|
||||
$service->shouldAllowMockingProtectedMethods();
|
||||
$service->shouldReceive('makePublisher')->andReturn(
|
||||
$publisher ?? Mockery::mock(LemmyPublisher::class)
|
||||
);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function test_sync_writes_the_key_the_duplicate_check_reads(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
// Populate the mirror through the real sync path rather than seeding a
|
||||
// row by hand, so write and read cannot silently disagree.
|
||||
Http::fake([
|
||||
'*/api/v3/post/list*' => Http::response([
|
||||
'posts' => [[
|
||||
'post' => [
|
||||
'id' => 555,
|
||||
'url' => $article->url,
|
||||
'name' => 'Already Posted',
|
||||
'published' => '2026-08-02T10:00:00Z',
|
||||
],
|
||||
]],
|
||||
]),
|
||||
]);
|
||||
|
||||
(new LemmyApiService('https://lemmy.test'))
|
||||
->syncChannelPosts('token', $channel, 8);
|
||||
|
||||
$this->assertDatabaseCount('platform_channel_posts', 1);
|
||||
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldNotReceive('publishToChannel');
|
||||
|
||||
$result = $this->service($publisher)->publishRouteArticle($routeArticle, ['title' => 'Already Posted']);
|
||||
|
||||
$this->assertTrue($result->wasSkipped());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
public function test_a_skipped_duplicate_is_not_reported_as_a_publish_failure(): void
|
||||
{
|
||||
[$routeArticle, $channel, $article] = $this->fixture;
|
||||
|
||||
PlatformChannelPost::storePost($channel, '555', $article->url, 'Already Posted');
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Already Posted']);
|
||||
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldNotReceive('publishToChannel');
|
||||
|
||||
(new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $this->service($publisher), new NotificationService)))
|
||||
->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertSame(PublishStatusEnum::SKIPPED, $routeArticle->fresh()->publish_status);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
public function test_a_genuine_failure_is_still_reported(): void
|
||||
{
|
||||
[$routeArticle] = $this->fixture;
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Some Title']);
|
||||
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldReceive('publishToChannel')->andThrow(new \RuntimeException('Lemmy rejected the post'));
|
||||
|
||||
(new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $this->service($publisher), new NotificationService)))
|
||||
->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_mirror_is_keyed_by_the_local_channel(): void
|
||||
{
|
||||
[, $channel, $article] = $this->fixture;
|
||||
|
||||
PlatformChannelPost::storePost(
|
||||
$channel,
|
||||
'555',
|
||||
$article->url,
|
||||
'Already Posted',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('platform_channel_posts', [
|
||||
'platform_channel_id' => $channel->id,
|
||||
'post_id' => '555',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_second_instance_with_the_same_community_name_does_not_collide(): void
|
||||
{
|
||||
[, $channel, $article] = $this->fixture;
|
||||
|
||||
$otherInstance = PlatformInstance::factory()->create(['url' => 'https://other.test']);
|
||||
$otherChannel = PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $otherInstance->id,
|
||||
'channel_id' => 'news',
|
||||
'name' => 'news',
|
||||
]);
|
||||
|
||||
PlatformChannelPost::storePost($channel, '1', $article->url, 'Same Title');
|
||||
|
||||
// Same community name on a different instance is a different community.
|
||||
$this->assertFalse(
|
||||
PlatformChannelPost::duplicateExists($otherChannel, $article->url, 'Same Title')
|
||||
);
|
||||
|
||||
$this->assertTrue(
|
||||
PlatformChannelPost::duplicateExists($channel, $article->url, 'Same Title')
|
||||
);
|
||||
}
|
||||
}
|
||||
110
tests/Feature/StoreCommunityIdMigrationTest.php
Normal file
110
tests/Feature/StoreCommunityIdMigrationTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreCommunityIdMigrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2024_01_01_000015_store_numeric_community_id_on_platform_channels.php');
|
||||
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
private function restoreSlugColumn(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropUnique('platform_channels_channel_id_unique');
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->string('channel_id')->change();
|
||||
});
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
|
||||
});
|
||||
}
|
||||
|
||||
private function seedChannel(PlatformInstance $instance, string $slug): int
|
||||
{
|
||||
return DB::table('platform_channels')->insertGetId([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => $slug,
|
||||
'display_name' => ucfirst($slug),
|
||||
'channel_id' => $slug,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function instanceWithAccount(): PlatformInstance
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||||
PlatformAccount::factory()->create(['instance_url' => 'https://lemmy.test', 'is_active' => true]);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function test_it_replaces_the_slug_with_the_resolved_community_id(): void
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
DB::table('platform_channels')->delete();
|
||||
|
||||
$instance = $this->instanceWithAccount();
|
||||
$id = $this->seedChannel($instance, 'news');
|
||||
|
||||
Http::fake([
|
||||
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
|
||||
'*/api/v3/community*' => Http::response(['community_view' => ['community' => ['id' => 8]]]),
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(8, (int) DB::table('platform_channels')->where('id', $id)->value('channel_id'));
|
||||
$this->assertSame('news', DB::table('platform_channels')->where('id', $id)->value('name'));
|
||||
}
|
||||
|
||||
public function test_it_aborts_when_a_community_cannot_be_resolved(): void
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
DB::table('platform_channels')->delete();
|
||||
|
||||
$instance = $this->instanceWithAccount();
|
||||
$this->seedChannel($instance, 'gone');
|
||||
|
||||
Http::fake([
|
||||
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
|
||||
'*/api/v3/community*' => Http::response('not found', 404),
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$this->runMigration();
|
||||
}
|
||||
|
||||
public function test_it_aborts_when_the_instance_has_no_active_account(): void
|
||||
{
|
||||
$this->restoreSlugColumn();
|
||||
DB::table('platform_channels')->delete();
|
||||
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://no-account.test']);
|
||||
$this->seedChannel($instance, 'news');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->runMigration();
|
||||
}
|
||||
}
|
||||
|
|
@ -31,11 +31,14 @@ public function test_creates_channel_and_attaches_account(): void
|
|||
]);
|
||||
$language = Language::factory()->create();
|
||||
|
||||
$channel = $this->action->execute('test_community', $instance->id, $language->id, 'A description');
|
||||
$channel = $this->action->execute(
|
||||
'test_community',
|
||||
8,
|
||||
$instance->id, $language->id, 'A description');
|
||||
|
||||
$this->assertInstanceOf(PlatformChannel::class, $channel);
|
||||
$this->assertEquals('test_community', $channel->name);
|
||||
$this->assertEquals('test_community', $channel->channel_id);
|
||||
$this->assertSame(8, $channel->channel_id);
|
||||
$this->assertEquals('Test_community', $channel->display_name);
|
||||
$this->assertEquals($instance->id, $channel->platform_instance_id);
|
||||
$this->assertEquals($language->id, $channel->language_id);
|
||||
|
|
@ -55,7 +58,10 @@ public function test_creates_channel_without_language(): void
|
|||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$channel = $this->action->execute('test_community', $instance->id);
|
||||
$channel = $this->action->execute(
|
||||
'test_community',
|
||||
8,
|
||||
$instance->id);
|
||||
|
||||
$this->assertNull($channel->language_id);
|
||||
}
|
||||
|
|
@ -72,7 +78,10 @@ public function test_fails_when_no_active_accounts(): void
|
|||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('No active platform accounts found for this instance');
|
||||
|
||||
$this->action->execute('test_community', $instance->id);
|
||||
$this->action->execute(
|
||||
'test_community',
|
||||
8,
|
||||
$instance->id);
|
||||
}
|
||||
|
||||
public function test_fails_when_no_accounts_at_all(): void
|
||||
|
|
@ -81,6 +90,9 @@ public function test_fails_when_no_accounts_at_all(): void
|
|||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->action->execute('test_community', $instance->id);
|
||||
$this->action->execute(
|
||||
'test_community',
|
||||
8,
|
||||
$instance->id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Exceptions\PublishException;
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use App\Services\Publishing\PublishOutcome;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
|
@ -95,7 +97,7 @@ public function test_handle_returns_early_when_no_approved_route_articles(): voi
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -114,7 +116,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_route_art
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -132,7 +134,7 @@ public function test_handle_skips_non_approved_route_articles(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -170,10 +172,10 @@ public function test_handle_publishes_oldest_approved_route_article(): void
|
|||
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
|
||||
$extractedData
|
||||
)
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -200,7 +202,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void
|
|||
|
||||
$this->expectException(PublishException::class);
|
||||
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
}
|
||||
|
||||
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
|
||||
|
|
@ -219,7 +221,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
|
|||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -243,10 +245,10 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -270,10 +272,10 @@ public function test_handle_publishes_when_interval_is_zero(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -297,10 +299,10 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -321,10 +323,10 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -343,10 +345,10 @@ public function test_handle_creates_warning_notification_when_no_publication_cre
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
->andReturn(PublishOutcome::failure('No publication created'));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
|
|
@ -380,7 +382,7 @@ public function test_handle_creates_notification_on_publish_exception(): void
|
|||
$job = new PublishNextArticleJob;
|
||||
|
||||
try {
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||
} catch (PublishException) {
|
||||
// Expected
|
||||
}
|
||||
|
|
@ -413,4 +415,12 @@ protected function tearDown(): void
|
|||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePublication(): ArticlePublication
|
||||
{
|
||||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->make();
|
||||
|
||||
return $publication;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,22 +133,18 @@ public function test_handle_logs_start_message(): void
|
|||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_sync_resolves_non_numeric_channel_id_via_get_community_id(): void
|
||||
public function test_sync_passes_the_stored_community_id(): void
|
||||
{
|
||||
[$channel, $account] = $this->makeSyncableChannel('tech_news');
|
||||
[$channel, $account] = $this->makeSyncableChannel(42);
|
||||
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('login')
|
||||
->once()
|
||||
->with($account->username, $account->password)
|
||||
->andReturn('token');
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('tech_news', 'token')
|
||||
->andReturn(42);
|
||||
$apiMock->shouldReceive('syncChannelPosts')
|
||||
->once()
|
||||
->with('token', 42, $channel->name);
|
||||
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
|
||||
|
||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||
|
|
@ -161,25 +157,18 @@ public function test_sync_resolves_non_numeric_channel_id_via_get_community_id()
|
|||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_sync_passes_resolved_community_id_to_sync_channel_posts(): void
|
||||
public function test_sync_uses_channel_id_without_a_lookup(): void
|
||||
{
|
||||
[$channel, $account] = $this->makeSyncableChannel('42');
|
||||
[$channel, $account] = $this->makeSyncableChannel(42);
|
||||
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('login')
|
||||
->once()
|
||||
->with($account->username, $account->password)
|
||||
->andReturn('token');
|
||||
// The slug-vs-numeric branch itself now lives in LemmyApiService::resolveCommunityId
|
||||
// and is covered by LemmyApiServiceTest; here we only assert the job forwards
|
||||
// whatever that resolution returns.
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('42', 'token')
|
||||
->andReturn(42);
|
||||
$apiMock->shouldReceive('syncChannelPosts')
|
||||
->once()
|
||||
->with('token', 42, $channel->name);
|
||||
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
|
||||
|
||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||
|
|
@ -195,7 +184,7 @@ public function test_sync_passes_resolved_community_id_to_sync_channel_posts():
|
|||
/**
|
||||
* @return array{0: PlatformChannel, 1: PlatformAccount}
|
||||
*/
|
||||
private function makeSyncableChannel(string $channelId): array
|
||||
private function makeSyncableChannel(int $channelId): array
|
||||
{
|
||||
$platformInstance = PlatformInstance::factory()->create([
|
||||
'platform' => PlatformEnum::LEMMY,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
|
|||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech',
|
||||
'channel_id' => 'tech',
|
||||
'channel_id' => 7,
|
||||
]);
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
|
|
@ -60,7 +60,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
|
|||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_alias',
|
||||
'channel_id' => 'tech',
|
||||
'channel_id' => 7,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -71,15 +71,15 @@ public function test_same_channel_id_allowed_across_different_instances(): void
|
|||
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceA->id,
|
||||
'channel_id' => 'tech',
|
||||
'channel_id' => 7,
|
||||
]);
|
||||
$second = PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'channel_id' => 'tech',
|
||||
'channel_id' => 7,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 2);
|
||||
$this->assertEquals('tech', $second->channel_id);
|
||||
$this->assertSame(7, $second->channel_id);
|
||||
}
|
||||
|
||||
public function test_belongs_to_platform_instance_relationship(): void
|
||||
|
|
@ -259,7 +259,7 @@ public function test_channel_creation_with_factory(): void
|
|||
$this->assertInstanceOf(PlatformChannel::class, $channel);
|
||||
$this->assertNotNull($channel->platform_instance_id);
|
||||
$this->assertIsString($channel->name);
|
||||
$this->assertIsString($channel->channel_id);
|
||||
$this->assertIsInt($channel->channel_id);
|
||||
$this->assertIsBool($channel->is_active);
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ public function test_channel_creation_with_explicit_values(): void
|
|||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'test_channel',
|
||||
'display_name' => 'Test Channel',
|
||||
'channel_id' => 'channel_123',
|
||||
'channel_id' => 123,
|
||||
'description' => 'A test channel',
|
||||
'language_id' => $language->id,
|
||||
'is_active' => false,
|
||||
|
|
@ -281,7 +281,7 @@ public function test_channel_creation_with_explicit_values(): void
|
|||
$this->assertEquals($instance->id, $channel->platform_instance_id);
|
||||
$this->assertEquals('test_channel', $channel->name);
|
||||
$this->assertEquals('Test Channel', $channel->display_name);
|
||||
$this->assertEquals('channel_123', $channel->channel_id);
|
||||
$this->assertSame(123, $channel->channel_id);
|
||||
$this->assertEquals('A test channel', $channel->description);
|
||||
$this->assertEquals($language->id, $channel->language_id);
|
||||
$this->assertFalse($channel->is_active);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
use App\Models\RouteArticle;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RouteArticleTest extends TestCase
|
||||
|
|
@ -60,6 +62,34 @@ public function test_route_article_can_be_approved(): void
|
|||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_approving_dispatches_the_approved_event(): void
|
||||
{
|
||||
Event::fake([RouteArticleApproved::class]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->create();
|
||||
|
||||
$routeArticle->approve();
|
||||
|
||||
Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
|
||||
}
|
||||
|
||||
public function test_re_approving_an_approved_article_does_not_dispatch_again(): void
|
||||
{
|
||||
Event::fake([RouteArticleApproved::class]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->create();
|
||||
|
||||
// A double-click, or a UI action racing an API call, calls approve() twice.
|
||||
// The second must be a no-op: each dispatch queues a publish listener.
|
||||
$routeArticle->approve();
|
||||
$routeArticle->approve();
|
||||
|
||||
Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_route_article_can_be_rejected(): void
|
||||
{
|
||||
/** @var RouteArticle $routeArticle */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace Tests\Unit\Modules\Lemmy\Services;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -13,6 +13,13 @@ class LemmyApiServiceTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ?PlatformChannel $channel = null;
|
||||
|
||||
private function syncChannel(): PlatformChannel
|
||||
{
|
||||
return $this->channel ??= PlatformChannel::factory()->create();
|
||||
}
|
||||
|
||||
public function test_constructor_sets_instance(): void
|
||||
{
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
|
|
@ -163,35 +170,40 @@ public function test_get_community_id_success(): void
|
|||
});
|
||||
}
|
||||
|
||||
public function test_resolve_community_id_looks_up_non_numeric_channel_id(): void
|
||||
public function test_list_communities_returns_local_communities(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'community_view' => [
|
||||
'community' => ['id' => 123],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
Http::fake(['*' => Http::response(['communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
||||
['community' => ['id' => 9, 'name' => 'memes', 'title' => 'Memes']],
|
||||
]], 200)]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$id = $service->resolveCommunityId('test-community', 'token');
|
||||
$communities = (new LemmyApiService('lemmy.world'))->listCommunities();
|
||||
|
||||
$this->assertSame(123, $id);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'name=test-community'));
|
||||
$this->assertSame(
|
||||
[['id' => 9, 'name' => 'memes', 'title' => 'Memes'], ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
||||
$communities
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolve_community_id_uses_numeric_channel_id_without_lookup(): void
|
||||
public function test_list_communities_omits_removed_and_deleted(): void
|
||||
{
|
||||
Http::fake();
|
||||
Http::fake(['*' => Http::response(['communities' => [
|
||||
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
||||
['community' => ['id' => 9, 'name' => 'gone', 'title' => 'Gone', 'removed' => true]],
|
||||
['community' => ['id' => 10, 'name' => 'bye', 'title' => 'Bye', 'deleted' => true]],
|
||||
]], 200)]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$id = $service->resolveCommunityId('42', 'token');
|
||||
$this->assertSame([8], collect((new LemmyApiService('lemmy.world'))->listCommunities())->pluck('id')->all());
|
||||
}
|
||||
|
||||
$this->assertSame(42, $id);
|
||||
public function test_list_communities_throws_on_unsuccessful_response(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response('nope', 500)]);
|
||||
|
||||
// A numeric channel_id is already the community id — no lookup should happen.
|
||||
Http::assertNothingSent();
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Failed to list communities: 500');
|
||||
|
||||
(new LemmyApiService('lemmy.world'))->listCommunities();
|
||||
}
|
||||
|
||||
public function test_get_community_id_throws_on_unsuccessful_response(): void
|
||||
|
|
@ -248,7 +260,7 @@ public function test_sync_channel_posts_success(): void
|
|||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$service->syncChannelPosts('token', 42, 'test-community');
|
||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/api/v3/post/list')
|
||||
|
|
@ -259,18 +271,14 @@ public function test_sync_channel_posts_success(): void
|
|||
|
||||
// Verify posts were stored in the database
|
||||
$this->assertDatabaseHas('platform_channel_posts', [
|
||||
'platform' => PlatformEnum::LEMMY->value,
|
||||
'channel_id' => '42',
|
||||
'channel_name' => 'test-community',
|
||||
'platform_channel_id' => $this->syncChannel()->id,
|
||||
'post_id' => '1',
|
||||
'url' => 'https://example.com/1',
|
||||
'title' => 'Post 1',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('platform_channel_posts', [
|
||||
'platform' => PlatformEnum::LEMMY->value,
|
||||
'channel_id' => '42',
|
||||
'channel_name' => 'test-community',
|
||||
'platform_channel_id' => $this->syncChannel()->id,
|
||||
'post_id' => '2',
|
||||
'url' => 'https://example.com/2',
|
||||
'title' => 'Post 2',
|
||||
|
|
@ -284,7 +292,7 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
|
|||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$service->syncChannelPosts('token', 42, 'test-community');
|
||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
$this->assertDatabaseCount('platform_channel_posts', 0);
|
||||
|
|
@ -297,7 +305,7 @@ public function test_sync_channel_posts_handles_exception(): void
|
|||
});
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$service->syncChannelPosts('token', 42, 'test-community');
|
||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
||||
|
||||
// Assert that the method completes without throwing
|
||||
$this->assertTrue(true);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public function test_publish_to_channel_with_all_data(): void
|
|||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->make([
|
||||
'channel_id' => '42',
|
||||
'channel_id' => 42,
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
|
|
@ -76,10 +76,6 @@ public function test_publish_to_channel_with_all_data(): void
|
|||
|
||||
// Mock LemmyApiService
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('42', 'test-token')
|
||||
->andReturn(42);
|
||||
$apiMock->shouldReceive('createPost')
|
||||
->once()
|
||||
->with(
|
||||
|
|
@ -117,7 +113,7 @@ public function test_publish_to_channel_with_minimal_data(): void
|
|||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->make([
|
||||
'channel_id' => '24',
|
||||
'channel_id' => 24,
|
||||
]);
|
||||
|
||||
$extractedData = [];
|
||||
|
|
@ -133,10 +129,6 @@ public function test_publish_to_channel_with_minimal_data(): void
|
|||
|
||||
// Mock LemmyApiService
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('24', 'minimal-token')
|
||||
->andReturn(24);
|
||||
$apiMock->shouldReceive('createPost')
|
||||
->once()
|
||||
->with(
|
||||
|
|
@ -174,7 +166,7 @@ public function test_publish_to_channel_without_thumbnail(): void
|
|||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->make([
|
||||
'channel_id' => '33',
|
||||
'channel_id' => 33,
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
|
|
@ -193,10 +185,6 @@ public function test_publish_to_channel_without_thumbnail(): void
|
|||
|
||||
// Mock LemmyApiService
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('33', 'no-thumb-token')
|
||||
->andReturn(33);
|
||||
$apiMock->shouldReceive('createPost')
|
||||
->once()
|
||||
->with(
|
||||
|
|
@ -260,7 +248,7 @@ public function test_publish_to_channel_throws_api_exception(): void
|
|||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->make([
|
||||
'channel_id' => '42',
|
||||
'channel_id' => 42,
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
|
|
@ -278,10 +266,6 @@ public function test_publish_to_channel_throws_api_exception(): void
|
|||
|
||||
// Mock LemmyApiService to throw exception
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('42', 'test-token')
|
||||
->andReturn(42);
|
||||
$apiMock->shouldReceive('createPost')
|
||||
->once()
|
||||
->andThrow(new Exception('API Error'));
|
||||
|
|
@ -311,7 +295,7 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
|
|||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->make([
|
||||
'channel_id' => 'string-42',
|
||||
'channel_id' => 42,
|
||||
]);
|
||||
|
||||
$extractedData = [
|
||||
|
|
@ -327,10 +311,6 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
|
|||
|
||||
// Mock LemmyApiService - should resolve non-numeric channel_id to a community id
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('string-42', 'token')
|
||||
->andReturn(42);
|
||||
$apiMock->shouldReceive('createPost')
|
||||
->once()
|
||||
->with(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace Tests\Unit\Services\Publishing;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
|
|
@ -15,7 +15,10 @@
|
|||
use App\Services\Log\LogSaver;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ public function test_publish_route_article_returns_null_when_no_active_account()
|
|||
|
||||
$result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertTrue($result->failed());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +117,7 @@ public function test_publish_route_article_successfully_publishes(): void
|
|||
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue($result->succeeded());
|
||||
$this->assertDatabaseHas('article_publications', [
|
||||
'article_id' => $article->id,
|
||||
'platform_channel_id' => $channel->id,
|
||||
|
|
@ -123,6 +126,73 @@ public function test_publish_route_article_successfully_publishes(): void
|
|||
]);
|
||||
}
|
||||
|
||||
public function test_concurrent_publishes_produce_only_one_remote_post(): void
|
||||
{
|
||||
[$routeArticle, $channel, , $article] = $this->createRouteArticleWithAccount();
|
||||
|
||||
$remoteCalls = 0;
|
||||
|
||||
// A competing listener committed its publication while this one was
|
||||
// between its duplicate check and its own insert. The second attempt
|
||||
// must notice and skip — the unique index cannot retract a remote post.
|
||||
$publisherDouble = Mockery::mock(LemmyPublisher::class);
|
||||
$publisherDouble->shouldReceive('publishToChannel')
|
||||
->andReturnUsing(function () use (&$remoteCalls, $article, $channel) {
|
||||
$remoteCalls++;
|
||||
|
||||
ArticlePublication::create([
|
||||
'article_id' => $article->id,
|
||||
'post_id' => 999,
|
||||
'platform_channel_id' => $channel->id,
|
||||
'published_by' => 'other-worker',
|
||||
'published_at' => now(),
|
||||
'platform' => $channel->platformInstance->platform->value,
|
||||
'publication_data' => [],
|
||||
]);
|
||||
|
||||
return ['post_view' => ['post' => ['id' => 900 + $remoteCalls]]];
|
||||
});
|
||||
|
||||
$service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
|
||||
$service->shouldAllowMockingProtectedMethods();
|
||||
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
|
||||
|
||||
$service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||
$service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||
|
||||
$this->assertSame(1, $remoteCalls, 'The remote must be called once, not once per racing listener.');
|
||||
$this->assertSame(1, ArticlePublication::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_losing_the_lock_race_skips_without_publishing(): void
|
||||
{
|
||||
[$routeArticle, $channel, , $article] = $this->createRouteArticleWithAccount();
|
||||
|
||||
// Another worker holds the lock, so block() gives up and throws. Faked
|
||||
// rather than genuinely contended, so the test does not sit out the wait.
|
||||
$lock = Mockery::mock(Lock::class);
|
||||
$lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException);
|
||||
Cache::shouldReceive('lock')
|
||||
->with("publish:{$article->id}:{$channel->id}", 180)
|
||||
->andReturn($lock);
|
||||
|
||||
$publisherDouble = Mockery::mock(LemmyPublisher::class);
|
||||
$publisherDouble->shouldNotReceive('publishToChannel');
|
||||
|
||||
$service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
|
||||
$service->shouldAllowMockingProtectedMethods();
|
||||
$service->shouldReceive('makePublisher')->andReturn($publisherDouble);
|
||||
|
||||
// Must decline rather than throw: a LockTimeoutException would reach the
|
||||
// caller's catch block and be recorded as a publish failure.
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||
|
||||
$this->assertTrue($result->wasSkipped());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
public function test_publish_route_article_handles_publishing_failure_gracefully(): void
|
||||
{
|
||||
[$routeArticle] = $this->createRouteArticleWithAccount();
|
||||
|
|
@ -138,7 +208,7 @@ public function test_publish_route_article_handles_publishing_failure_gracefully
|
|||
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertTrue($result->failed());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +218,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
|
|||
|
||||
// Simulate the URL already being posted to this channel
|
||||
PlatformChannelPost::storePost(
|
||||
PlatformEnum::LEMMY,
|
||||
(string) $channel->channel_id,
|
||||
$channel->name,
|
||||
$channel,
|
||||
'999',
|
||||
$article->url,
|
||||
'Different Title',
|
||||
|
|
@ -165,7 +233,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
|
|||
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertTrue($result->wasSkipped());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +243,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
|
|||
|
||||
// Simulate the same title already posted with a different URL
|
||||
PlatformChannelPost::storePost(
|
||||
PlatformEnum::LEMMY,
|
||||
(string) $channel->channel_id,
|
||||
$channel->name,
|
||||
$channel,
|
||||
'888',
|
||||
'https://example.com/different-url',
|
||||
'Breaking News',
|
||||
|
|
@ -192,7 +258,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
|
|||
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertTrue($result->wasSkipped());
|
||||
$this->assertDatabaseCount('article_publications', 0);
|
||||
}
|
||||
|
||||
|
|
@ -202,9 +268,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
|
|||
|
||||
// Existing post in the channel has a completely different URL and title
|
||||
PlatformChannelPost::storePost(
|
||||
PlatformEnum::LEMMY,
|
||||
(string) $channel->channel_id,
|
||||
$channel->name,
|
||||
$channel,
|
||||
'777',
|
||||
'https://example.com/other-article',
|
||||
'Totally Different Title',
|
||||
|
|
@ -221,7 +285,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
|
|||
|
||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertTrue($result->succeeded());
|
||||
$this->assertDatabaseHas('article_publications', [
|
||||
'article_id' => $article->id,
|
||||
'post_id' => 456,
|
||||
|
|
|
|||
Loading…
Reference in a new issue