Release v1.1.0 #79

Merged
myrmidex merged 12 commits from release/v1.1.0 into main 2026-03-08 11:44:53 +01:00
14 changed files with 239 additions and 176 deletions
Showing only changes of commit 025794c852 - Show all commits

View file

@ -5,6 +5,8 @@
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class CreateChannelAction
{
@ -17,9 +19,10 @@ public function execute(string $name, int $platformInstanceId, ?int $languageId
->get();
if ($activeAccounts->isEmpty()) {
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) {
$channel = PlatformChannel::create([
'platform_instance_id' => $platformInstanceId,
'channel_id' => $name,
@ -38,5 +41,6 @@ public function execute(string $name, int $platformInstanceId, ?int $languageId
]);
return $channel->load('platformAccounts');
});
}
}

View file

@ -4,6 +4,7 @@
use App\Models\Feed;
use App\Models\Language;
use InvalidArgumentException;
class CreateFeedAction
{
@ -15,7 +16,7 @@ public function execute(string $name, string $provider, int $languageId, ?string
$url = config("feed.providers.{$provider}.languages.{$langCode}.url");
if (!$url) {
throw new \InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}");
throw new InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}");
}
$providerConfig = config("feed.providers.{$provider}");

View file

@ -6,6 +6,7 @@
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Support\Facades\DB;
class CreatePlatformAccountAction
{
@ -23,6 +24,7 @@ public function execute(string $instanceDomain, string $username, string $passwo
// Authenticate first — if this fails, no records are created
$authResponse = $this->lemmyAuthService->authenticate($fullInstanceUrl, $username, $password);
return DB::transaction(function () use ($fullInstanceUrl, $instanceDomain, $username, $password, $platform, $authResponse) {
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => $platform,
@ -46,5 +48,6 @@ public function execute(string $instanceDomain, string $username, string $passwo
'is_active' => true,
'status' => 'active',
]);
});
}
}

View file

@ -2,12 +2,15 @@
namespace App\Http\Controllers\Api\V1;
use App\Actions\CreateFeedAction;
use App\Http\Requests\StoreFeedRequest;
use App\Http\Requests\UpdateFeedRequest;
use App\Http\Resources\FeedResource;
use App\Models\Feed;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvalidArgumentException;
use Illuminate\Validation\ValidationException;
class FeedsController extends BaseController
@ -41,32 +44,26 @@ public function index(Request $request): JsonResponse
/**
* Store a newly created feed
*/
public function store(StoreFeedRequest $request): JsonResponse
public function store(StoreFeedRequest $request, CreateFeedAction $createFeedAction): JsonResponse
{
try {
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true;
// Map provider to URL and set type
$providers = [
'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(),
'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(),
];
$adapter = $providers[$validated['provider']];
$validated['url'] = $adapter->getHomepageUrl();
$validated['type'] = 'website';
$feed = Feed::create($validated);
$feed = $createFeedAction->execute(
$validated['name'],
$validated['provider'],
$validated['language_id'],
$validated['description'] ?? null,
);
return $this->sendResponse(
new FeedResource($feed),
'Feed created successfully!',
201
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (InvalidArgumentException $e) {
return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) {
return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500);
}
}
@ -99,7 +96,7 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to update feed: ' . $e->getMessage(), [], 500);
}
}
@ -116,7 +113,7 @@ public function destroy(Feed $feed): JsonResponse
null,
'Feed deleted successfully!'
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to delete feed: ' . $e->getMessage(), [], 500);
}
}
@ -136,7 +133,7 @@ public function toggle(Feed $feed): JsonResponse
new FeedResource($feed->fresh()),
"Feed {$status} successfully!"
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to toggle feed status: ' . $e->getMessage(), [], 500);
}
}

View file

@ -2,16 +2,21 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\PlatformEnum;
use App\Actions\CreatePlatformAccountAction;
use App\Exceptions\PlatformAuthException;
use App\Http\Requests\StorePlatformAccountRequest;
use App\Http\Resources\PlatformAccountResource;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PlatformAccountsController extends BaseController
{
public function __construct(
private readonly CreatePlatformAccountAction $createPlatformAccountAction,
) {}
/**
* Display a listing of platform accounts
*/
@ -30,46 +35,30 @@ public function index(): JsonResponse
/**
* Store a newly created platform account
*/
public function store(Request $request): JsonResponse
public function store(StorePlatformAccountRequest $request): JsonResponse
{
try {
$validated = $request->validate([
'platform' => 'required|in:lemmy,mastodon,reddit',
'instance_url' => 'required|url',
'username' => 'required|string|max:255',
'password' => 'required|string',
'settings' => 'nullable|array',
]);
$validated = $request->validated();
// Create or find platform instance
$platformEnum = PlatformEnum::from($validated['platform']);
$instance = PlatformInstance::firstOrCreate([
'platform' => $platformEnum,
'url' => $validated['instance_url'],
], [
'name' => parse_url($validated['instance_url'], PHP_URL_HOST),
'description' => ucfirst($validated['platform']) . ' instance',
'is_active' => true,
]);
$account = PlatformAccount::create($validated);
// If this is the first account for this platform, make it active
if (!PlatformAccount::where('platform', $validated['platform'])
->where('is_active', true)
->exists()) {
$account->setAsActive();
}
$account = $this->createPlatformAccountAction->execute(
$validated['instance_url'],
$validated['username'],
$validated['password'],
$validated['platform'],
);
return $this->sendResponse(
new PlatformAccountResource($account),
'Platform account created successfully!',
201
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 500);
} catch (PlatformAuthException $e) {
if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429);
}
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (Exception $e) {
return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422);
}
}
@ -110,7 +99,7 @@ public function update(Request $request, PlatformAccount $platformAccount): Json
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to update platform account: ' . $e->getMessage(), [], 500);
}
}
@ -127,7 +116,7 @@ public function destroy(PlatformAccount $platformAccount): JsonResponse
null,
'Platform account deleted successfully!'
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to delete platform account: ' . $e->getMessage(), [], 500);
}
}
@ -144,7 +133,7 @@ public function setActive(PlatformAccount $platformAccount): JsonResponse
new PlatformAccountResource($platformAccount->fresh()),
"Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!"
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to set platform account as active: ' . $e->getMessage(), [], 500);
}
}

View file

@ -2,12 +2,16 @@
namespace App\Http\Controllers\Api\V1;
use App\Actions\CreateChannelAction;
use App\Http\Requests\StorePlatformChannelRequest;
use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformChannel;
use App\Models\PlatformAccount;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use RuntimeException;
class PlatformChannelsController extends BaseController
{
@ -30,55 +34,26 @@ public function index(): JsonResponse
/**
* Store a newly created platform channel
*/
public function store(Request $request): JsonResponse
public function store(StorePlatformChannelRequest $request, CreateChannelAction $createChannelAction): JsonResponse
{
try {
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'channel_id' => 'required|string|max:255',
'name' => 'required|string|max:255',
'display_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true;
// Get the platform instance to check for active accounts
$platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->get();
if ($activeAccounts->isEmpty()) {
return $this->sendError(
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
[],
422
$channel = $createChannelAction->execute(
$validated['name'],
$validated['platform_instance_id'],
$validated['language_id'] ?? null,
$validated['description'] ?? null,
);
}
$channel = PlatformChannel::create($validated);
// Automatically attach the first active account to the channel
$firstAccount = $activeAccounts->first();
$channel->platformAccounts()->attach($firstAccount->id, [
'is_active' => true,
'priority' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
return $this->sendResponse(
new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])),
'Platform channel created successfully and linked to platform account!',
201
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (RuntimeException $e) {
return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) {
return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500);
}
}
@ -115,7 +90,7 @@ public function update(Request $request, PlatformChannel $platformChannel): Json
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500);
}
}
@ -132,7 +107,7 @@ public function destroy(PlatformChannel $platformChannel): JsonResponse
null,
'Platform channel deleted successfully!'
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500);
}
}
@ -152,7 +127,7 @@ public function toggle(PlatformChannel $channel): JsonResponse
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
"Platform channel {$status} successfully!"
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500);
}
}
@ -189,7 +164,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500);
}
}
@ -210,7 +185,7 @@ public function detachAccount(PlatformChannel $channel, PlatformAccount $account
new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])),
'Platform account detached from channel successfully!'
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500);
}
}
@ -242,7 +217,7 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
}
}

View file

@ -2,10 +2,13 @@
namespace App\Http\Controllers\Api\V1;
use App\Actions\CreateRouteAction;
use App\Http\Requests\StoreRouteRequest;
use App\Http\Resources\RouteResource;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -31,29 +34,24 @@ public function index(): JsonResponse
/**
* Store a newly created routing configuration
*/
public function store(Request $request): JsonResponse
public function store(StoreRouteRequest $request, CreateRouteAction $createRouteAction): JsonResponse
{
try {
$validated = $request->validate([
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
]);
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true;
$validated['priority'] = $validated['priority'] ?? 0;
$route = Route::create($validated);
$route = $createRouteAction->execute(
$validated['feed_id'],
$validated['platform_channel_id'],
$validated['priority'] ?? 0,
$validated['is_active'] ?? true,
);
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
'Routing configuration created successfully!',
201
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500);
}
}
@ -104,7 +102,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to update routing configuration: ' . $e->getMessage(), [], 500);
}
}
@ -129,7 +127,7 @@ public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
null,
'Routing configuration deleted successfully!'
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to delete routing configuration: ' . $e->getMessage(), [], 500);
}
}
@ -157,7 +155,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
"Routing configuration {$status} successfully!"
);
} catch (\Exception $e) {
} catch (Exception $e) {
return $this->sendError('Failed to toggle routing configuration status: ' . $e->getMessage(), [], 500);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePlatformAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'platform' => 'required|in:lemmy',
'instance_url' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
'username' => 'required|string|max:255',
'password' => 'required|string',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePlatformChannelRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'platform_instance_id' => 'required|exists:platform_instances,id',
'name' => 'required|string|max:255',
'language_id' => 'nullable|exists:languages,id',
'description' => 'nullable|string',
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreRouteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'is_active' => 'boolean',
'priority' => 'nullable|integer|min:0',
];
}
}

View file

@ -49,7 +49,7 @@ public function test_index_returns_feeds_ordered_by_active_status_then_name(): v
public function test_store_creates_vrt_feed_successfully(): void
{
$language = Language::factory()->create();
$language = Language::factory()->english()->create();
$feedData = [
'name' => 'VRT Test Feed',
@ -81,7 +81,7 @@ public function test_store_creates_vrt_feed_successfully(): void
public function test_store_creates_belga_feed_successfully(): void
{
$language = Language::factory()->create();
$language = Language::factory()->english()->create();
$feedData = [
'name' => 'Belga Test Feed',
@ -99,7 +99,7 @@ public function test_store_creates_belga_feed_successfully(): void
'data' => [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'type' => 'rss',
'is_active' => true,
]
]);
@ -107,13 +107,13 @@ public function test_store_creates_belga_feed_successfully(): void
$this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'type' => 'rss',
]);
}
public function test_store_sets_default_active_status(): void
{
$language = Language::factory()->create();
$language = Language::factory()->english()->create();
$feedData = [
'name' => 'Test Feed',

View file

@ -4,7 +4,9 @@
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PlatformAccountsControllerTest extends TestCase
@ -42,12 +44,21 @@ public function test_index_returns_successful_response(): void
public function test_store_creates_platform_account_successfully(): void
{
$mockAuth = Mockery::mock(LemmyAuthService::class);
$mockAuth->shouldReceive('authenticate')
->once()
->with('https://lemmy.example.com', 'testuser', 'testpass123')
->andReturn([
'jwt' => 'test-token',
'person_view' => ['person' => ['id' => 1, 'display_name' => null, 'bio' => null]],
]);
$this->app->instance(LemmyAuthService::class, $mockAuth);
$data = [
'platform' => 'lemmy',
'instance_url' => 'https://lemmy.example.com',
'instance_url' => 'lemmy.example.com',
'username' => 'testuser',
'password' => 'testpass123',
'settings' => ['key' => 'value']
];
$response = $this->postJson('/api/v1/platform-accounts', $data);

View file

@ -56,11 +56,8 @@ public function test_store_creates_platform_channel_successfully(): void
$data = [
'platform_instance_id' => $instance->id,
'channel_id' => 'test_channel',
'name' => 'Test Channel',
'display_name' => 'Test Channel Display',
'name' => 'test_channel',
'description' => 'A test channel',
'is_active' => true
];
$response = $this->postJson('/api/v1/platform-channels', $data);
@ -89,7 +86,7 @@ public function test_store_creates_platform_channel_successfully(): void
$this->assertDatabaseHas('platform_channels', [
'platform_instance_id' => $instance->id,
'channel_id' => 'test_channel',
'name' => 'Test Channel',
'name' => 'test_channel',
]);
}
@ -98,14 +95,13 @@ public function test_store_validates_required_fields(): void
$response = $this->postJson('/api/v1/platform-channels', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['platform_instance_id', 'channel_id', 'name']);
->assertJsonValidationErrors(['platform_instance_id', 'name']);
}
public function test_store_validates_platform_instance_exists(): void
{
$data = [
'platform_instance_id' => 999,
'channel_id' => 'test_channel',
'name' => 'Test Channel'
];

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Actions;
use App\Actions\CreatePlatformAccountAction;
use App\Enums\PlatformEnum;
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
@ -89,7 +90,7 @@ public function test_propagates_auth_exception(): void
$this->lemmyAuthService
->shouldReceive('authenticate')
->once()
->andThrow(new PlatformAuthException(\App\Enums\PlatformEnum::LEMMY, 'Invalid credentials'));
->andThrow(new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials'));
try {
$this->action->execute('lemmy.world', 'baduser', 'badpass');