diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..6b2cbec --- /dev/null +++ b/.env.testing @@ -0,0 +1,28 @@ +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 diff --git a/.gitignore b/.gitignore index d801cf9..970182b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.vite /.phpunit.cache /bootstrap/ssr /node_modules diff --git a/app/Actions/CreateChannelAction.php b/app/Actions/CreateChannelAction.php new file mode 100644 index 0000000..5fdd318 --- /dev/null +++ b/app/Actions/CreateChannelAction.php @@ -0,0 +1,47 @@ +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'); + }); + } +} diff --git a/app/Actions/CreateFeedAction.php b/app/Actions/CreateFeedAction.php new file mode 100644 index 0000000..ac3ac62 --- /dev/null +++ b/app/Actions/CreateFeedAction.php @@ -0,0 +1,36 @@ +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, + ] + ); + } +} diff --git a/app/Actions/CreatePlatformAccountAction.php b/app/Actions/CreatePlatformAccountAction.php new file mode 100644 index 0000000..e84e37d --- /dev/null +++ b/app/Actions/CreatePlatformAccountAction.php @@ -0,0 +1,53 @@ +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', + ]); + }); + } +} diff --git a/app/Actions/CreateRouteAction.php b/app/Actions/CreateRouteAction.php new file mode 100644 index 0000000..8b9c9ae --- /dev/null +++ b/app/Actions/CreateRouteAction.php @@ -0,0 +1,26 @@ + $feedId, + 'platform_channel_id' => $platformChannelId, + ], + [ + 'priority' => $priority, + 'is_active' => $isActive, + ] + ); + } +} diff --git a/app/Http/Controllers/Api/V1/FeedsController.php b/app/Http/Controllers/Api/V1/FeedsController.php index f8b07f0..37f8bf1 100644 --- a/app/Http/Controllers/Api/V1/FeedsController.php +++ b/app/Http/Controllers/Api/V1/FeedsController.php @@ -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); } } diff --git a/app/Http/Controllers/Api/V1/OnboardingController.php b/app/Http/Controllers/Api/V1/OnboardingController.php index f39c83c..1972a8f 100644 --- a/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/app/Http/Controllers/Api/V1/OnboardingController.php @@ -2,12 +2,6 @@ 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; @@ -15,18 +9,10 @@ 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 */ @@ -111,233 +97,6 @@ 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 */ diff --git a/app/Http/Controllers/Api/V1/PlatformAccountsController.php b/app/Http/Controllers/Api/V1/PlatformAccountsController.php index f9b3a58..08b2ce3 100644 --- a/app/Http/Controllers/Api/V1/PlatformAccountsController.php +++ b/app/Http/Controllers/Api/V1/PlatformAccountsController.php @@ -2,10 +2,12 @@ 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; @@ -30,46 +32,30 @@ public function index(): JsonResponse /** * Store a newly created platform account */ - public function store(Request $request): JsonResponse + public function store(StorePlatformAccountRequest $request, CreatePlatformAccountAction $action): 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 = $action->execute( + $validated['instance_domain'], + $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 +96,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 +113,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 +130,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); } } diff --git a/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/app/Http/Controllers/Api/V1/PlatformChannelsController.php index 9e7fcfa..8f94aec 100644 --- a/app/Http/Controllers/Api/V1/PlatformChannelsController.php +++ b/app/Http/Controllers/Api/V1/PlatformChannelsController.php @@ -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 = 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(), - ]); + $channel = $createChannelAction->execute( + $validated['name'], + $validated['platform_instance_id'], + $validated['language_id'] ?? null, + $validated['description'] ?? null, + ); 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); } } diff --git a/app/Http/Controllers/Api/V1/RoutingController.php b/app/Http/Controllers/Api/V1/RoutingController.php index 1693cd8..185f099 100644 --- a/app/Http/Controllers/Api/V1/RoutingController.php +++ b/app/Http/Controllers/Api/V1/RoutingController.php @@ -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); } } diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php index 25ba8a7..80edb29 100644 --- a/app/Http/Controllers/Api/V1/SettingsController.php +++ b/app/Http/Controllers/Api/V1/SettingsController.php @@ -18,6 +18,7 @@ public function index(): JsonResponse $settings = [ 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), + 'article_publishing_interval' => Setting::getArticlePublishingInterval(), ]; return $this->sendResponse($settings, 'Settings retrieved successfully.'); @@ -35,6 +36,7 @@ public function update(Request $request): JsonResponse $validated = $request->validate([ 'article_processing_enabled' => 'boolean', 'publishing_approvals_enabled' => 'boolean', + 'article_publishing_interval' => 'integer|min:0', ]); if (isset($validated['article_processing_enabled'])) { @@ -45,9 +47,14 @@ public function update(Request $request): JsonResponse Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']); } + if (isset($validated['article_publishing_interval'])) { + Setting::setArticlePublishingInterval($validated['article_publishing_interval']); + } + $updatedSettings = [ 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), + 'article_publishing_interval' => Setting::getArticlePublishingInterval(), ]; return $this->sendResponse( diff --git a/app/Http/Requests/StoreFeedRequest.php b/app/Http/Requests/StoreFeedRequest.php index a49570c..ac2e533 100644 --- a/app/Http/Requests/StoreFeedRequest.php +++ b/app/Http/Requests/StoreFeedRequest.php @@ -12,13 +12,15 @@ public function authorize(): bool } /** - * @return array + * @return array */ public function rules(): array { + $providers = implode(',', array_keys(config('feed.providers', []))); + return [ 'name' => 'required|string|max:255', - 'provider' => 'required|in:vrt,belga', + 'provider' => "required|in:{$providers}", 'language_id' => 'required|exists:languages,id', 'description' => 'nullable|string', 'is_active' => 'boolean' diff --git a/app/Http/Requests/StorePlatformAccountRequest.php b/app/Http/Requests/StorePlatformAccountRequest.php new file mode 100644 index 0000000..b678482 --- /dev/null +++ b/app/Http/Requests/StorePlatformAccountRequest.php @@ -0,0 +1,36 @@ + + */ + 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 + */ + public function messages(): array + { + return [ + 'instance_domain.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)', + ]; + } +} diff --git a/app/Http/Requests/StorePlatformChannelRequest.php b/app/Http/Requests/StorePlatformChannelRequest.php new file mode 100644 index 0000000..0950699 --- /dev/null +++ b/app/Http/Requests/StorePlatformChannelRequest.php @@ -0,0 +1,26 @@ + + */ + 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', + ]; + } +} diff --git a/app/Http/Requests/StoreRouteRequest.php b/app/Http/Requests/StoreRouteRequest.php new file mode 100644 index 0000000..572f835 --- /dev/null +++ b/app/Http/Requests/StoreRouteRequest.php @@ -0,0 +1,26 @@ + + */ + 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', + ]; + } +} diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 4e5fc0d..f62c857 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -4,6 +4,8 @@ use App\Exceptions\PublishException; use App\Models\Article; +use App\Models\ArticlePublication; +use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -30,6 +32,16 @@ public function __construct() */ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void { + $interval = Setting::getArticlePublishingInterval(); + + if ($interval > 0) { + $lastPublishedAt = ArticlePublication::max('published_at'); + + if ($lastPublishedAt && now()->diffInMinutes($lastPublishedAt, absolute: true) < $interval) { + return; + } + } + // Get the oldest approved article that hasn't been published yet $article = Article::where('approval_status', 'approved') ->whereDoesntHave('articlePublication') diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 4cce6b3..6e603be 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -36,13 +36,7 @@ public function refresh(): void ArticleDiscoveryJob::dispatch(); - // Reset after 10 seconds - $this->dispatch('refresh-complete')->self(); - } - - public function refreshComplete(): void - { - $this->isRefreshing = false; + $this->dispatch('refresh-started'); } public function render() diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index 5e05ff9..c568a43 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -2,6 +2,11 @@ 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; @@ -11,10 +16,11 @@ use App\Models\PlatformInstance; use App\Models\Route; use App\Models\Setting; -use App\Services\Auth\LemmyAuthService; use App\Services\OnboardingService; -use Illuminate\Support\Facades\Crypt; +use Exception; +use InvalidArgumentException; use Livewire\Component; +use RuntimeException; class Onboarding extends Component { @@ -47,12 +53,24 @@ class Onboarding extends Component // State public array $formErrors = []; public bool $isLoading = false; + #[\Livewire\Attributes\Locked] + public ?int $previousChannelLanguageId = null; - protected LemmyAuthService $lemmyAuthService; + protected CreatePlatformAccountAction $createPlatformAccountAction; + protected CreateFeedAction $createFeedAction; + protected CreateChannelAction $createChannelAction; + protected CreateRouteAction $createRouteAction; - public function boot(LemmyAuthService $lemmyAuthService): void - { - $this->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 mount(): void @@ -104,6 +122,11 @@ public function nextStep(): void { $this->step++; $this->formErrors = []; + + // When entering feed step, inherit language from channel + if ($this->step === 4 && $this->channelLanguageId) { + $this->feedLanguageId = $this->channelLanguageId; + } } public function previousStep(): void @@ -140,42 +163,13 @@ public function createPlatformAccount(): void 'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)', ]); - $fullInstanceUrl = 'https://' . $this->instanceUrl; - try { - // Authenticate with Lemmy API first (before creating any records) - $authResponse = $this->lemmyAuthService->authenticate( - $fullInstanceUrl, + $platformAccount = $this->createPlatformAccountAction->execute( + $this->instanceUrl, $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, @@ -183,7 +177,7 @@ public function createPlatformAccount(): void ]; $this->nextStep(); - } catch (\App\Exceptions\PlatformAuthException $e) { + } catch (PlatformAuthException $e) { $message = $e->getMessage(); if (str_contains($message, 'Rate limited by')) { $this->formErrors['general'] = $message; @@ -192,9 +186,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' => $fullInstanceUrl, + 'instance_url' => 'https://' . $this->instanceUrl, 'username' => $this->username, 'error' => $e->getMessage(), 'class' => get_class($e), @@ -210,33 +204,28 @@ public function createFeed(): void $this->formErrors = []; $this->isLoading = true; + // Get available provider codes for validation + $availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(','); + $this->validate([ 'feedName' => 'required|string|max:255', - 'feedProvider' => 'required|in:belga,vrt', + 'feedProvider' => "required|in:{$availableProviders}", 'feedLanguageId' => 'required|exists:languages,id', 'feedDescription' => 'nullable|string|max:1000', ]); try { - // Map provider to URL - $url = $this->feedProvider === 'vrt' - ? 'https://www.vrt.be/vrtnws/en/' - : 'https://www.belganewsagency.eu/'; - - Feed::firstOrCreate( - ['url' => $url], - [ - 'name' => $this->feedName, - 'type' => 'website', - 'provider' => $this->feedProvider, - 'language_id' => $this->feedLanguageId, - 'description' => $this->feedDescription ?: null, - 'is_active' => true, - ] + $this->createFeedAction->execute( + $this->feedName, + $this->feedProvider, + $this->feedLanguageId, + $this->feedDescription ?: null, ); $this->nextStep(); - } catch (\Exception $e) { + } catch (InvalidArgumentException $e) { + $this->formErrors['general'] = 'Invalid provider and language combination.'; + } catch (Exception $e) { $this->formErrors['general'] = 'Failed to create feed. Please try again.'; } finally { $this->isLoading = false; @@ -255,43 +244,31 @@ public function createChannel(): void 'channelDescription' => 'nullable|string|max:1000', ]); + // If language changed, reset feed form + if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) { + $this->feedName = ''; + $this->feedProvider = ''; + $this->feedDescription = ''; + $this->routeFeedId = null; + $this->routeChannelId = null; + } + $this->previousChannelLanguageId = $this->channelLanguageId; + try { - $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(), - ]); + $channel = $this->createChannelAction->execute( + $this->channelName, + $this->platformInstanceId, + $this->channelLanguageId, + $this->channelDescription ?: null, + ); // Sync existing posts from this channel for duplicate detection SyncChannelPostsJob::dispatch($channel); $this->nextStep(); - } catch (\Exception $e) { + } catch (RuntimeException $e) { + $this->formErrors['general'] = $e->getMessage(); + } catch (Exception $e) { $this->formErrors['general'] = 'Failed to create channel. Please try again.'; } finally { $this->isLoading = false; @@ -310,18 +287,17 @@ public function createRoute(): void ]); try { - Route::create([ - 'feed_id' => $this->routeFeedId, - 'platform_channel_id' => $this->routeChannelId, - 'priority' => $this->routePriority, - 'is_active' => true, - ]); + $this->createRouteAction->execute( + $this->routeFeedId, + $this->routeChannelId, + $this->routePriority, + ); // 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; @@ -340,23 +316,97 @@ public function completeOnboarding(): void $this->redirect(route('dashboard')); } + /** + * Get language codes that have at least one active provider. + */ + public function getAvailableLanguageCodes(): array + { + $providers = config('feed.providers', []); + $languageCodes = []; + + foreach ($providers as $provider) { + if (!($provider['is_active'] ?? false)) { + continue; + } + foreach (array_keys($provider['languages'] ?? []) as $code) { + $languageCodes[$code] = true; + } + } + + return array_keys($languageCodes); + } + + /** + * Get providers available for the current channel language. + */ + public function getProvidersForLanguage(): array + { + if (!$this->channelLanguageId) { + return []; + } + + $language = Language::find($this->channelLanguageId); + if (!$language) { + return []; + } + + $langCode = $language->short_code; + $providers = config('feed.providers', []); + $available = []; + + foreach ($providers as $key => $provider) { + if (!($provider['is_active'] ?? false)) { + continue; + } + if (isset($provider['languages'][$langCode])) { + $available[] = [ + 'code' => $provider['code'], + 'name' => $provider['name'], + 'description' => $provider['description'] ?? '', + ]; + } + } + + return $available; + } + + /** + * Get the current channel language model. + */ + public function getChannelLanguage(): ?Language + { + if (!$this->channelLanguageId) { + return null; + } + return Language::find($this->channelLanguageId); + } + public function render() { - $languages = Language::where('is_active', true)->orderBy('name')->get(); - $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); + // For channel step: only show languages that have providers + $availableCodes = $this->getAvailableLanguageCodes(); + $wizardLanguages = Language::where('is_active', true) + ->whereIn('short_code', $availableCodes) + ->orderBy('name') + ->get(); - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active'] ?? false) - ->values(); + $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); + $feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get(); + $channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get(); + + // For feed step: only show providers for the channel's language + $feedProviders = collect($this->getProvidersForLanguage()); + + // Get channel language for display + $channelLanguage = $this->getChannelLanguage(); return view('livewire.onboarding', [ - 'languages' => $languages, + 'wizardLanguages' => $wizardLanguages, 'platformInstances' => $platformInstances, 'feeds' => $feeds, 'channels' => $channels, 'feedProviders' => $feedProviders, + 'channelLanguage' => $channelLanguage, ])->layout('layouts.onboarding'); } } diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index f63de38..9502fbd 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -9,6 +9,7 @@ class Settings extends Component { public bool $articleProcessingEnabled = true; public bool $publishingApprovalsEnabled = false; + public int $articlePublishingInterval = 5; public ?string $successMessage = null; public ?string $errorMessage = null; @@ -17,6 +18,7 @@ public function mount(): void { $this->articleProcessingEnabled = Setting::isArticleProcessingEnabled(); $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); + $this->articlePublishingInterval = Setting::getArticlePublishingInterval(); } public function toggleArticleProcessing(): void @@ -33,6 +35,16 @@ public function togglePublishingApprovals(): void $this->showSuccess(); } + public function updateArticlePublishingInterval(): void + { + $this->validate([ + 'articlePublishingInterval' => 'required|integer|min:0', + ]); + + Setting::setArticlePublishingInterval($this->articlePublishingInterval); + $this->showSuccess(); + } + protected function showSuccess(): void { $this->successMessage = 'Settings updated successfully!'; diff --git a/app/Models/Article.php b/app/Models/Article.php index f5cd55b..d508e79 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -61,7 +61,6 @@ 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(); } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 9f5a132..09b0381 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -59,4 +59,14 @@ public static function setPublishingApprovalsEnabled(bool $enabled): void { static::setBool('enable_publishing_approvals', $enabled); } + + public static function getArticlePublishingInterval(): int + { + return (int) static::get('article_publishing_interval', 5); + } + + public static function setArticlePublishingInterval(int $minutes): void + { + static::set('article_publishing_interval', (string) $minutes); + } } diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index 44124c4..6669a7c 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -41,9 +41,45 @@ public function getArticlesFromFeed(Feed $feed): Collection */ private function getArticlesFromRssFeed(Feed $feed): Collection { - // TODO: Implement RSS feed parsing - // For now, return empty collection - return collect(); + try { + $xml = HttpFetcher::fetchHtml($feed->url); + + $previousUseErrors = libxml_use_internal_errors(true); + + try { + $rss = simplexml_load_string($xml); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previousUseErrors); + } + + if ($rss === false || !isset($rss->channel->item)) { + $this->logSaver->warning("Failed to parse RSS feed XML", null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + ]); + + return collect(); + } + + $articles = collect(); + foreach ($rss->channel->item as $item) { + $link = (string) $item->link; + if ($link !== '') { + $articles->push($this->saveArticle($link, $feed->id)); + } + } + + return $articles; + } catch (Exception $e) { + $this->logSaver->error("Failed to fetch articles from RSS feed", null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + 'error' => $e->getMessage(), + ]); + + return collect(); + } } /** diff --git a/app/Services/Factories/ArticleParserFactory.php b/app/Services/Factories/ArticleParserFactory.php index 765994a..cfef7b3 100644 --- a/app/Services/Factories/ArticleParserFactory.php +++ b/app/Services/Factories/ArticleParserFactory.php @@ -6,6 +6,7 @@ use App\Models\Feed; use App\Services\Parsers\VrtArticleParser; use App\Services\Parsers\BelgaArticleParser; +use App\Services\Parsers\GuardianArticleParser; use Exception; class ArticleParserFactory @@ -16,6 +17,7 @@ class ArticleParserFactory private static array $parsers = [ VrtArticleParser::class, BelgaArticleParser::class, + GuardianArticleParser::class, ]; /** diff --git a/app/Services/Factories/HomepageParserFactory.php b/app/Services/Factories/HomepageParserFactory.php index 7215961..547836e 100644 --- a/app/Services/Factories/HomepageParserFactory.php +++ b/app/Services/Factories/HomepageParserFactory.php @@ -4,36 +4,9 @@ use App\Contracts\HomepageParserInterface; use App\Models\Feed; -use App\Services\Parsers\VrtHomepageParserAdapter; -use App\Services\Parsers\BelgaHomepageParserAdapter; -use Exception; class HomepageParserFactory { - /** - * @var array> - */ - private static array $parsers = [ - VrtHomepageParserAdapter::class, - BelgaHomepageParserAdapter::class, - ]; - - /** - * @throws Exception - */ - public static function getParser(string $url): HomepageParserInterface - { - foreach (self::$parsers as $parserClass) { - $parser = new $parserClass(); - - if ($parser->canParse($url)) { - return $parser; - } - } - - throw new Exception("No homepage parser found for URL: {$url}"); - } - public static function getParserForFeed(Feed $feed): ?HomepageParserInterface { if (!$feed->provider) { @@ -50,6 +23,8 @@ public static function getParserForFeed(Feed $feed): ?HomepageParserInterface return null; } - return new $parserClass(); + $language = $feed->language?->short_code ?? 'en'; + + return new $parserClass($language); } } diff --git a/app/Services/Parsers/BelgaHomepageParserAdapter.php b/app/Services/Parsers/BelgaHomepageParserAdapter.php index 4613e76..f3ba438 100644 --- a/app/Services/Parsers/BelgaHomepageParserAdapter.php +++ b/app/Services/Parsers/BelgaHomepageParserAdapter.php @@ -6,6 +6,10 @@ class BelgaHomepageParserAdapter implements HomepageParserInterface { + public function __construct( + private string $language = 'en', + ) {} + public function canParse(string $url): bool { return str_contains($url, 'belganewsagency.eu'); diff --git a/app/Services/Parsers/GuardianArticlePageParser.php b/app/Services/Parsers/GuardianArticlePageParser.php new file mode 100644 index 0000000..7f94570 --- /dev/null +++ b/app/Services/Parsers/GuardianArticlePageParser.php @@ -0,0 +1,110 @@ +]*>([^<]+)<\/h1>/i', $html, $matches)) { + return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8'); + } + + // Try title tag + if (preg_match('/([^<]+)<\/title>/i', $html, $matches)) { + return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8'); + } + + return null; + } + + public static function extractDescription(string $html): ?string + { + // Try meta description first + if (preg_match('/<meta property="og:description" content="([^"]+)"/i', $html, $matches)) { + return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8'); + } + + // Try first paragraph + if (preg_match('/<p[^>]*>([^<]+(?:<[^\/](?!p)[^>]*>[^<]*<\/[^>]*>[^<]*)*)<\/p>/i', $html, $matches)) { + return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8'); + } + + return null; + } + + public static function extractFullArticle(string $html): ?string + { + // Remove scripts, styles, and other non-content elements + $cleanHtml = preg_replace('/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi', '', $html); + $cleanHtml = preg_replace('/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/mi', '', $cleanHtml); + + // Try Guardian-specific article body container (greedy to avoid stopping at nested divs) + if (preg_match('/<div[^>]*class="[^"]*article-body-commercial-selector[^"]*"[^>]*>(.*)<\/div>/is', $cleanHtml, $sectionMatches)) { + $sectionHtml = $sectionMatches[1]; + preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches); + + if (!empty($matches[1])) { + return self::joinParagraphs($matches[1]); + } + } + + // Fallback: extract all paragraph content + preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches); + if (!empty($matches[1])) { + return self::joinParagraphs($matches[1]); + } + + return null; + } + + public static function extractThumbnail(string $html): ?string + { + // Try OpenGraph image first + if (preg_match('/<meta property="og:image" content="([^"]+)"/i', $html, $matches)) { + return $matches[1]; + } + + // Try first image in content + if (preg_match('/<img[^>]+src="([^"]+)"/i', $html, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * @return array<string, string|null> + */ + public static function extractData(string $html): array + { + return [ + 'title' => self::extractTitle($html), + 'description' => self::extractDescription($html), + 'full_article' => self::extractFullArticle($html), + 'thumbnail' => self::extractThumbnail($html), + ]; + } + + /** + * @param array<int, string> $paragraphs + */ + private static function joinParagraphs(array $paragraphs): ?string + { + $paragraphs = array_map(function ($paragraph) { + return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); + }, $paragraphs); + + $fullText = implode("\n\n", array_filter($paragraphs, function ($p) { + return trim($p) !== ''; + })); + + return $fullText ?: null; + } +} \ No newline at end of file diff --git a/app/Services/Parsers/GuardianArticleParser.php b/app/Services/Parsers/GuardianArticleParser.php new file mode 100644 index 0000000..a363199 --- /dev/null +++ b/app/Services/Parsers/GuardianArticleParser.php @@ -0,0 +1,23 @@ +<?php + +namespace App\Services\Parsers; + +use App\Contracts\ArticleParserInterface; + +class GuardianArticleParser implements ArticleParserInterface +{ + public function canParse(string $url): bool + { + return str_contains($url, 'theguardian.com'); + } + + public function extractData(string $html): array + { + return GuardianArticlePageParser::extractData($html); + } + + public function getSourceName(): string + { + return 'The Guardian'; + } +} \ No newline at end of file diff --git a/app/Services/Parsers/VrtHomepageParser.php b/app/Services/Parsers/VrtHomepageParser.php index 8ca4d96..8aa5b17 100644 --- a/app/Services/Parsers/VrtHomepageParser.php +++ b/app/Services/Parsers/VrtHomepageParser.php @@ -7,10 +7,10 @@ class VrtHomepageParser /** * @return array<int, string> */ - public static function extractArticleUrls(string $html): array + public static function extractArticleUrls(string $html, string $language = 'en'): array { - // Extract article links using regex - preg_match_all('/href="(\/vrtnws\/en\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches); + $escapedLanguage = preg_quote($language, '/'); + preg_match_all('/href="(?:https:\/\/www\.vrt\.be)?(\/vrtnws\/' . $escapedLanguage . '\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches); $urls = collect($matches[1]) ->unique() diff --git a/app/Services/Parsers/VrtHomepageParserAdapter.php b/app/Services/Parsers/VrtHomepageParserAdapter.php index 3d1db3a..557bb44 100644 --- a/app/Services/Parsers/VrtHomepageParserAdapter.php +++ b/app/Services/Parsers/VrtHomepageParserAdapter.php @@ -6,6 +6,10 @@ class VrtHomepageParserAdapter implements HomepageParserInterface { + public function __construct( + private string $language = 'en', + ) {} + public function canParse(string $url): bool { return str_contains($url, 'vrt.be'); @@ -13,12 +17,12 @@ public function canParse(string $url): bool public function extractArticleUrls(string $html): array { - return VrtHomepageParser::extractArticleUrls($html); + return VrtHomepageParser::extractArticleUrls($html, $this->language); } public function getHomepageUrl(): string { - return 'https://www.vrt.be/vrtnws/en/'; + return "https://www.vrt.be/vrtnws/{$this->language}/"; } public function getSourceName(): string diff --git a/config/feed.php b/config/feed.php index 2c4288d..4ddb9ea 100644 --- a/config/feed.php +++ b/config/feed.php @@ -19,6 +19,10 @@ 'description' => 'Belgian public broadcaster news', 'type' => 'website', 'is_active' => true, + 'languages' => [ + 'en' => ['url' => 'https://www.vrt.be/vrtnws/en/'], + 'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'], + ], 'parsers' => [ 'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, 'article' => \App\Services\Parsers\VrtArticleParser::class, @@ -27,16 +31,33 @@ ], 'belga' => [ 'code' => 'belga', - 'name' => 'Belga News Agency', + 'name' => 'Belga News Agency', 'description' => 'Belgian national news agency', - 'type' => 'rss', + 'type' => 'website', 'is_active' => true, + 'languages' => [ + 'en' => ['url' => 'https://www.belganewsagency.eu/'], + ], 'parsers' => [ 'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class, 'article' => \App\Services\Parsers\BelgaArticleParser::class, 'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class, ], ], + 'guardian' => [ + 'code' => 'guardian', + 'name' => 'The Guardian', + 'description' => 'British daily newspaper', + 'type' => 'rss', + 'is_active' => true, + 'languages' => [ + 'en' => ['url' => 'https://www.theguardian.com/international/rss'], + ], + 'parsers' => [ + 'article' => \App\Services\Parsers\GuardianArticleParser::class, + 'article_page' => \App\Services\Parsers\GuardianArticlePageParser::class, + ], + ], ], /* diff --git a/config/languages.php b/config/languages.php index d0f8c7f..6b63ce0 100644 --- a/config/languages.php +++ b/config/languages.php @@ -12,30 +12,43 @@ */ 'supported' => [ - 'en' => [ - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - 'is_active' => true, - ], - 'nl' => [ - 'short_code' => 'nl', - 'name' => 'Dutch', - 'native_name' => 'Nederlands', - 'is_active' => true, - ], - 'fr' => [ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => true, - ], - 'de' => [ - 'short_code' => 'de', - 'name' => 'German', - 'native_name' => 'Deutsch', - 'is_active' => true, - ], + 'en' => ['short_code' => 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true], + 'nl' => ['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true], + 'fr' => ['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true], + 'de' => ['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true], + 'es' => ['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true], + 'it' => ['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true], + 'pt' => ['short_code' => 'pt', 'name' => 'Portuguese', 'native_name' => 'Português', 'is_active' => true], + 'pl' => ['short_code' => 'pl', 'name' => 'Polish', 'native_name' => 'Polski', 'is_active' => true], + 'ru' => ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский', 'is_active' => true], + 'uk' => ['short_code' => 'uk', 'name' => 'Ukrainian', 'native_name' => 'Українська', 'is_active' => true], + 'cs' => ['short_code' => 'cs', 'name' => 'Czech', 'native_name' => 'Čeština', 'is_active' => true], + 'sk' => ['short_code' => 'sk', 'name' => 'Slovak', 'native_name' => 'Slovenčina', 'is_active' => true], + 'hu' => ['short_code' => 'hu', 'name' => 'Hungarian', 'native_name' => 'Magyar', 'is_active' => true], + 'ro' => ['short_code' => 'ro', 'name' => 'Romanian', 'native_name' => 'Română', 'is_active' => true], + 'bg' => ['short_code' => 'bg', 'name' => 'Bulgarian', 'native_name' => 'Български', 'is_active' => true], + 'hr' => ['short_code' => 'hr', 'name' => 'Croatian', 'native_name' => 'Hrvatski', 'is_active' => true], + 'sl' => ['short_code' => 'sl', 'name' => 'Slovenian', 'native_name' => 'Slovenščina', 'is_active' => true], + 'sr' => ['short_code' => 'sr', 'name' => 'Serbian', 'native_name' => 'Српски', 'is_active' => true], + 'el' => ['short_code' => 'el', 'name' => 'Greek', 'native_name' => 'Ελληνικά', 'is_active' => true], + 'tr' => ['short_code' => 'tr', 'name' => 'Turkish', 'native_name' => 'Türkçe', 'is_active' => true], + 'da' => ['short_code' => 'da', 'name' => 'Danish', 'native_name' => 'Dansk', 'is_active' => true], + 'sv' => ['short_code' => 'sv', 'name' => 'Swedish', 'native_name' => 'Svenska', 'is_active' => true], + 'no' => ['short_code' => 'no', 'name' => 'Norwegian', 'native_name' => 'Norsk', 'is_active' => true], + 'fi' => ['short_code' => 'fi', 'name' => 'Finnish', 'native_name' => 'Suomi', 'is_active' => true], + 'et' => ['short_code' => 'et', 'name' => 'Estonian', 'native_name' => 'Eesti', 'is_active' => true], + 'lv' => ['short_code' => 'lv', 'name' => 'Latvian', 'native_name' => 'Latviešu', 'is_active' => true], + 'lt' => ['short_code' => 'lt', 'name' => 'Lithuanian', 'native_name' => 'Lietuvių', 'is_active' => true], + 'ja' => ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語', 'is_active' => true], + 'zh' => ['short_code' => 'zh', 'name' => 'Chinese', 'native_name' => '中文', 'is_active' => true], + 'ko' => ['short_code' => 'ko', 'name' => 'Korean', 'native_name' => '한국어', 'is_active' => true], + 'ar' => ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية', 'is_active' => true], + 'he' => ['short_code' => 'he', 'name' => 'Hebrew', 'native_name' => 'עברית', 'is_active' => true], + 'hi' => ['short_code' => 'hi', 'name' => 'Hindi', 'native_name' => 'हिन्दी', 'is_active' => true], + 'th' => ['short_code' => 'th', 'name' => 'Thai', 'native_name' => 'ไทย', 'is_active' => true], + 'vi' => ['short_code' => 'vi', 'name' => 'Vietnamese', 'native_name' => 'Tiếng Việt', 'is_active' => true], + 'id' => ['short_code' => 'id', 'name' => 'Indonesian', 'native_name' => 'Bahasa Indonesia', 'is_active' => true], + 'ms' => ['short_code' => 'ms', 'name' => 'Malay', 'native_name' => 'Bahasa Melayu', 'is_active' => true], ], /* diff --git a/phpunit.xml b/phpunit.xml index dda51b4..17f1185 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,17 +18,17 @@ </include> </source> <php> - <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"/> + <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"/> </php> </phpunit> diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index b972dd5..3acec05 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -19,6 +19,7 @@ wire:click="refresh" wire:loading.attr="disabled" @disabled($isRefreshing) + x-on:refresh-started.window="setTimeout(() => window.location.reload(), 10000)" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 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" > <svg class="h-4 w-4 mr-2 {{ $isRefreshing ? 'animate-spin' : '' }}" wire:loading.class="animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> diff --git a/resources/views/livewire/onboarding.blade.php b/resources/views/livewire/onboarding.blade.php index 24b6419..f08f2d6 100644 --- a/resources/views/livewire/onboarding.blade.php +++ b/resources/views/livewire/onboarding.blade.php @@ -241,7 +241,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc required > <option value="">Select language</option> - @foreach ($languages as $language) + @foreach ($wizardLanguages as $language) <option value="{{ $language->id }}">{{ $language->name }}</option> @endforeach </select> @@ -316,6 +316,25 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc @error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror </div> + <div> + <label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2 flex items-center"> + Language + <span class="ml-2 text-gray-400 cursor-help" title="Language matches your channel. Additional languages can be configured from the dashboard after setup."> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> + </svg> + </span> + </label> + <select + id="feedLanguageId" + disabled + class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100 text-gray-600 cursor-not-allowed" + > + <option>{{ $channelLanguage?->name ?? 'Unknown' }}</option> + </select> + <p class="text-sm text-gray-500 mt-1">Inherited from your channel</p> + </div> + <div> <label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2"> News Provider @@ -331,27 +350,12 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc <option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option> @endforeach </select> + @if ($feedProviders->isEmpty()) + <p class="text-sm text-amber-600 mt-1">No providers available for this language.</p> + @endif @error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror </div> - <div> - <label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2"> - Language - </label> - <select - id="feedLanguageId" - wire:model="feedLanguageId" - class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - required - > - <option value="">Select language</option> - @foreach ($languages as $language) - <option value="{{ $language->id }}">{{ $language->name }}</option> - @endforeach - </select> - @error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror - </div> - <div> <label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2"> Description (Optional) @@ -372,7 +376,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc </button> <button type="submit" - @disabled($isLoading) + @disabled($isLoading || $feedProviders->isEmpty()) class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50" > {{ $isLoading ? 'Creating...' : 'Continue' }} @@ -418,7 +422,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc > <option value="">Select a feed</option> @foreach ($feeds as $feed) - <option value="{{ $feed->id }}">{{ $feed->name }}</option> + <option value="{{ $feed->id }}">{{ $feed->name }} ({{ $feed->language?->short_code ?? '?' }})</option> @endforeach </select> @error('routeFeedId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror @@ -436,7 +440,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc > <option value="">Select a channel</option> @foreach ($channels as $channel) - <option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }}</option> + <option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }} ({{ $channel->language?->short_code ?? '?' }})</option> @endforeach </select> @if ($channels->isEmpty()) diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 96b050c..bd7a258 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -47,6 +47,36 @@ class="flex-shrink-0" </button> </div> + <div class="flex items-center justify-between"> + <div> + <h3 class="text-sm font-medium text-gray-900"> + Publishing Interval (minutes) + </h3> + <p class="text-sm text-gray-500"> + Minimum time between publishing articles. Set to 0 for no delay. + </p> + </div> + <div class="flex items-center space-x-2"> + <input + type="number" + wire:model="articlePublishingInterval" + min="0" + max="1440" + step="1" + class="w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> + <button + wire:click="updateArticlePublishingInterval" + class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + Save + </button> + </div> + </div> + @error('articlePublishingInterval') + <p class="text-sm text-red-600">{{ $message }}</p> + @enderror + <div class="flex items-center justify-between"> <div> <h3 class="text-sm font-medium text-gray-900"> diff --git a/routes/api.php b/routes/api.php index b54f639..3142d04 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,10 +38,6 @@ // 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'); diff --git a/shell.nix b/shell.nix index d674e21..c4c0097 100644 --- a/shell.nix +++ b/shell.nix @@ -65,6 +65,16 @@ pkgs.mkShell { podman-compose -f $COMPOSE_FILE restart "$@" } + dev-rebuild() { + echo "Rebuilding services (down -v + up)..." + podman-compose -f $COMPOSE_FILE down -v + PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@" + echo "" + podman-compose -f $COMPOSE_FILE ps + echo "" + echo "App available at: http://localhost:8000" + } + dev-logs() { podman-compose -f $COMPOSE_FILE logs -f app "$@" } @@ -123,6 +133,7 @@ pkgs.mkShell { echo "Commands:" echo " dev-up [services] Start all or specific services" echo " dev-down [-v] Stop services (-v removes volumes)" + echo " dev-rebuild Fresh start (down -v + up)" echo " dev-restart Restart services" echo " dev-logs Tail app logs" echo " dev-logs-db Tail database logs" diff --git a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php index 5b53248..143a8f5 100644 --- a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php @@ -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()->create(); - + $language = Language::factory()->english()->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()->create(); - + $language = Language::factory()->english()->create(); + $feedData = [ 'name' => 'Belga Test Feed', 'provider' => 'belga', @@ -111,10 +111,42 @@ public function test_store_creates_belga_feed_successfully(): void ]); } + public function test_store_creates_guardian_feed_successfully(): void + { + $language = Language::factory()->english()->create(); + + $feedData = [ + 'name' => 'Guardian Test Feed', + 'provider' => 'guardian', + 'language_id' => $language->id, + 'is_active' => true, + ]; + + $response = $this->postJson('/api/v1/feeds', $feedData); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'message' => 'Feed created successfully!', + 'data' => [ + 'name' => 'Guardian Test Feed', + 'url' => 'https://www.theguardian.com/international/rss', + 'type' => 'rss', + 'is_active' => true, + ] + ]); + + $this->assertDatabaseHas('feeds', [ + 'name' => 'Guardian Test Feed', + 'url' => 'https://www.theguardian.com/international/rss', + 'type' => 'rss', + ]); + } + public function test_store_sets_default_active_status(): void { - $language = Language::factory()->create(); - + $language = Language::factory()->english()->create(); + $feedData = [ 'name' => 'Test Feed', 'provider' => 'vrt', diff --git a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php index dfca1c1..67f4a66 100644 --- a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php @@ -9,7 +9,6 @@ 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; @@ -189,180 +188,6 @@ 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'); @@ -443,27 +268,6 @@ 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 diff --git a/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php index ae4573a..511f826 100644 --- a/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php @@ -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_domain' => 'lemmy.example.com', 'username' => 'testuser', 'password' => 'testpass123', - 'settings' => ['key' => 'value'] ]; $response = $this->postJson('/api/v1/platform-accounts', $data); @@ -83,7 +94,7 @@ public function test_store_validates_required_fields(): void $response = $this->postJson('/api/v1/platform-accounts', []); $response->assertStatus(422) - ->assertJsonValidationErrors(['platform', 'instance_url', 'username', 'password']); + ->assertJsonValidationErrors(['platform', 'instance_domain', 'username', 'password']); } public function test_show_returns_platform_account_successfully(): void diff --git a/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php index 63765ca..b6e883e 100644 --- a/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php @@ -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' ]; diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php index 95e8ead..e41b8c3 100644 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php @@ -20,6 +20,7 @@ public function test_index_returns_current_settings(): void 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ], 'message' ]) @@ -90,12 +91,58 @@ public function test_update_accepts_partial_updates(): void ] ]); - // Should still have structure for both settings + // Should still have structure for all settings $response->assertJsonStructure([ 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ] ]); } + + public function test_index_returns_article_publishing_interval(): void + { + $response = $this->getJson('/api/v1/settings'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'article_publishing_interval', + ], + ]) + ->assertJsonPath('data.article_publishing_interval', 5); + } + + public function test_update_accepts_valid_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 15, + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.article_publishing_interval', 15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + } + + public function test_update_rejects_negative_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => -5, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } + + public function test_update_rejects_non_integer_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 'abc', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } } diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index 58a732c..c73ce60 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -18,6 +18,7 @@ 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; @@ -175,7 +176,10 @@ public function test_exception_logged_event_is_dispatched(): void public function test_validate_article_listener_processes_new_article(): void { - Event::fake([ArticleReadyToPublish::class]); + Event::fake([ArticleApproved::class]); + + // Disable approvals so listener auto-approves valid articles + Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); $article = Article::factory()->create([ @@ -200,8 +204,8 @@ public function test_validate_article_listener_processes_new_article(): void $listener->handle($event); $article->refresh(); - $this->assertNotEquals('pending', $article->approval_status); - $this->assertContains($article->approval_status, ['approved', 'rejected']); + $this->assertEquals('approved', $article->approval_status); + Event::assertDispatched(ArticleApproved::class); } // Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist diff --git a/tests/Unit/Actions/CreateChannelActionTest.php b/tests/Unit/Actions/CreateChannelActionTest.php new file mode 100644 index 0000000..f100a95 --- /dev/null +++ b/tests/Unit/Actions/CreateChannelActionTest.php @@ -0,0 +1,86 @@ +<?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); + } +} diff --git a/tests/Unit/Actions/CreateFeedActionTest.php b/tests/Unit/Actions/CreateFeedActionTest.php new file mode 100644 index 0000000..5c4eec6 --- /dev/null +++ b/tests/Unit/Actions/CreateFeedActionTest.php @@ -0,0 +1,91 @@ +<?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('website', $feed->type); + $this->assertEquals('belga', $feed->provider); + $this->assertNull($feed->description); + } + + public function test_creates_guardian_feed_with_correct_url(): void + { + $language = Language::factory()->create(['short_code' => 'en', 'is_active' => true]); + + $feed = $this->action->execute('Guardian News', 'guardian', $language->id); + + $this->assertEquals('https://www.theguardian.com/international/rss', $feed->url); + $this->assertEquals('rss', $feed->type); + $this->assertEquals('guardian', $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); + } +} diff --git a/tests/Unit/Actions/CreatePlatformAccountActionTest.php b/tests/Unit/Actions/CreatePlatformAccountActionTest.php new file mode 100644 index 0000000..5d01af3 --- /dev/null +++ b/tests/Unit/Actions/CreatePlatformAccountActionTest.php @@ -0,0 +1,104 @@ +<?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); + } + } +} diff --git a/tests/Unit/Actions/CreateRouteActionTest.php b/tests/Unit/Actions/CreateRouteActionTest.php new file mode 100644 index 0000000..0012237 --- /dev/null +++ b/tests/Unit/Actions/CreateRouteActionTest.php @@ -0,0 +1,76 @@ +<?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); + } +} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 9e8f6cf..af12d93 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -7,6 +7,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; +use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -288,6 +289,153 @@ public function test_handle_fetches_article_data_before_publishing(): void $this->assertTrue(true); } + public function test_handle_skips_publishing_when_last_publication_within_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 3 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(3), + ]); + Setting::setArticlePublishingInterval(10); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + // Neither should be called + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_beyond_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 15 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(15), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_interval_is_zero(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was just now, but interval is 0 + ArticlePublication::factory()->create([ + 'published_at' => now(), + ]); + Setting::setArticlePublishingInterval(0); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_exactly_at_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(10), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_no_previous_publications_exist(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Models/ArticleTest.php b/tests/Unit/Models/ArticleTest.php index e8ba894..93d5617 100644 --- a/tests/Unit/Models/ArticleTest.php +++ b/tests/Unit/Models/ArticleTest.php @@ -46,15 +46,26 @@ public function test_is_valid_returns_false_when_approval_status_is_rejected(): $this->assertFalse($article->isValid()); } - public function test_is_valid_returns_true_when_approval_status_is_approved(): void + public function test_is_valid_returns_true_when_validated_and_not_rejected(): 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']); @@ -149,10 +160,12 @@ 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()); @@ -165,7 +178,8 @@ 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', // Only approved articles can be published + 'approval_status' => 'approved', + 'validated_at' => now(), ]); $this->assertTrue($article->canBePublished()); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php new file mode 100644 index 0000000..1165b81 --- /dev/null +++ b/tests/Unit/Models/SettingTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Tests\Unit\Models; + +use App\Models\Setting; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\TestCase; + +class SettingTest extends TestCase +{ + use RefreshDatabase; + + public function test_get_article_publishing_interval_returns_default_when_not_set(): void + { + $this->assertSame(5, Setting::getArticlePublishingInterval()); + } + + public function test_get_article_publishing_interval_returns_stored_value(): void + { + Setting::set('article_publishing_interval', '10'); + + $this->assertSame(10, Setting::getArticlePublishingInterval()); + } + + public function test_set_article_publishing_interval_persists_value(): void + { + Setting::setArticlePublishingInterval(15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + $this->assertDatabaseHas('settings', [ + 'key' => 'article_publishing_interval', + 'value' => '15', + ]); + } + + public function test_set_article_publishing_interval_zero(): void + { + Setting::setArticlePublishingInterval(0); + + $this->assertSame(0, Setting::getArticlePublishingInterval()); + } +} diff --git a/tests/Unit/Services/ArticleFetcherRssTest.php b/tests/Unit/Services/ArticleFetcherRssTest.php new file mode 100644 index 0000000..0479d6a --- /dev/null +++ b/tests/Unit/Services/ArticleFetcherRssTest.php @@ -0,0 +1,164 @@ +<?php + +namespace Tests\Unit\Services; + +use App\Models\Article; +use App\Models\Feed; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use Mockery; +use Tests\TestCase; +use Tests\Traits\CreatesArticleFetcher; + +class ArticleFetcherRssTest extends TestCase +{ + use RefreshDatabase, CreatesArticleFetcher; + + private string $sampleRss; + + protected function setUp(): void + { + parent::setUp(); + + $this->sampleRss = <<<'XML' +<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0"> + <channel> + <title>The Guardian - International + https://www.theguardian.com/international + + First Article Title + https://www.theguardian.com/world/2026/mar/08/first-article + First article description + Sun, 08 Mar 2026 12:00:00 GMT + + + Second Article Title + https://www.theguardian.com/world/2026/mar/08/second-article + Second article description + Sun, 08 Mar 2026 11:00:00 GMT + + + +XML; + } + + public function test_get_articles_from_rss_feed_returns_collection(): void + { + Http::fake(['*' => Http::response($this->sampleRss, 200)]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + } + + public function test_get_articles_from_rss_feed_creates_articles(): void + { + Http::fake(['*' => Http::response($this->sampleRss, 200)]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertCount(2, $result); + $this->assertDatabaseHas('articles', [ + 'url' => 'https://www.theguardian.com/world/2026/mar/08/first-article', + 'feed_id' => $feed->id, + ]); + $this->assertDatabaseHas('articles', [ + 'url' => 'https://www.theguardian.com/world/2026/mar/08/second-article', + 'feed_id' => $feed->id, + ]); + } + + public function test_get_articles_from_rss_feed_does_not_duplicate_existing(): void + { + Http::fake(['*' => Http::response($this->sampleRss, 200)]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + Article::factory()->create([ + 'url' => 'https://www.theguardian.com/world/2026/mar/08/first-article', + 'feed_id' => $feed->id, + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertCount(2, $result); + $this->assertEquals(1, Article::where('url', 'https://www.theguardian.com/world/2026/mar/08/first-article')->count()); + } + + public function test_get_articles_from_rss_feed_handles_invalid_xml(): void + { + Http::fake(['*' => Http::response('this is not xml', 200)]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertEmpty($result); + } + + public function test_get_articles_from_rss_feed_handles_empty_channel(): void + { + Http::fake([ + '*' => Http::response('Empty', 200), + ]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertEmpty($result); + } + + public function test_get_articles_from_rss_feed_handles_http_failure(): void + { + Http::fake(['*' => Http::response('Server Error', 500)]); + + $feed = Feed::factory()->create([ + 'type' => 'rss', + 'provider' => 'guardian', + 'url' => 'https://www.theguardian.com/international/rss', + ]); + + $fetcher = $this->createArticleFetcher(); + $result = $fetcher->getArticlesFromFeed($feed); + + $this->assertEmpty($result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Unit/Services/Factories/ArticleParserFactoryTest.php b/tests/Unit/Services/Factories/ArticleParserFactoryTest.php index a0a9808..ef5a24a 100644 --- a/tests/Unit/Services/Factories/ArticleParserFactoryTest.php +++ b/tests/Unit/Services/Factories/ArticleParserFactoryTest.php @@ -46,9 +46,10 @@ public function test_get_supported_sources_returns_array_of_source_names(): void $sources = ArticleParserFactory::getSupportedSources(); $this->assertIsArray($sources); - $this->assertCount(2, $sources); + $this->assertCount(3, $sources); $this->assertContains('VRT News', $sources); $this->assertContains('Belga News Agency', $sources); + $this->assertContains('The Guardian', $sources); } public function test_get_supported_sources_returns_sources_in_correct_order(): void @@ -88,7 +89,7 @@ public function getSourceName(): string // Verify it's now included in supported sources $sources = ArticleParserFactory::getSupportedSources(); $this->assertContains('TestParser', $sources); - $this->assertCount(3, $sources); // Original 2 + 1 new + $this->assertCount(4, $sources); // Original 3 + 1 new // Verify it can be used to parse URLs $testUrl = 'https://test-parser.com/article'; diff --git a/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php b/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php new file mode 100644 index 0000000..a0126d0 --- /dev/null +++ b/tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php @@ -0,0 +1,285 @@ +'; + + $title = GuardianArticlePageParser::extractTitle($html); + + $this->assertEquals('Guardian Article Title', $title); + } + + public function test_extract_title_from_h1_tag(): void + { + $html = '

H1 Title Test

'; + + $title = GuardianArticlePageParser::extractTitle($html); + + $this->assertEquals('H1 Title Test', $title); + } + + public function test_extract_title_from_title_tag(): void + { + $html = 'Page Title Test'; + + $title = GuardianArticlePageParser::extractTitle($html); + + $this->assertEquals('Page Title Test', $title); + } + + public function test_extract_title_with_html_entities(): void + { + $html = ''; + + $title = GuardianArticlePageParser::extractTitle($html); + + $this->assertEquals('Test & Article "Title"', $title); + } + + public function test_extract_title_returns_null_when_not_found(): void + { + $html = '

No title here

'; + + $title = GuardianArticlePageParser::extractTitle($html); + + $this->assertNull($title); + } + + public function test_extract_description_from_og_meta_tag(): void + { + $html = ''; + + $description = GuardianArticlePageParser::extractDescription($html); + + $this->assertEquals('Guardian article description', $description); + } + + public function test_extract_description_from_paragraph(): void + { + $html = '

This is the first paragraph description.

'; + + $description = GuardianArticlePageParser::extractDescription($html); + + $this->assertEquals('This is the first paragraph description.', $description); + } + + public function test_extract_description_returns_null_when_not_found(): void + { + $html = '
No description here
'; + + $description = GuardianArticlePageParser::extractDescription($html); + + $this->assertNull($description); + } + + public function test_extract_full_article_from_guardian_article_body(): void + { + $html = ' + + +
+

First paragraph of the article.

+

Second paragraph of the article.

+
+ + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $expected = "First paragraph of the article.\n\nSecond paragraph of the article."; + $this->assertEquals($expected, $fullArticle); + } + + public function test_extract_full_article_fallback_to_all_paragraphs(): void + { + $html = ' + + +

First general paragraph.

+

Second general paragraph.

+ + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $expected = "First general paragraph.\n\nSecond general paragraph."; + $this->assertEquals($expected, $fullArticle); + } + + public function test_extract_full_article_filters_empty_paragraphs(): void + { + $html = ' + + +
+

Content paragraph.

+

+

+

Another content paragraph.

+
+ + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $expected = "Content paragraph.\n\nAnother content paragraph."; + $this->assertEquals($expected, $fullArticle); + } + + public function test_extract_full_article_handles_nested_tags(): void + { + $html = ' + + +
+

This has bold text and italic text.

+

This has a link inside.

+
+ + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $expected = "This has bold text and italic text.\n\nThis has a link inside."; + $this->assertEquals($expected, $fullArticle); + } + + public function test_extract_full_article_removes_scripts_and_styles(): void + { + $html = ' + + + + + + +
+

Clean content.

+
+ + + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $this->assertEquals('Clean content.', $fullArticle); + $this->assertStringNotContainsString('console.log', $fullArticle); + $this->assertStringNotContainsString('alert', $fullArticle); + } + + public function test_extract_full_article_returns_null_when_no_content(): void + { + $html = '
No paragraphs here
'; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $this->assertNull($fullArticle); + } + + public function test_extract_thumbnail_from_og_image(): void + { + $html = ''; + + $thumbnail = GuardianArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://i.guim.co.uk/img/test.jpg', $thumbnail); + } + + public function test_extract_thumbnail_from_img_tag(): void + { + $html = 'test'; + + $thumbnail = GuardianArticlePageParser::extractThumbnail($html); + + $this->assertEquals('https://i.guim.co.uk/img/article-image.png', $thumbnail); + } + + public function test_extract_thumbnail_returns_null_when_not_found(): void + { + $html = '
No images here
'; + + $thumbnail = GuardianArticlePageParser::extractThumbnail($html); + + $this->assertNull($thumbnail); + } + + public function test_extract_data_returns_all_components(): void + { + $html = ' + + + + + + + +
+

Full article content here.

+
+ + + '; + + $data = GuardianArticlePageParser::extractData($html); + + $this->assertIsArray($data); + $this->assertArrayHasKey('title', $data); + $this->assertArrayHasKey('description', $data); + $this->assertArrayHasKey('full_article', $data); + $this->assertArrayHasKey('thumbnail', $data); + + $this->assertEquals('Guardian Test Article', $data['title']); + $this->assertEquals('Test description', $data['description']); + $this->assertEquals('Full article content here.', $data['full_article']); + $this->assertEquals('https://i.guim.co.uk/img/image.jpg', $data['thumbnail']); + } + + public function test_extract_data_handles_missing_components_gracefully(): void + { + $html = '
Minimal content
'; + + $data = GuardianArticlePageParser::extractData($html); + + $this->assertIsArray($data); + $this->assertNull($data['title']); + $this->assertNull($data['description']); + $this->assertNull($data['full_article']); + $this->assertNull($data['thumbnail']); + } + + public function test_extract_full_article_with_realistic_guardian_html(): void + { + $html = ' + + +
+

The prime minister has announced a new climate policy that aims to reduce carbon emissions by 50% by 2030.

+

The announcement came during a press conference at Downing Street on Tuesday afternoon.

+

Environmental groups have cautiously welcomed the move, while industry leaders have expressed concern about the timeline.

+
+ + + '; + + $fullArticle = GuardianArticlePageParser::extractFullArticle($html); + + $this->assertNotNull($fullArticle); + $this->assertStringContainsString('climate policy', $fullArticle); + $this->assertStringContainsString('press conference', $fullArticle); + $this->assertStringContainsString('Environmental groups', $fullArticle); + $this->assertStringContainsString("\n\n", $fullArticle); + $this->assertStringNotContainsString('', $fullArticle); + } +} \ No newline at end of file diff --git a/tests/Unit/Services/Parsers/GuardianArticleParserTest.php b/tests/Unit/Services/Parsers/GuardianArticleParserTest.php new file mode 100644 index 0000000..51bcd43 --- /dev/null +++ b/tests/Unit/Services/Parsers/GuardianArticleParserTest.php @@ -0,0 +1,63 @@ +parser = new GuardianArticleParser(); + } + + public function test_implements_article_parser_interface(): void + { + $this->assertInstanceOf(ArticleParserInterface::class, $this->parser); + } + + public function test_can_parse_guardian_url(): void + { + $this->assertTrue($this->parser->canParse('https://www.theguardian.com/world/2026/mar/08/some-article')); + } + + public function test_can_parse_guardian_url_without_www(): void + { + $this->assertTrue($this->parser->canParse('https://theguardian.com/world/2026/mar/08/some-article')); + } + + public function test_cannot_parse_non_guardian_url(): void + { + $this->assertFalse($this->parser->canParse('https://www.vrt.be/vrtnws/en/article')); + $this->assertFalse($this->parser->canParse('https://www.belganewsagency.eu/article')); + } + + public function test_get_source_name(): void + { + $this->assertEquals('The Guardian', $this->parser->getSourceName()); + } + + public function test_extract_data_delegates_to_page_parser(): void + { + $html = ' + + + + + +

Content

+ + '; + + $data = $this->parser->extractData($html); + + $this->assertIsArray($data); + $this->assertArrayHasKey('title', $data); + $this->assertEquals('Test Title', $data['title']); + } +} \ No newline at end of file diff --git a/tests/Unit/Services/Parsers/VrtHomepageParserTest.php b/tests/Unit/Services/Parsers/VrtHomepageParserTest.php new file mode 100644 index 0000000..303f86d --- /dev/null +++ b/tests/Unit/Services/Parsers/VrtHomepageParserTest.php @@ -0,0 +1,123 @@ + + + Culture +

Da Vinci, Botticelli and Cranach shine at the Bozar

+ + + + + Home News +

Work to remove 7 Nazi sea mines to get underway on Monday

+ +
+ HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'en'); + + $this->assertCount(2, $urls); + $this->assertContains('https://www.vrt.be/vrtnws/en/2026/03/03/da-vinci-botticelli-and-cranach-shine-at-the-bozar/', $urls); + $this->assertContains('https://www.vrt.be/vrtnws/en/2026/03/06/work-to-remove-7-nazi-sea-mines-to-get-underway-on-monday/', $urls); + } + + public function test_extracts_dutch_article_urls_from_absolute_links(): void + { + $html = <<<'HTML' + + + Latijns-Amerika +

Cuba nadert het einde

+ +
+ + + Binnenland +

Goudkopleeuwaapje even ontsnapt

+ +
+ HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'nl'); + + $this->assertCount(2, $urls); + $this->assertContains('https://www.vrt.be/vrtnws/nl/2026/03/07/cuba-nadert-het-einde-en-zal-snel-onderhandelen-zegt-presiden/', $urls); + $this->assertContains('https://www.vrt.be/vrtnws/nl/2026/03/07/planckendael-aap-ontsnapt/', $urls); + } + + public function test_does_not_extract_urls_for_wrong_language(): void + { + $html = <<<'HTML' + Article + HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'nl'); + + $this->assertEmpty($urls); + } + + public function test_deduplicates_urls(): void + { + $html = <<<'HTML' + Article + Article again + HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'en'); + + $this->assertCount(1, $urls); + } + + public function test_returns_empty_array_for_html_without_article_links(): void + { + $html = 'About'; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'en'); + + $this->assertEmpty($urls); + } + + public function test_handles_mixed_relative_and_absolute_links(): void + { + $html = <<<'HTML' + Relative + Absolute + HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html, 'nl'); + + $this->assertCount(2, $urls); + $this->assertContains('https://www.vrt.be/vrtnws/nl/2026/03/07/relative-article/', $urls); + $this->assertContains('https://www.vrt.be/vrtnws/nl/2026/03/07/absolute-article/', $urls); + } + + public function test_defaults_to_english_when_no_language_specified(): void + { + $html = <<<'HTML' + Test + Dutch + HTML; + + $urls = VrtHomepageParser::extractArticleUrls($html); + + $this->assertCount(1, $urls); + $this->assertContains('https://www.vrt.be/vrtnws/en/2026/03/03/test-article/', $urls); + } + + public function test_returns_empty_array_for_empty_html(): void + { + $urls = VrtHomepageParser::extractArticleUrls('', 'en'); + + $this->assertEmpty($urls); + } +} \ No newline at end of file diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 53da385..6880c9f 100644 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -62,7 +62,8 @@ 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' + 'approval_status' => 'approved', + 'validated_at' => now() ]); $extractedData = ['title' => 'Test Title']; @@ -79,6 +80,7 @@ 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 @@ -105,7 +107,8 @@ 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']); + $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', + 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -151,7 +154,8 @@ 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']); + $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', + 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -192,7 +196,8 @@ 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']); + $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', + 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -247,7 +252,8 @@ 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']); + $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', + 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -305,6 +311,7 @@ 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', ]); @@ -356,6 +363,7 @@ 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', ]); @@ -408,6 +416,7 @@ 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', ]);