release/v1.3.2 #107
21 changed files with 1108 additions and 30 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -20,11 +20,7 @@ yarn-error.log
|
|||
/package-lock.json
|
||||
/auth.json
|
||||
/composer.lock
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
/coverage-report*
|
||||
/coverage.xml
|
||||
/.claude
|
||||
/.php-cs-fixer.dist.php
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use Exception;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -51,6 +52,11 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
|
|||
'Platform channel created successfully and linked to platform account!',
|
||||
201
|
||||
);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// The (platform_instance_id, channel_id) unique index is the last line of
|
||||
// defence if a duplicate slips past request validation — surface it as a
|
||||
// validation error rather than leaking the driver's SQL message in a 500.
|
||||
return $this->sendError('A channel with this name already exists for this instance.', [], 422);
|
||||
} catch (RuntimeException $e) {
|
||||
return $this->sendError($e->getMessage(), [], 422);
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePlatformChannelRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -12,15 +13,36 @@ public function authorize(): bool
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
* @return array<string, array<int, mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||
'name' => 'required|string|max:255',
|
||||
// name doubles as the Lemmy community slug (CreateChannelAction copies it
|
||||
// verbatim into channel_id for community lookup at publish time), so it must
|
||||
// be slug format and unique per instance — matching the Livewire create form.
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
'regex:/^[a-z0-9_]+$/',
|
||||
Rule::unique('platform_channels', 'name')
|
||||
->where('platform_instance_id', $this->input('platform_instance_id')),
|
||||
],
|
||||
'language_id' => 'nullable|exists:languages,id',
|
||||
'description' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,17 +65,14 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
|||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'No active account found for channel');
|
||||
}
|
||||
|
||||
$api = new LemmyApiService($this->channel->platformInstance->url);
|
||||
$api = $this->makeApiService($this->channel->platformInstance->url);
|
||||
$token = $this->getAuthToken($api, $account);
|
||||
|
||||
$platformChannelId = $this->channel->channel_id
|
||||
? $this->channel->channel_id
|
||||
: $api->getCommunityId($this->channel->name, $token);
|
||||
$communityId = $api->resolveCommunityId($this->channel->channel_id, $token);
|
||||
|
||||
$api->syncChannelPosts($token, $platformChannelId, $this->channel->name);
|
||||
$api->syncChannelPosts($token, $communityId, $this->channel->name);
|
||||
|
||||
$logSaver->info('Channel posts synced successfully', $this->channel);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$logSaver->error('Failed to sync channel posts', $this->channel, [
|
||||
'error' => $e->getMessage(),
|
||||
|
|
@ -85,6 +82,15 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seam for testing — LemmyApiService needs a per-instance URL so it cannot be
|
||||
* container-resolved. Override in tests to inject a mock.
|
||||
*/
|
||||
protected function makeApiService(string $instanceUrl): LemmyApiService
|
||||
{
|
||||
return new LemmyApiService($instanceUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PlatformAuthException
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,15 +2,31 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\CreateChannelAction;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
use RuntimeException;
|
||||
|
||||
class Channels extends Component
|
||||
{
|
||||
public ?int $managingChannelId = null;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public string $newName = '';
|
||||
|
||||
public ?int $newPlatformInstanceId = null;
|
||||
|
||||
public ?int $newLanguageId = null;
|
||||
|
||||
public string $newDescription = '';
|
||||
|
||||
public function toggle(int $channelId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
|
|
@ -18,6 +34,62 @@ public function toggle(int $channelId): void
|
|||
$channel->save();
|
||||
}
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']);
|
||||
$this->resetErrorBag();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function closeCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
}
|
||||
|
||||
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' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
'regex:/^[a-z0-9_]+$/',
|
||||
Rule::unique('platform_channels', 'name')
|
||||
->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.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$action->execute(
|
||||
$this->newName,
|
||||
$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.');
|
||||
|
||||
return;
|
||||
} catch (RuntimeException $e) {
|
||||
$this->addError('newPlatformInstanceId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeCreateModal();
|
||||
}
|
||||
|
||||
public function openAccountModal(int $channelId): void
|
||||
{
|
||||
$this->managingChannelId = $channelId;
|
||||
|
|
@ -69,6 +141,8 @@ public function render(): View
|
|||
'channels' => $channels,
|
||||
'managingChannel' => $managingChannel,
|
||||
'availableAccounts' => $availableAccounts,
|
||||
'platformInstances' => PlatformInstance::where('is_active', true)->orderBy('name')->get(),
|
||||
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,25 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\CreateFeedAction;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Component;
|
||||
|
||||
class Feeds extends Component
|
||||
{
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public string $newName = '';
|
||||
|
||||
public string $newProvider = '';
|
||||
|
||||
public ?int $newLanguageId = null;
|
||||
|
||||
public string $newDescription = '';
|
||||
|
||||
public function toggle(int $feedId): void
|
||||
{
|
||||
$feed = Feed::findOrFail($feedId);
|
||||
|
|
@ -15,12 +28,64 @@ public function toggle(int $feedId): void
|
|||
$feed->save();
|
||||
}
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->reset(['newName', 'newProvider', 'newLanguageId', 'newDescription']);
|
||||
$this->resetErrorBag();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function closeCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
}
|
||||
|
||||
public function createFeed(CreateFeedAction $action): void
|
||||
{
|
||||
$providers = array_keys($this->activeProviders());
|
||||
|
||||
$this->validate([
|
||||
'newName' => 'required|string|max:255',
|
||||
'newProvider' => ['required', 'string', 'in:'.implode(',', $providers)],
|
||||
'newLanguageId' => 'required|integer|exists:languages,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$action->execute(
|
||||
$this->newName,
|
||||
$this->newProvider,
|
||||
$this->newLanguageId,
|
||||
// Blade textarea binds an empty string when blank; the action expects null for "no description".
|
||||
$this->newDescription !== '' ? $this->newDescription : null,
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->addError('newProvider', 'This provider is not available for the selected language.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeCreateModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function activeProviders(): array
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> $providers */
|
||||
$providers = config('feed.providers', []);
|
||||
|
||||
return array_filter($providers, fn (array $provider): bool => ($provider['is_active'] ?? false) === true);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$feeds = Feed::orderBy('name')->get();
|
||||
|
||||
return view('livewire.feeds', [
|
||||
'feeds' => $feeds,
|
||||
'providers' => $this->activeProviders(),
|
||||
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
* @property int $id
|
||||
* @property int $platform_instance_id
|
||||
* @property PlatformInstance $platformInstance
|
||||
* @property int $channel_id
|
||||
* @property string $channel_id
|
||||
* @property string $name
|
||||
* @property int $language_id
|
||||
* @property Language|null $language
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ public function login(string $username, string $password): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PlatformChannel.channel_id to a numeric Lemmy community id.
|
||||
*
|
||||
* channel_id holds either a community slug (the usual case — CreateChannelAction
|
||||
* copies `name` into it) or an already-numeric community id. Callers that need the
|
||||
* numeric id should use this rather than reimplementing the check, so the two forms
|
||||
* stay handled identically everywhere.
|
||||
*/
|
||||
public function resolveCommunityId(string $channelId, string $token): int
|
||||
{
|
||||
return is_numeric($channelId)
|
||||
? (int) $channelId
|
||||
: $this->getCommunityId($channelId, $token);
|
||||
}
|
||||
|
||||
public function getCommunityId(string $communityName, string $token): int
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -54,9 +54,7 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
|||
{
|
||||
$languageId = $extractedData['language_id'] ?? null;
|
||||
|
||||
$communityId = is_numeric($channel->channel_id)
|
||||
? (int) $channel->channel_id
|
||||
: $this->api->getCommunityId($channel->channel_id, $token);
|
||||
$communityId = $this->api->resolveCommunityId($channel->channel_id, $token);
|
||||
|
||||
return $this->api->createPost(
|
||||
$token,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* channel_id is used verbatim as the Lemmy community slug at publish time
|
||||
* (LemmyPublisher -> getCommunityId). It is kept equal to `name` on create,
|
||||
* but only `name` had a uniqueness guarantee. Enforce the same scope on
|
||||
* channel_id so duplicate/ambiguous community references cannot exist.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$this->guardAgainstDuplicates();
|
||||
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight check so an existing database with drifted data fails with an
|
||||
* actionable message naming the offending rows, rather than a raw driver error.
|
||||
*/
|
||||
private function guardAgainstDuplicates(): void
|
||||
{
|
||||
$duplicates = DB::table('platform_channels')
|
||||
->select('platform_instance_id', 'channel_id', DB::raw('COUNT(*) as total'))
|
||||
->groupBy('platform_instance_id', 'channel_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
if ($duplicates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$details = $duplicates
|
||||
->map(fn ($row) => "platform_instance_id={$row->platform_instance_id} channel_id='{$row->channel_id}' ({$row->total} rows)")
|
||||
->implode('; ');
|
||||
|
||||
throw new RuntimeException(
|
||||
'Cannot add unique index on platform_channels (platform_instance_id, channel_id): '.
|
||||
'duplicate rows exist. Resolve these before migrating: '.$details
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('platform_channels', function (Blueprint $table) {
|
||||
$table->dropUnique('platform_channels_channel_id_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -102,12 +102,6 @@ parameters:
|
|||
count: 6
|
||||
path: tests/Unit/Models/PlatformChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with int will always evaluate to false\.$#'
|
||||
identifier: method.impossibleType
|
||||
count: 1
|
||||
path: tests/Unit/Models/PlatformChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Language\:\:\$pivot\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
<div class="p-6">
|
||||
<x-page-header title="Channels" subtitle="Manage your platform channels and linked accounts" />
|
||||
<x-page-header title="Channels" subtitle="Manage your platform channels and linked accounts">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Channel
|
||||
</button>
|
||||
</x-page-header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@forelse ($channels as $channel)
|
||||
|
|
@ -98,6 +108,17 @@ class="text-red-500 hover:text-red-700"
|
|||
<p class="mt-1 text-sm text-gray-500">
|
||||
No platform channels have been configured yet.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
|
@ -155,4 +176,98 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Create Channel Modal -->
|
||||
@if ($showCreateModal)
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeCreateModal"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Add Channel</h3>
|
||||
<button wire:click="closeCreateModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-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"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select an instance</option>
|
||||
@foreach ($platformInstances as $instance)
|
||||
<option value="{{ $instance->id }}">{{ $instance->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newPlatformInstanceId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</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>
|
||||
<select
|
||||
id="new-channel-language"
|
||||
wire:model="newLanguageId"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a language</option>
|
||||
@foreach ($languages as $language)
|
||||
<option value="{{ $language->id }}">{{ $language->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newLanguageId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-channel-description" class="block text-sm font-medium text-gray-700">Description <span class="text-gray-400">(optional)</span></label>
|
||||
<textarea
|
||||
id="new-channel-description"
|
||||
wire:model="newDescription"
|
||||
rows="2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
></textarea>
|
||||
@error('newDescription') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="closeCreateModal"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="createChannel"
|
||||
class="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
<div class="p-6">
|
||||
<x-page-header title="Feeds" subtitle="Manage your news feed sources" />
|
||||
<x-page-header title="Feeds" subtitle="Manage your news feed sources">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Feed
|
||||
</button>
|
||||
</x-page-header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@forelse ($feeds as $feed)
|
||||
|
|
@ -76,7 +86,112 @@ class="text-gray-400 hover:text-gray-600"
|
|||
<p class="mt-1 text-sm text-gray-500">
|
||||
No feeds have been configured yet.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Feed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- Create Feed Modal -->
|
||||
@if ($showCreateModal)
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeCreateModal"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Add Feed</h3>
|
||||
<button wire:click="closeCreateModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form wire:submit="createFeed" class="space-y-4">
|
||||
<div>
|
||||
<label for="new-feed-name" class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-feed-name"
|
||||
wire:model="newName"
|
||||
class="mt-1 block w-full rounded-md 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-feed-provider" class="block text-sm font-medium text-gray-700">Provider</label>
|
||||
<select
|
||||
id="new-feed-provider"
|
||||
wire:model="newProvider"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a provider</option>
|
||||
@foreach ($providers as $code => $provider)
|
||||
<option value="{{ $code }}">{{ $provider['name'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newProvider') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-feed-language" class="block text-sm font-medium text-gray-700">Language</label>
|
||||
<select
|
||||
id="new-feed-language"
|
||||
wire:model="newLanguageId"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a language</option>
|
||||
@foreach ($languages as $language)
|
||||
<option value="{{ $language->id }}">{{ $language->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newLanguageId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-feed-description" class="block text-sm font-medium text-gray-700">Description <span class="text-gray-400">(optional)</span></label>
|
||||
<textarea
|
||||
id="new-feed-description"
|
||||
wire:model="newDescription"
|
||||
rows="2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
></textarea>
|
||||
@error('newDescription') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="closeCreateModal"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="createFeed"
|
||||
class="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,10 @@ public function test_status_shows_route_step_when_platform_account_feed_and_chan
|
|||
$language = Language::first();
|
||||
PlatformAccount::factory()->create(['is_active' => true]);
|
||||
Feed::factory()->language($language)->create(['is_active' => true]);
|
||||
PlatformChannel::factory()->create(['is_active' => true]);
|
||||
// Reuse the seeded language: PlatformChannelFactory otherwise creates one via
|
||||
// faker->unique()->languageCode(), which can randomly draw 'en' and collide with
|
||||
// the short_code created in setUp().
|
||||
PlatformChannel::factory()->create(['is_active' => true, 'language_id' => $language->id]);
|
||||
|
||||
$response = $this->getJson('/api/v1/onboarding/status');
|
||||
|
||||
|
|
@ -119,7 +122,8 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
|
|||
$language = Language::first();
|
||||
PlatformAccount::factory()->create(['is_active' => true]);
|
||||
Feed::factory()->language($language)->create(['is_active' => true]);
|
||||
PlatformChannel::factory()->create(['is_active' => true]);
|
||||
// Reuse the seeded language — see note above re: languageCode() collisions.
|
||||
PlatformChannel::factory()->create(['is_active' => true, 'language_id' => $language->id]);
|
||||
Route::factory()->create(['is_active' => true]);
|
||||
|
||||
$response = $this->getJson('/api/v1/onboarding/status');
|
||||
|
|
|
|||
|
|
@ -111,6 +111,69 @@ public function test_store_validates_platform_instance_exists(): void
|
|||
->assertJsonValidationErrors(['platform_instance_id']);
|
||||
}
|
||||
|
||||
public function test_store_rejects_non_slug_name(): 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',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_store_rejects_duplicate_name_for_same_instance(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 'tech_news',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_news',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['name']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 1);
|
||||
}
|
||||
|
||||
public function test_store_allows_same_name_on_different_instance(): void
|
||||
{
|
||||
$instanceA = PlatformInstance::factory()->create();
|
||||
$instanceB = PlatformInstance::factory()->create();
|
||||
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => $instanceB->url,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceA->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => 'tech_news',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/platform-channels', [
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'name' => 'tech_news',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 2);
|
||||
}
|
||||
|
||||
public function test_show_returns_platform_channel_successfully(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
|
|
|||
199
tests/Feature/Livewire/ChannelsTest.php
Normal file
199
tests/Feature/Livewire/ChannelsTest.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\Channels;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ChannelsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
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_add_button_renders_when_channels_exist(): void
|
||||
{
|
||||
PlatformChannel::factory()->create(['name' => 'existing_channel']);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->assertSee('Add Channel')
|
||||
->assertSee('existing_channel');
|
||||
}
|
||||
|
||||
public function test_add_button_renders_in_empty_state(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->assertSee('No channels')
|
||||
->assertSee('Add Channel');
|
||||
}
|
||||
|
||||
public function test_open_create_modal_shows_modal(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->assertSet('showCreateModal', false)
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_name(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_platform_instance(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newPlatformInstanceId' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_channel_succeeds_and_attaches_account(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newLanguageId', $language->id)
|
||||
->set('newDescription', 'A tech community')
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showCreateModal', false);
|
||||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'name' => 'tech_community',
|
||||
'display_name' => 'Tech_community',
|
||||
'platform_instance_id' => $instance->id,
|
||||
'language_id' => $language->id,
|
||||
'description' => 'A tech community',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$channel = PlatformChannel::where('name', 'tech_community')->firstOrFail();
|
||||
$this->assertCount(1, $channel->platformAccounts);
|
||||
}
|
||||
|
||||
public function test_create_channel_leaves_description_null_when_blank(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'name' => 'tech_community',
|
||||
'description' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_non_slug_name(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'regex']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_duplicate_name_on_same_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_community',
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'unique'])
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 1);
|
||||
}
|
||||
|
||||
public function test_create_channel_allows_same_name_on_different_instance(): void
|
||||
{
|
||||
$instanceA = $this->instanceWithActiveAccount();
|
||||
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => 'https://lemmy.other',
|
||||
'is_active' => true,
|
||||
]);
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'name' => 'tech_community',
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instanceA->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 2);
|
||||
}
|
||||
|
||||
public function test_create_channel_surfaces_no_active_accounts_error(): void
|
||||
{
|
||||
// Instance exists but has no active account for its url.
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors('newPlatformInstanceId')
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_toggle_flips_active_state(): void
|
||||
{
|
||||
$channel = PlatformChannel::factory()->create(['is_active' => true]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('toggle', $channel->id);
|
||||
|
||||
$this->assertFalse($channel->fresh()->is_active);
|
||||
}
|
||||
}
|
||||
157
tests/Feature/Livewire/FeedsTest.php
Normal file
157
tests/Feature/Livewire/FeedsTest.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\Feeds;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FeedsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_add_button_renders_when_feeds_exist(): void
|
||||
{
|
||||
Feed::factory()->create(['name' => 'Existing Feed']);
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->assertSee('Add Feed')
|
||||
->assertSee('Existing Feed');
|
||||
}
|
||||
|
||||
public function test_add_button_renders_in_empty_state(): void
|
||||
{
|
||||
Livewire::test(Feeds::class)
|
||||
->assertSee('No feeds')
|
||||
->assertSee('Add Feed');
|
||||
}
|
||||
|
||||
public function test_open_create_modal_shows_modal(): void
|
||||
{
|
||||
Livewire::test(Feeds::class)
|
||||
->assertSet('showCreateModal', false)
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_create_feed_requires_name(): void
|
||||
{
|
||||
$language = Language::factory()->english()->create();
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newProvider', 'guardian')
|
||||
->set('newLanguageId', $language->id)
|
||||
->call('createFeed')
|
||||
->assertHasErrors(['newName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_feed_requires_provider(): void
|
||||
{
|
||||
$language = Language::factory()->english()->create();
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newLanguageId', $language->id)
|
||||
->call('createFeed')
|
||||
->assertHasErrors(['newProvider' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_feed_requires_language(): void
|
||||
{
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newProvider', 'guardian')
|
||||
->call('createFeed')
|
||||
->assertHasErrors(['newLanguageId' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_feed_rejects_unknown_provider(): void
|
||||
{
|
||||
$language = Language::factory()->english()->create();
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newProvider', 'nonexistent')
|
||||
->set('newLanguageId', $language->id)
|
||||
->call('createFeed')
|
||||
->assertHasErrors('newProvider');
|
||||
|
||||
$this->assertDatabaseCount('feeds', 0);
|
||||
}
|
||||
|
||||
public function test_create_feed_succeeds_and_resolves_url(): void
|
||||
{
|
||||
$language = Language::factory()->english()->create();
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Guardian News')
|
||||
->set('newProvider', 'guardian')
|
||||
->set('newLanguageId', $language->id)
|
||||
->set('newDescription', 'British daily')
|
||||
->call('createFeed')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showCreateModal', false);
|
||||
|
||||
$this->assertDatabaseHas('feeds', [
|
||||
'name' => 'Guardian News',
|
||||
'provider' => 'guardian',
|
||||
'language_id' => $language->id,
|
||||
'url' => 'https://www.theguardian.com/international/rss',
|
||||
'description' => 'British daily',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_feed_leaves_description_null_when_blank(): void
|
||||
{
|
||||
$language = Language::factory()->english()->create();
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Guardian News')
|
||||
->set('newProvider', 'guardian')
|
||||
->set('newLanguageId', $language->id)
|
||||
->call('createFeed')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('feeds', [
|
||||
'name' => 'Guardian News',
|
||||
'description' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_feed_surfaces_invalid_provider_language_combination(): void
|
||||
{
|
||||
// Belga only supports 'en'; a non-'en' language triggers InvalidArgumentException in the action.
|
||||
$language = Language::factory()->create(['short_code' => 'nl', 'name' => 'Dutch', 'is_active' => true]);
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Belga NL')
|
||||
->set('newProvider', 'belga')
|
||||
->set('newLanguageId', $language->id)
|
||||
->call('createFeed')
|
||||
->assertHasErrors('newProvider')
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('feeds', 0);
|
||||
}
|
||||
|
||||
public function test_toggle_flips_active_state(): void
|
||||
{
|
||||
$feed = Feed::factory()->create(['is_active' => true]);
|
||||
|
||||
Livewire::test(Feeds::class)
|
||||
->call('toggle', $feed->id);
|
||||
|
||||
$this->assertFalse($feed->fresh()->is_active);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
|
|
@ -132,6 +133,113 @@ 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
|
||||
{
|
||||
[$channel, $account] = $this->makeSyncableChannel('tech_news');
|
||||
|
||||
$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);
|
||||
|
||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||
$logSaverMock->shouldReceive('error')->zeroOrMoreTimes();
|
||||
|
||||
$this->makeJobWithApi($channel, $apiMock)->handle($logSaverMock);
|
||||
|
||||
// The behaviour under test is the mocked call sequence above; assert explicitly
|
||||
// so PHPUnit does not flag the test as risky for performing no assertions.
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_sync_passes_resolved_community_id_to_sync_channel_posts(): void
|
||||
{
|
||||
[$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);
|
||||
|
||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||
$logSaverMock->shouldReceive('error')->zeroOrMoreTimes();
|
||||
|
||||
$this->makeJobWithApi($channel, $apiMock)->handle($logSaverMock);
|
||||
|
||||
// The mocked call sequence above is the assertion; assert explicitly so PHPUnit
|
||||
// does not flag the test as risky.
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: PlatformChannel, 1: PlatformAccount}
|
||||
*/
|
||||
private function makeSyncableChannel(string $channelId): array
|
||||
{
|
||||
$platformInstance = PlatformInstance::factory()->create([
|
||||
'platform' => PlatformEnum::LEMMY,
|
||||
'url' => 'https://lemmy.example.com',
|
||||
]);
|
||||
|
||||
$account = PlatformAccount::factory()->create([
|
||||
'instance_url' => $platformInstance->url,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$channel = PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $platformInstance->id,
|
||||
'name' => 'tech_news',
|
||||
'channel_id' => $channelId,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$channel->platformAccounts()->attach($account->id, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return [$channel->fresh(['platformInstance']), $account];
|
||||
}
|
||||
|
||||
private function makeJobWithApi(PlatformChannel $channel, LemmyApiService $api): SyncChannelPostsJob
|
||||
{
|
||||
return new class($channel, $api) extends SyncChannelPostsJob
|
||||
{
|
||||
public function __construct(PlatformChannel $channel, private readonly LemmyApiService $api)
|
||||
{
|
||||
parent::__construct($channel);
|
||||
}
|
||||
|
||||
protected function makeApiService(string $instanceUrl): LemmyApiService
|
||||
{
|
||||
return $this->api;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function test_job_can_be_serialized(): void
|
||||
{
|
||||
$platformInstance = PlatformInstance::factory()->create();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -45,6 +46,42 @@ public function test_casts_is_active_to_boolean(): void
|
|||
$this->assertFalse($channel->is_active);
|
||||
}
|
||||
|
||||
public function test_channel_id_is_unique_per_platform_instance(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech',
|
||||
'channel_id' => 'tech',
|
||||
]);
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_alias',
|
||||
'channel_id' => 'tech',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_same_channel_id_allowed_across_different_instances(): void
|
||||
{
|
||||
$instanceA = PlatformInstance::factory()->create();
|
||||
$instanceB = PlatformInstance::factory()->create();
|
||||
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceA->id,
|
||||
'channel_id' => 'tech',
|
||||
]);
|
||||
$second = PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'channel_id' => 'tech',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 2);
|
||||
$this->assertEquals('tech', $second->channel_id);
|
||||
}
|
||||
|
||||
public function test_belongs_to_platform_instance_relationship(): void
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create();
|
||||
|
|
|
|||
|
|
@ -163,6 +163,37 @@ public function test_get_community_id_success(): void
|
|||
});
|
||||
}
|
||||
|
||||
public function test_resolve_community_id_looks_up_non_numeric_channel_id(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'community_view' => [
|
||||
'community' => ['id' => 123],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$id = $service->resolveCommunityId('test-community', 'token');
|
||||
|
||||
$this->assertSame(123, $id);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'name=test-community'));
|
||||
}
|
||||
|
||||
public function test_resolve_community_id_uses_numeric_channel_id_without_lookup(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$service = new LemmyApiService('lemmy.world');
|
||||
$id = $service->resolveCommunityId('42', 'token');
|
||||
|
||||
$this->assertSame(42, $id);
|
||||
|
||||
// A numeric channel_id is already the community id — no lookup should happen.
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_get_community_id_throws_on_unsuccessful_response(): void
|
||||
{
|
||||
Http::fake([
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ 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(
|
||||
|
|
@ -129,6 +133,10 @@ 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(
|
||||
|
|
@ -185,6 +193,10 @@ 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(
|
||||
|
|
@ -266,6 +278,10 @@ 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'));
|
||||
|
|
@ -284,7 +300,7 @@ public function test_publish_to_channel_throws_api_exception(): void
|
|||
$publisher->publishToChannel($article, $extractedData, $channel);
|
||||
}
|
||||
|
||||
public function test_publish_to_channel_handles_string_channel_id(): void
|
||||
public function test_publish_to_channel_forwards_resolved_community_id_to_create_post(): void
|
||||
{
|
||||
$account = PlatformAccount::factory()->make([
|
||||
'instance_url' => 'https://lemmy.world',
|
||||
|
|
@ -309,9 +325,9 @@ public function test_publish_to_channel_handles_string_channel_id(): void
|
|||
->once()
|
||||
->andReturn('token');
|
||||
|
||||
// Mock LemmyApiService - should call getCommunityId for non-numeric channel_id
|
||||
// Mock LemmyApiService - should resolve non-numeric channel_id to a community id
|
||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||
$apiMock->shouldReceive('getCommunityId')
|
||||
$apiMock->shouldReceive('resolveCommunityId')
|
||||
->once()
|
||||
->with('string-42', 'token')
|
||||
->andReturn(42);
|
||||
|
|
|
|||
Loading…
Reference in a new issue