Compare commits

..

No commits in common. "0123e20b1d93658a53e6d417afb5c436715dc32c" and "3ed49cfbbe2ce36eaaa2eec9ae79e971e2ec5b31" have entirely different histories.

28 changed files with 706 additions and 824 deletions

View file

@ -1,28 +0,0 @@
APP_NAME=Laravel
APP_ENV=testing
APP_KEY=base64:5VABFQKtzx6flRFn7rQUQYI/G8xLnkUSYPVaYz2s/4M=
APP_DEBUG=true
APP_URL=http://localhost
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=4
LOG_CHANNEL=stack
LOG_STACK=single
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
SESSION_DRIVER=array
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=array
MAIL_MAILER=array
PULSE_ENABLED=false
TELESCOPE_ENABLED=false

View file

@ -1,47 +0,0 @@
<?php
namespace App\Actions;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class CreateChannelAction
{
public function execute(string $name, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
{
$platformInstance = PlatformInstance::findOrFail($platformInstanceId);
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->get();
if ($activeAccounts->isEmpty()) {
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,
'name' => $name,
'display_name' => ucfirst($name),
'description' => $description,
'language_id' => $languageId,
'is_active' => true,
]);
// Attach only the first active account — additional accounts can be linked via the channel management UI
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
'is_active' => true,
'priority' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
return $channel->load('platformAccounts');
});
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Feed;
use App\Models\Language;
use InvalidArgumentException;
class CreateFeedAction
{
public function execute(string $name, string $provider, int $languageId, ?string $description = null): Feed
{
$language = Language::findOrFail($languageId);
$langCode = $language->short_code;
$url = config("feed.providers.{$provider}.languages.{$langCode}.url");
if (!$url) {
throw new InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}");
}
$providerConfig = config("feed.providers.{$provider}");
return Feed::firstOrCreate(
['url' => $url],
[
'name' => $name,
'type' => $providerConfig['type'] ?? 'website',
'provider' => $provider,
'language_id' => $languageId,
'description' => $description,
'is_active' => true,
]
);
}
}

View file

@ -1,53 +0,0 @@
<?php
namespace App\Actions;
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Support\Facades\DB;
class CreatePlatformAccountAction
{
public function __construct(
private readonly LemmyAuthService $lemmyAuthService,
) {}
/**
* @throws PlatformAuthException
*/
public function execute(string $instanceDomain, string $username, string $password, string $platform = 'lemmy'): PlatformAccount
{
$fullInstanceUrl = 'https://' . $instanceDomain;
// 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,
], [
'name' => ucfirst($instanceDomain),
'is_active' => true,
]);
return PlatformAccount::create([
'platform' => $platform,
'instance_url' => $fullInstanceUrl,
'username' => $username,
'password' => $password,
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
'api_token' => $authResponse['jwt'] ?? null,
],
'is_active' => true,
'status' => 'active',
]);
});
}
}

View file

@ -1,26 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Route;
class CreateRouteAction
{
/**
* Create a route or return an existing one for the same feed+channel pair.
* When a route already exists, the provided priority and isActive values are ignored.
*/
public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route
{
return Route::firstOrCreate(
[
'feed_id' => $feedId,
'platform_channel_id' => $platformChannelId,
],
[
'priority' => $priority,
'is_active' => $isActive,
]
);
}
}

View file

@ -2,15 +2,12 @@
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
@ -44,26 +41,32 @@ public function index(Request $request): JsonResponse
/**
* Store a newly created feed
*/
public function store(StoreFeedRequest $request, CreateFeedAction $createFeedAction): JsonResponse
public function store(StoreFeedRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true;
$feed = $createFeedAction->execute(
$validated['name'],
$validated['provider'],
$validated['language_id'],
$validated['description'] ?? null,
);
// 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);
return $this->sendResponse(
new FeedResource($feed),
'Feed created successfully!',
201
);
} catch (InvalidArgumentException $e) {
return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) {
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500);
}
}
@ -96,7 +99,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);
}
}
@ -113,7 +116,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);
}
}
@ -133,7 +136,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,6 +2,12 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\StoreFeedRequest;
use App\Http\Resources\FeedResource;
use App\Http\Resources\PlatformAccountResource;
use App\Http\Resources\PlatformChannelResource;
use App\Http\Resources\RouteResource;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
@ -9,10 +15,18 @@
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class OnboardingController extends BaseController
{
public function __construct(
private readonly LemmyAuthService $lemmyAuthService
) {}
/**
* Get onboarding status - whether user needs onboarding
*/
@ -97,6 +111,233 @@ public function options(): JsonResponse
], 'Onboarding options retrieved successfully.');
}
/**
* Create platform account for onboarding
*/
public function createPlatform(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'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|min:6',
'platform' => 'required|in:lemmy',
], [
'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)'
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
// Normalize the instance URL - prepend https:// if needed
$instanceDomain = $validated['instance_url'];
$fullInstanceUrl = 'https://' . $instanceDomain;
try {
// Create or get platform instance
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => $validated['platform'],
], [
'name' => ucfirst($instanceDomain),
'is_active' => true,
]);
// Authenticate with Lemmy API using the full URL
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$validated['username'],
$validated['password']
);
// Create platform account with the current schema
$platformAccount = PlatformAccount::create([
'platform' => $validated['platform'],
'instance_url' => $fullInstanceUrl,
'username' => $validated['username'],
'password' => $validated['password'],
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
],
'is_active' => true,
'status' => 'active',
]);
return $this->sendResponse(
new PlatformAccountResource($platformAccount),
'Platform account created successfully.'
);
} catch (\App\Exceptions\PlatformAuthException $e) {
// Check if it's a rate limit error
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) {
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
// If it's a network/connection issue, provide a more specific message
if (str_contains(strtolower($e->getMessage()), 'connection') ||
str_contains(strtolower($e->getMessage()), 'network') ||
str_contains(strtolower($e->getMessage()), 'timeout')) {
$message = 'Connection failed. Please check the instance URL and your internet connection.';
}
return $this->sendError($message, [], 422);
}
}
/**
* Create feed for onboarding
*/
public function createFeed(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'provider' => 'required|in:belga,vrt',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
// Map provider to preset URL and type as required by onboarding tests
$provider = $validated['provider'];
$url = null;
$type = 'website';
if ($provider === 'vrt') {
$url = 'https://www.vrt.be/vrtnws/en/';
} elseif ($provider === 'belga') {
$url = 'https://www.belganewsagency.eu/';
}
$feed = Feed::firstOrCreate(
['url' => $url],
[
'name' => $validated['name'],
'type' => $type,
'provider' => $provider,
'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null,
'is_active' => true,
]
);
return $this->sendResponse(
new FeedResource($feed->load('language')),
'Feed created successfully.'
);
}
/**
* Create channel for onboarding
* @throws ValidationException
*/
public function createChannel(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'platform_instance_id' => 'required|exists:platform_instances,id',
'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string|max:1000',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
// Get the platform instance to check for active accounts
$platformInstance = 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 = PlatformChannel::create([
'platform_instance_id' => $validated['platform_instance_id'],
'channel_id' => $validated['name'], // For Lemmy, this is the community name
'name' => $validated['name'],
'display_name' => ucfirst($validated['name']),
'description' => $validated['description'] ?? null,
'language_id' => $validated['language_id'],
'is_active' => true,
]);
// 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', 'language', 'platformAccounts'])),
'Channel created successfully and linked to platform account.'
);
}
/**
* Create route for onboarding
*
* @throws ValidationException
*/
public function createRoute(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id',
'priority' => 'nullable|integer|min:1|max:100',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
$validated = $validator->validated();
$route = Route::create([
'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'],
'priority' => $validated['priority'] ?? 50,
'is_active' => true,
]);
// Trigger article discovery when the first route is created during onboarding
// This ensures articles start being fetched immediately after setup
ArticleDiscoveryJob::dispatch();
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel'])),
'Route created successfully.'
);
}
/**
* Mark onboarding as complete
*/

View file

@ -2,12 +2,10 @@
namespace App\Http\Controllers\Api\V1;
use App\Actions\CreatePlatformAccountAction;
use App\Exceptions\PlatformAuthException;
use App\Http\Requests\StorePlatformAccountRequest;
use App\Enums\PlatformEnum;
use App\Http\Resources\PlatformAccountResource;
use App\Models\PlatformAccount;
use Exception;
use App\Models\PlatformInstance;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -32,30 +30,46 @@ public function index(): JsonResponse
/**
* Store a newly created platform account
*/
public function store(StorePlatformAccountRequest $request, CreatePlatformAccountAction $action): JsonResponse
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validated();
$validated = $request->validate([
'platform' => 'required|in:lemmy,mastodon,reddit',
'instance_url' => 'required|url',
'username' => 'required|string|max:255',
'password' => 'required|string',
'settings' => 'nullable|array',
]);
$account = $action->execute(
$validated['instance_domain'],
$validated['username'],
$validated['password'],
$validated['platform'],
);
// 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();
}
return $this->sendResponse(
new PlatformAccountResource($account),
'Platform account created successfully!',
201
);
} 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);
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 500);
}
}
@ -96,7 +110,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);
}
}
@ -113,7 +127,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);
}
}
@ -130,7 +144,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,16 +2,12 @@
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
{
@ -34,26 +30,55 @@ public function index(): JsonResponse
/**
* Store a newly created platform channel
*/
public function store(StorePlatformChannelRequest $request, CreateChannelAction $createChannelAction): JsonResponse
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validated();
$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',
]);
$channel = $createChannelAction->execute(
$validated['name'],
$validated['platform_instance_id'],
$validated['language_id'] ?? null,
$validated['description'] ?? null,
);
$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 = 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 (RuntimeException $e) {
return $this->sendError($e->getMessage(), [], 422);
} catch (Exception $e) {
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500);
}
}
@ -90,7 +115,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);
}
}
@ -107,7 +132,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);
}
}
@ -127,7 +152,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);
}
}
@ -164,7 +189,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);
}
}
@ -185,7 +210,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);
}
}
@ -217,7 +242,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,13 +2,10 @@
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;
@ -34,24 +31,29 @@ public function index(): JsonResponse
/**
* Store a newly created routing configuration
*/
public function store(StoreRouteRequest $request, CreateRouteAction $createRouteAction): JsonResponse
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validated();
$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',
]);
$route = $createRouteAction->execute(
$validated['feed_id'],
$validated['platform_channel_id'],
$validated['priority'] ?? 0,
$validated['is_active'] ?? true,
);
$validated['is_active'] = $validated['is_active'] ?? true;
$validated['priority'] = $validated['priority'] ?? 0;
$route = Route::create($validated);
return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
'Routing configuration created successfully!',
201
);
} catch (Exception $e) {
} catch (ValidationException $e) {
return $this->sendValidationError($e->errors());
} catch (\Exception $e) {
return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500);
}
}
@ -102,7 +104,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);
}
}
@ -127,7 +129,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);
}
}
@ -155,7 +157,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

@ -1,36 +0,0 @@
<?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_domain' => '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_domain.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
];
}
}

View file

@ -1,26 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

@ -2,11 +2,6 @@
namespace App\Livewire;
use App\Actions\CreateChannelAction;
use App\Actions\CreateFeedAction;
use App\Actions\CreatePlatformAccountAction;
use App\Actions\CreateRouteAction;
use App\Exceptions\PlatformAuthException;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\SyncChannelPostsJob;
use App\Models\Feed;
@ -16,11 +11,10 @@
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use App\Services\OnboardingService;
use Exception;
use InvalidArgumentException;
use Illuminate\Support\Facades\Crypt;
use Livewire\Component;
use RuntimeException;
class Onboarding extends Component
{
@ -53,24 +47,13 @@ class Onboarding extends Component
// State
public array $formErrors = [];
public bool $isLoading = false;
#[\Livewire\Attributes\Locked]
public ?int $previousChannelLanguageId = null;
private ?int $previousChannelLanguageId = null;
protected CreatePlatformAccountAction $createPlatformAccountAction;
protected CreateFeedAction $createFeedAction;
protected CreateChannelAction $createChannelAction;
protected CreateRouteAction $createRouteAction;
protected LemmyAuthService $lemmyAuthService;
public function boot(
CreatePlatformAccountAction $createPlatformAccountAction,
CreateFeedAction $createFeedAction,
CreateChannelAction $createChannelAction,
CreateRouteAction $createRouteAction,
): void {
$this->createPlatformAccountAction = $createPlatformAccountAction;
$this->createFeedAction = $createFeedAction;
$this->createChannelAction = $createChannelAction;
$this->createRouteAction = $createRouteAction;
public function boot(LemmyAuthService $lemmyAuthService): void
{
$this->lemmyAuthService = $lemmyAuthService;
}
public function mount(): void
@ -163,13 +146,42 @@ public function createPlatformAccount(): void
'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)',
]);
$fullInstanceUrl = 'https://' . $this->instanceUrl;
try {
$platformAccount = $this->createPlatformAccountAction->execute(
$this->instanceUrl,
// Authenticate with Lemmy API first (before creating any records)
$authResponse = $this->lemmyAuthService->authenticate(
$fullInstanceUrl,
$this->username,
$this->password,
$this->password
);
// Only create platform instance after successful authentication
$platformInstance = PlatformInstance::firstOrCreate([
'url' => $fullInstanceUrl,
'platform' => 'lemmy',
], [
'name' => ucfirst($this->instanceUrl),
'is_active' => true,
]);
// Create platform account
$platformAccount = PlatformAccount::create([
'platform' => 'lemmy',
'instance_url' => $fullInstanceUrl,
'username' => $this->username,
'password' => Crypt::encryptString($this->password),
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
'api_token' => $authResponse['jwt'] ?? null,
],
'is_active' => true,
'status' => 'active',
]);
$this->existingAccount = [
'id' => $platformAccount->id,
'username' => $platformAccount->username,
@ -177,7 +189,7 @@ public function createPlatformAccount(): void
];
$this->nextStep();
} catch (PlatformAuthException $e) {
} catch (\App\Exceptions\PlatformAuthException $e) {
$message = $e->getMessage();
if (str_contains($message, 'Rate limited by')) {
$this->formErrors['general'] = $message;
@ -186,9 +198,9 @@ public function createPlatformAccount(): void
} else {
$this->formErrors['general'] = 'Invalid username or password. Please check your credentials and try again.';
}
} catch (Exception $e) {
} catch (\Exception $e) {
logger()->error('Lemmy platform account creation failed', [
'instance_url' => 'https://' . $this->instanceUrl,
'instance_url' => $fullInstanceUrl,
'username' => $this->username,
'error' => $e->getMessage(),
'class' => get_class($e),
@ -215,17 +227,35 @@ public function createFeed(): void
]);
try {
$this->createFeedAction->execute(
$this->feedName,
$this->feedProvider,
$this->feedLanguageId,
$this->feedDescription ?: null,
// Get language short code
$language = Language::find($this->feedLanguageId);
$langCode = $language->short_code;
// Look up URL from config
$url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url");
if (!$url) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
$this->isLoading = false;
return;
}
$providerConfig = config("feed.providers.{$this->feedProvider}");
Feed::firstOrCreate(
['url' => $url],
[
'name' => $this->feedName,
'type' => $providerConfig['type'] ?? 'website',
'provider' => $this->feedProvider,
'language_id' => $this->feedLanguageId,
'description' => $this->feedDescription ?: null,
'is_active' => true,
]
);
$this->nextStep();
} catch (InvalidArgumentException $e) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
} catch (Exception $e) {
} catch (\Exception $e) {
$this->formErrors['general'] = 'Failed to create feed. Please try again.';
} finally {
$this->isLoading = false;
@ -255,20 +285,42 @@ public function createChannel(): void
$this->previousChannelLanguageId = $this->channelLanguageId;
try {
$channel = $this->createChannelAction->execute(
$this->channelName,
$this->platformInstanceId,
$this->channelLanguageId,
$this->channelDescription ?: null,
);
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
// Check for active platform accounts
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->get();
if ($activeAccounts->isEmpty()) {
$this->formErrors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.';
$this->isLoading = false;
return;
}
$channel = PlatformChannel::create([
'platform_instance_id' => $this->platformInstanceId,
'channel_id' => $this->channelName,
'name' => $this->channelName,
'display_name' => ucfirst($this->channelName),
'description' => $this->channelDescription ?: null,
'language_id' => $this->channelLanguageId,
'is_active' => true,
]);
// Attach first active account
$channel->platformAccounts()->attach($activeAccounts->first()->id, [
'is_active' => true,
'priority' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// Sync existing posts from this channel for duplicate detection
SyncChannelPostsJob::dispatch($channel);
$this->nextStep();
} catch (RuntimeException $e) {
$this->formErrors['general'] = $e->getMessage();
} catch (Exception $e) {
} catch (\Exception $e) {
$this->formErrors['general'] = 'Failed to create channel. Please try again.';
} finally {
$this->isLoading = false;
@ -287,17 +339,18 @@ public function createRoute(): void
]);
try {
$this->createRouteAction->execute(
$this->routeFeedId,
$this->routeChannelId,
$this->routePriority,
);
Route::create([
'feed_id' => $this->routeFeedId,
'platform_channel_id' => $this->routeChannelId,
'priority' => $this->routePriority,
'is_active' => true,
]);
// Trigger article discovery
ArticleDiscoveryJob::dispatch();
$this->nextStep();
} catch (Exception $e) {
} catch (\Exception $e) {
$this->formErrors['general'] = 'Failed to create route. Please try again.';
} finally {
$this->isLoading = false;

View file

@ -61,6 +61,7 @@ public function casts(): array
public function isValid(): bool
{
// Article is valid if it passed validation and wasn't rejected
return $this->validated_at !== null && ! $this->isRejected();
}

View file

@ -18,17 +18,17 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<server name="APP_ENV" value="testing" force="true"/>
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
<server name="CACHE_STORE" value="array" force="true"/>
<server name="DB_CONNECTION" value="sqlite" force="true"/>
<server name="DB_DATABASE" value=":memory:" force="true"/>
<server name="MAIL_MAILER" value="array" force="true"/>
<server name="PULSE_ENABLED" value="false" force="true"/>
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
<server name="SESSION_DRIVER" value="array" force="true"/>
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:5VABFQKtzx6flRFn7rQUQYI/G8xLnkUSYPVaYz2s/4M="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -38,6 +38,10 @@
// Onboarding
Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status');
Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options');
Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
Route::post('/onboarding/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route');
Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');

View file

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

View file

@ -9,6 +9,7 @@
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -188,6 +189,180 @@ public function test_options_returns_languages_and_platform_instances()
]);
}
public function test_create_feed_validates_required_fields()
{
$response = $this->postJson('/api/v1/onboarding/feed', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'provider', 'language_id']);
}
public function test_create_feed_creates_vrt_feed_successfully()
{
$feedData = [
'name' => 'VRT Test Feed',
'provider' => 'vrt',
'language_id' => 1,
'description' => 'Test description',
];
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'name' => 'VRT Test Feed',
'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'website',
'is_active' => true,
]
]);
$this->assertDatabaseHas('feeds', [
'name' => 'VRT Test Feed',
'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'website',
'language_id' => 1,
'is_active' => true,
]);
}
public function test_create_feed_creates_belga_feed_successfully()
{
$feedData = [
'name' => 'Belga Test Feed',
'provider' => 'belga',
'language_id' => 1,
'description' => 'Test description',
];
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'is_active' => true,
]
]);
$this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/',
'type' => 'website',
'language_id' => 1,
'is_active' => true,
]);
}
public function test_create_feed_rejects_invalid_provider()
{
$feedData = [
'name' => 'Invalid Feed',
'provider' => 'invalid',
'language_id' => 1,
'description' => 'Test description',
];
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
$response->assertStatus(422)
->assertJsonValidationErrors(['provider']);
}
public function test_create_channel_validates_required_fields()
{
$response = $this->postJson('/api/v1/onboarding/channel', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'platform_instance_id', 'language_id']);
}
public function test_create_channel_creates_channel_successfully()
{
$platformInstance = PlatformInstance::factory()->create();
$language = Language::factory()->create();
// Create a platform account for this instance first
PlatformAccount::factory()->create([
'instance_url' => $platformInstance->url,
'is_active' => true
]);
$channelData = [
'name' => 'test_community',
'platform_instance_id' => $platformInstance->id,
'language_id' => $language->id,
'description' => 'Test community description',
];
$response = $this->postJson('/api/v1/onboarding/channel', $channelData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'name' => 'test_community',
'display_name' => 'Test_community',
'channel_id' => 'test_community',
'is_active' => true,
]
]);
$this->assertDatabaseHas('platform_channels', [
'name' => 'test_community',
'channel_id' => 'test_community',
'platform_instance_id' => $platformInstance->id,
'language_id' => $language->id,
'is_active' => true,
]);
}
public function test_create_route_validates_required_fields()
{
$response = $this->postJson('/api/v1/onboarding/route', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['feed_id', 'platform_channel_id']);
}
public function test_create_route_creates_route_successfully()
{
$language = Language::first();
$feed = Feed::factory()->language($language)->create();
$platformChannel = PlatformChannel::factory()->create();
$routeData = [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
];
$response = $this->postJson('/api/v1/onboarding/route', $routeData);
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
'is_active' => true,
]
]);
$this->assertDatabaseHas('routes', [
'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id,
'priority' => 75,
'is_active' => true,
]);
}
public function test_complete_onboarding_returns_success()
{
$response = $this->postJson('/api/v1/onboarding/complete');
@ -268,6 +443,27 @@ public function test_reset_skip_works_when_no_setting_exists()
]);
}
public function test_create_platform_validates_instance_url_format()
{
$response = $this->postJson('/api/v1/onboarding/platform', [
'instance_url' => 'invalid.domain.with.spaces and symbols!',
'username' => 'testuser',
'password' => 'password123',
'platform' => 'lemmy',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['instance_url']);
}
public function test_create_platform_validates_required_fields()
{
$response = $this->postJson('/api/v1/onboarding/platform', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['instance_url', 'username', 'password', 'platform']);
}
public function test_onboarding_flow_integration()
{
// 1. Initial status - needs onboarding

View file

@ -4,9 +4,7 @@
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
@ -44,21 +42,12 @@ 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_domain' => 'lemmy.example.com',
'instance_url' => 'https://lemmy.example.com',
'username' => 'testuser',
'password' => 'testpass123',
'settings' => ['key' => 'value']
];
$response = $this->postJson('/api/v1/platform-accounts', $data);
@ -94,7 +83,7 @@ public function test_store_validates_required_fields(): void
$response = $this->postJson('/api/v1/platform-accounts', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['platform', 'instance_domain', 'username', 'password']);
->assertJsonValidationErrors(['platform', 'instance_url', 'username', 'password']);
}
public function test_show_returns_platform_account_successfully(): void

View file

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

View file

@ -18,7 +18,6 @@
use App\Models\Article;
use App\Models\Feed;
use App\Models\Log;
use App\Models\Setting;
use App\Models\PlatformChannel;
use App\Services\Log\LogSaver;
use App\Services\Article\ArticleFetcher;
@ -176,10 +175,7 @@ public function test_exception_logged_event_is_dispatched(): void
public function test_validate_article_listener_processes_new_article(): void
{
Event::fake([ArticleApproved::class]);
// Disable approvals so listener auto-approves valid articles
Setting::setBool('enable_publishing_approvals', false);
Event::fake([ArticleReadyToPublish::class]);
$feed = Feed::factory()->create();
$article = Article::factory()->create([
@ -204,8 +200,8 @@ public function test_validate_article_listener_processes_new_article(): void
$listener->handle($event);
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
Event::assertDispatched(ArticleApproved::class);
$this->assertNotEquals('pending', $article->approval_status);
$this->assertContains($article->approval_status, ['approved', 'rejected']);
}
// Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist

View file

@ -1,86 +0,0 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\CreateChannelAction;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateChannelActionTest extends TestCase
{
use RefreshDatabase;
private CreateChannelAction $action;
protected function setUp(): void
{
parent::setUp();
$this->action = new CreateChannelAction();
}
public function test_creates_channel_and_attaches_account(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
$account = PlatformAccount::factory()->create([
'instance_url' => 'https://lemmy.world',
'is_active' => true,
]);
$language = Language::factory()->create();
$channel = $this->action->execute('test_community', $instance->id, $language->id, 'A description');
$this->assertInstanceOf(PlatformChannel::class, $channel);
$this->assertEquals('test_community', $channel->name);
$this->assertEquals('test_community', $channel->channel_id);
$this->assertEquals('Test_community', $channel->display_name);
$this->assertEquals($instance->id, $channel->platform_instance_id);
$this->assertEquals($language->id, $channel->language_id);
$this->assertEquals('A description', $channel->description);
$this->assertTrue($channel->is_active);
// Verify account is attached
$this->assertTrue($channel->platformAccounts->contains($account));
$this->assertEquals(1, $channel->platformAccounts->first()->pivot->priority);
}
public function test_creates_channel_without_language(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
PlatformAccount::factory()->create([
'instance_url' => 'https://lemmy.world',
'is_active' => true,
]);
$channel = $this->action->execute('test_community', $instance->id);
$this->assertNull($channel->language_id);
}
public function test_fails_when_no_active_accounts(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
// Create inactive account
PlatformAccount::factory()->create([
'instance_url' => 'https://lemmy.world',
'is_active' => false,
]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No active platform accounts found for this instance');
$this->action->execute('test_community', $instance->id);
}
public function test_fails_when_no_accounts_at_all(): void
{
$instance = PlatformInstance::factory()->create();
$this->expectException(\RuntimeException::class);
$this->action->execute('test_community', $instance->id);
}
}

View file

@ -1,79 +0,0 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\CreateFeedAction;
use App\Models\Feed;
use App\Models\Language;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateFeedActionTest extends TestCase
{
use RefreshDatabase;
private CreateFeedAction $action;
protected function setUp(): void
{
parent::setUp();
$this->action = new CreateFeedAction();
}
public function test_creates_vrt_feed_with_correct_url(): void
{
$language = Language::factory()->create(['short_code' => 'en', 'is_active' => true]);
$feed = $this->action->execute('VRT News', 'vrt', $language->id, 'Test description');
$this->assertInstanceOf(Feed::class, $feed);
$this->assertEquals('VRT News', $feed->name);
$this->assertEquals('https://www.vrt.be/vrtnws/en/', $feed->url);
$this->assertEquals('website', $feed->type);
$this->assertEquals('vrt', $feed->provider);
$this->assertEquals($language->id, $feed->language_id);
$this->assertEquals('Test description', $feed->description);
$this->assertTrue($feed->is_active);
}
public function test_creates_belga_feed_with_correct_url(): void
{
$language = Language::factory()->create(['short_code' => 'en', 'is_active' => true]);
$feed = $this->action->execute('Belga News', 'belga', $language->id);
$this->assertEquals('https://www.belganewsagency.eu/', $feed->url);
$this->assertEquals('rss', $feed->type);
$this->assertEquals('belga', $feed->provider);
$this->assertNull($feed->description);
}
public function test_creates_vrt_feed_with_dutch_language(): void
{
$language = Language::factory()->create(['short_code' => 'nl', 'is_active' => true]);
$feed = $this->action->execute('VRT Nieuws', 'vrt', $language->id);
$this->assertEquals('https://www.vrt.be/vrtnws/nl/', $feed->url);
}
public function test_returns_existing_feed_for_duplicate_url(): void
{
$language = Language::factory()->create(['short_code' => 'en', 'is_active' => true]);
$first = $this->action->execute('VRT News', 'vrt', $language->id);
$second = $this->action->execute('VRT News Duplicate', 'vrt', $language->id);
$this->assertEquals($first->id, $second->id);
$this->assertEquals(1, Feed::count());
}
public function test_throws_exception_for_invalid_provider_language_combination(): void
{
$language = Language::factory()->create(['short_code' => 'fr', 'is_active' => true]);
$this->expectException(\InvalidArgumentException::class);
$this->action->execute('Feed', 'belga', $language->id);
}
}

View file

@ -1,104 +0,0 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\CreatePlatformAccountAction;
use App\Enums\PlatformEnum;
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class CreatePlatformAccountActionTest extends TestCase
{
use RefreshDatabase;
private CreatePlatformAccountAction $action;
private LemmyAuthService $lemmyAuthService;
protected function setUp(): void
{
parent::setUp();
$this->lemmyAuthService = Mockery::mock(LemmyAuthService::class);
$this->action = new CreatePlatformAccountAction($this->lemmyAuthService);
}
public function test_creates_platform_account_with_new_instance(): void
{
$this->lemmyAuthService
->shouldReceive('authenticate')
->once()
->with('https://lemmy.world', 'testuser', 'testpass')
->andReturn([
'jwt' => 'test-jwt-token',
'person_view' => [
'person' => [
'id' => 42,
'display_name' => 'Test User',
'bio' => 'A test bio',
],
],
]);
$account = $this->action->execute('lemmy.world', 'testuser', 'testpass');
$this->assertInstanceOf(PlatformAccount::class, $account);
$this->assertEquals('testuser', $account->username);
$this->assertEquals('https://lemmy.world', $account->instance_url);
$this->assertEquals('lemmy', $account->platform->value);
$this->assertTrue($account->is_active);
$this->assertEquals('active', $account->status);
$this->assertEquals(42, $account->settings['person_id']);
$this->assertEquals('Test User', $account->settings['display_name']);
$this->assertEquals('A test bio', $account->settings['description']);
$this->assertEquals('test-jwt-token', $account->settings['api_token']);
$this->assertDatabaseHas('platform_instances', [
'url' => 'https://lemmy.world',
'platform' => 'lemmy',
]);
}
public function test_reuses_existing_platform_instance(): void
{
$existingInstance = PlatformInstance::factory()->create([
'url' => 'https://lemmy.world',
'platform' => 'lemmy',
'name' => 'Existing Name',
]);
$this->lemmyAuthService
->shouldReceive('authenticate')
->once()
->andReturn([
'jwt' => 'token',
'person_view' => ['person' => ['id' => 1, 'display_name' => null, 'bio' => null]],
]);
$account = $this->action->execute('lemmy.world', 'user', 'pass');
$this->assertEquals($existingInstance->id, $account->settings['platform_instance_id']);
$this->assertEquals(1, PlatformInstance::where('url', 'https://lemmy.world')->count());
}
public function test_propagates_auth_exception(): void
{
$this->lemmyAuthService
->shouldReceive('authenticate')
->once()
->andThrow(new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials'));
try {
$this->action->execute('lemmy.world', 'baduser', 'badpass');
$this->fail('Expected PlatformAuthException was not thrown');
} catch (PlatformAuthException) {
// No instance or account should be created on auth failure
$this->assertDatabaseCount('platform_instances', 0);
$this->assertDatabaseCount('platform_accounts', 0);
}
}
}

View file

@ -1,76 +0,0 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\CreateRouteAction;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateRouteActionTest extends TestCase
{
use RefreshDatabase;
private CreateRouteAction $action;
protected function setUp(): void
{
parent::setUp();
$this->action = new CreateRouteAction();
}
public function test_creates_route_with_defaults(): void
{
$language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create();
$channel = PlatformChannel::factory()->create();
$route = $this->action->execute($feed->id, $channel->id);
$this->assertInstanceOf(Route::class, $route);
$this->assertEquals($feed->id, $route->feed_id);
$this->assertEquals($channel->id, $route->platform_channel_id);
$this->assertEquals(0, $route->priority);
$this->assertTrue($route->is_active);
}
public function test_creates_route_with_custom_priority(): void
{
$language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create();
$channel = PlatformChannel::factory()->create();
$route = $this->action->execute($feed->id, $channel->id, 75);
$this->assertEquals(75, $route->priority);
$this->assertTrue($route->is_active);
}
public function test_creates_inactive_route(): void
{
$language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create();
$channel = PlatformChannel::factory()->create();
$route = $this->action->execute($feed->id, $channel->id, 0, false);
$this->assertFalse($route->is_active);
}
public function test_returns_existing_route_for_duplicate_feed_channel_pair(): void
{
$language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create();
$channel = PlatformChannel::factory()->create();
$first = $this->action->execute($feed->id, $channel->id, 10);
$second = $this->action->execute($feed->id, $channel->id, 99);
$this->assertEquals($first->id, $second->id);
$this->assertEquals(10, $second->priority);
$this->assertDatabaseCount('routes', 1);
}
}

View file

@ -46,26 +46,15 @@ public function test_is_valid_returns_false_when_approval_status_is_rejected():
$this->assertFalse($article->isValid());
}
public function test_is_valid_returns_true_when_validated_and_not_rejected(): void
public function test_is_valid_returns_true_when_approval_status_is_approved(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertTrue($article->isValid());
}
public function test_is_valid_returns_false_when_not_validated(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => null,
]);
$this->assertFalse($article->isValid());
}
public function test_is_approved_returns_true_for_approved_status(): void
{
$article = Article::factory()->make(['approval_status' => 'approved']);
@ -160,12 +149,10 @@ public function test_can_be_published_requires_approval_when_approvals_enabled()
$pendingArticle = Article::factory()->make([
'approval_status' => 'pending',
'validated_at' => now(),
]);
$approvedArticle = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertFalse($pendingArticle->canBePublished());
@ -178,8 +165,7 @@ public function test_can_be_published_returns_true_when_approvals_disabled(): vo
Setting::where('key', 'enable_publishing_approvals')->delete();
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
'approval_status' => 'approved', // Only approved articles can be published
]);
$this->assertTrue($article->canBePublished());

View file

@ -62,8 +62,7 @@ public function test_publish_to_routed_channels_returns_empty_collection_when_no
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now()
'approval_status' => 'approved'
]);
$extractedData = ['title' => 'Test Title'];
@ -80,7 +79,6 @@ public function test_publish_to_routed_channels_skips_routes_without_active_acco
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(),
]);
// Create a route with a channel but no active accounts
@ -107,8 +105,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved',
'validated_at' => now()]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -154,8 +151,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved',
'validated_at' => now()]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -196,8 +192,7 @@ public function test_publish_to_routed_channels_publishes_to_multiple_routes():
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved',
'validated_at' => now()]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -252,8 +247,7 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved',
'validated_at' => now()]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']);
$platformInstance = PlatformInstance::factory()->create();
$channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
@ -311,7 +305,6 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(),
'url' => 'https://example.com/article-1',
]);
@ -363,7 +356,6 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(),
'url' => 'https://example.com/article-new-url',
'title' => 'Breaking News: Something Happened',
]);
@ -416,7 +408,6 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'validated_at' => now(),
'url' => 'https://example.com/unique-article',
]);