114 - Select channel communities from the instance instead of typing a slug
Some checks failed
CI / ci (pull_request) Failing after 26m6s
CI / ci (push) Successful in 21m12s

This commit is contained in:
myrmidex 2026-08-06 00:20:49 +02:00
parent 358171c8cc
commit d0d81e524e
25 changed files with 1043 additions and 193 deletions

View file

@ -10,7 +10,7 @@
class CreateChannelAction 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); $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.'); 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([ $channel = PlatformChannel::create([
'platform_instance_id' => $platformInstanceId, 'platform_instance_id' => $platformInstanceId,
'channel_id' => $name, 'channel_id' => $communityId,
'name' => $name, 'name' => $name,
'display_name' => ucfirst($name), 'display_name' => ucfirst($name),
'description' => $description, 'description' => $description,

View file

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

View file

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

View file

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

View file

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

View file

@ -17,8 +17,10 @@
use App\Models\Route; use App\Models\Route;
use App\Models\Setting; use App\Models\Setting;
use App\Services\OnboardingService; use App\Services\OnboardingService;
use App\Services\Platform\CommunityDirectory;
use Exception; use Exception;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Validation\Rule;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -49,7 +51,12 @@ class Onboarding extends Component
public string $feedDescription = ''; public string $feedDescription = '';
// Channel form // 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; public ?int $platformInstanceId = null;
@ -117,10 +124,11 @@ public function mount(): void
// Pre-fill channel form if exists // Pre-fill channel form if exists
$channel = PlatformChannel::where('is_active', true)->first(); $channel = PlatformChannel::where('is_active', true)->first();
if ($channel) { if ($channel) {
$this->channelName = $channel->name;
$this->platformInstanceId = $channel->platform_instance_id; $this->platformInstanceId = $channel->platform_instance_id;
$this->channelLanguageId = $channel->language_id; $this->channelLanguageId = $channel->language_id;
$this->channelDescription = $channel->description ?? ''; $this->channelDescription = $channel->description ?? '';
$this->loadCommunities();
$this->channelCommunityId = $channel->channel_id;
} }
// Pre-fill route form if exists // Pre-fill route form if exists
@ -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 public function createChannel(): void
{ {
$this->formErrors = []; $this->formErrors = [];
$this->isLoading = true; $this->isLoading = true;
$this->validate([ $this->validate([
'channelName' => 'required|string|max:255', 'channelCommunityId' => [
'required',
'integer',
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
],
'platformInstanceId' => 'required|exists:platform_instances,id', 'platformInstanceId' => 'required|exists:platform_instances,id',
'channelLanguageId' => 'required|exists:languages,id', 'channelLanguageId' => 'required|exists:languages,id',
'channelDescription' => 'nullable|string|max:1000', 'channelDescription' => 'nullable|string|max:1000',
], [
'channelCommunityId.in' => 'Select a community from this instance.',
]); ]);
// If language changed, reset feed form // If language changed, reset feed form
@ -274,11 +327,14 @@ public function createChannel(): void
} }
$this->previousChannelLanguageId = $this->channelLanguageId; $this->previousChannelLanguageId = $this->channelLanguageId;
$name = collect($this->availableCommunities)->firstWhere('id', $this->channelCommunityId)['name'] ?? null;
try { try {
$channel = $this->createChannelAction->execute( $channel = $this->createChannelAction->execute(
$this->channelName, $name,
$this->platformInstanceId, (int) $this->channelCommunityId,
$this->channelLanguageId, (int) $this->platformInstanceId,
$this->channelLanguageId !== null ? (int) $this->channelLanguageId : null,
$this->channelDescription ?: null, $this->channelDescription ?: null,
); );

View file

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

View file

@ -84,18 +84,35 @@ public function login(string $username, string $password): ?string
} }
/** /**
* Resolve a PlatformChannel.channel_id to a numeric Lemmy community id. * @return array<int, array{id: int, name: string, title: string}>
*
* channel_id holds either a community slug (the usual case CreateChannelAction
* copies `name` into it) or an already-numeric community id. Callers that need the
* numeric id should use this rather than reimplementing the check, so the two forms
* stay handled identically everywhere.
*/ */
public function resolveCommunityId(string $channelId, string $token): int public function listCommunities(?string $token = null): array
{ {
return is_numeric($channelId) $request = new LemmyRequest($this->instance, $token);
? (int) $channelId $response = $request->get('community/list', [
: $this->getCommunityId($channelId, $token); '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 public function getCommunityId(string $communityName, string $token): int

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -99,7 +99,7 @@ private function makeListener(): PublishApprovedArticleListener
return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService)); return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
} }
public function test_clicking_approve_twice_creates_only_one_remote_post(): void public function test_clicking_approve_twice_dispatches_only_one_event(): void
{ {
Event::fake([RouteArticleApproved::class]); Event::fake([RouteArticleApproved::class]);
@ -112,6 +112,25 @@ public function test_clicking_approve_twice_creates_only_one_remote_post(): void
Event::assertDispatchedTimes(RouteArticleApproved::class, 1); 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 public function test_two_queued_listeners_create_only_one_remote_post(): void
{ {
[$routeArticle, $channel, $article] = $this->fixture; [$routeArticle, $channel, $article] = $this->fixture;
@ -153,6 +172,50 @@ public function test_two_queued_listeners_create_only_one_remote_post(): void
->count()); ->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 public function test_a_single_approval_publishes_exactly_once(): void
{ {
[$routeArticle, $channel, $article] = $this->fixture; [$routeArticle, $channel, $article] = $this->fixture;

View file

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

View file

@ -8,6 +8,9 @@
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformInstance; use App\Models\PlatformInstance;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@ -15,6 +18,23 @@ class ChannelsTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->fakeCommunities();
}
private function fakeCommunities(): void
{
Http::fake(['*/api/v3/community/list*' => Http::response([
'communities' => [
['community' => ['id' => 8, 'name' => 'tech_community', 'title' => 'Tech Community']],
['community' => ['id' => 9, 'name' => 'other_community', 'title' => 'Other Community']],
],
])]);
}
private function instanceWithActiveAccount(): PlatformInstance private function instanceWithActiveAccount(): PlatformInstance
{ {
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']); $instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
@ -50,7 +70,7 @@ public function test_open_create_modal_shows_modal(): void
->assertSet('showCreateModal', true); ->assertSet('showCreateModal', true);
} }
public function test_create_channel_requires_name(): void public function test_create_channel_requires_community(): void
{ {
$instance = $this->instanceWithActiveAccount(); $instance = $this->instanceWithActiveAccount();
@ -58,14 +78,44 @@ public function test_create_channel_requires_name(): void
->call('openCreateModal') ->call('openCreateModal')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->call('createChannel') ->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 public function test_create_channel_requires_platform_instance(): void
{ {
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community') ->set('newCommunityId', 8)
->call('createChannel') ->call('createChannel')
->assertHasErrors(['newPlatformInstanceId' => 'required']); ->assertHasErrors(['newPlatformInstanceId' => 'required']);
} }
@ -77,8 +127,8 @@ public function test_create_channel_succeeds_and_attaches_account(): void
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->set('newLanguageId', $language->id) ->set('newLanguageId', $language->id)
->set('newDescription', 'A tech community') ->set('newDescription', 'A tech community')
->call('createChannel') ->call('createChannel')
@ -104,8 +154,8 @@ public function test_create_channel_leaves_description_null_when_blank(): void
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel') ->call('createChannel')
->assertHasNoErrors(); ->assertHasNoErrors();
@ -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(); $instance = $this->instanceWithActiveAccount();
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'Tech News')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 4242)
->call('createChannel') ->call('createChannel')
->assertHasErrors(['newName' => 'regex']); ->assertHasErrors(['newCommunityId' => 'in']);
$this->assertDatabaseCount('platform_channels', 0); $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(); $instance = $this->instanceWithActiveAccount();
PlatformChannel::factory()->create([ PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'name' => 'tech_community', 'name' => 'tech_community',
'channel_id' => 8,
]); ]);
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel') ->call('createChannel')
->assertHasErrors(['newName' => 'unique']) ->assertHasErrors(['newCommunityId' => 'unique'])
->assertSet('showCreateModal', true); ->assertSet('showCreateModal', true);
$this->assertDatabaseCount('platform_channels', 1); $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(); $instanceA = $this->instanceWithActiveAccount();
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']); $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([ PlatformChannel::factory()->create([
'platform_instance_id' => $instanceB->id, 'platform_instance_id' => $instanceB->id,
'name' => 'tech_community', 'name' => 'tech_community',
'channel_id' => 8,
]); ]);
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instanceA->id) ->set('newPlatformInstanceId', $instanceA->id)
->set('newCommunityId', 8)
->call('createChannel') ->call('createChannel')
->assertHasNoErrors(); ->assertHasNoErrors();
@ -178,8 +230,8 @@ public function test_create_channel_surfaces_no_active_accounts_error(): void
Livewire::test(Channels::class) Livewire::test(Channels::class)
->call('openCreateModal') ->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id) ->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel') ->call('createChannel')
->assertHasErrors('newPlatformInstanceId') ->assertHasErrors('newPlatformInstanceId')
->assertSet('showCreateModal', true); ->assertSet('showCreateModal', true);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -170,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::fake(['*' => Http::response(['communities' => [
'*' => Http::response([ ['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
'community_view' => [ ['community' => ['id' => 9, 'name' => 'memes', 'title' => 'Memes']],
'community' => ['id' => 123], ]], 200)]);
],
], 200),
]);
$service = new LemmyApiService('lemmy.world'); $communities = (new LemmyApiService('lemmy.world'))->listCommunities();
$id = $service->resolveCommunityId('test-community', 'token');
$this->assertSame(123, $id); $this->assertSame(
[['id' => 9, 'name' => 'memes', 'title' => 'Memes'], ['id' => 8, 'name' => 'news', 'title' => 'News']],
Http::assertSent(fn ($request) => str_contains($request->url(), 'name=test-community')); $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'); $this->assertSame([8], collect((new LemmyApiService('lemmy.world'))->listCommunities())->pluck('id')->all());
$id = $service->resolveCommunityId('42', 'token'); }
$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. $this->expectException(Exception::class);
Http::assertNothingSent(); $this->expectExceptionMessage('Failed to list communities: 500');
(new LemmyApiService('lemmy.world'))->listCommunities();
} }
public function test_get_community_id_throws_on_unsuccessful_response(): void public function test_get_community_id_throws_on_unsuccessful_response(): void

View file

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