Compare commits

...

13 commits

Author SHA1 Message Date
cf2fa647f5 Merge pull request 'Release v1.1.0' (#79) from release/v1.1.0 into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
Reviewed-on: #79
2026-03-08 11:44:53 +01:00
677d1cab6e 62 - Add article publishing interval setting 2026-03-08 11:25:50 +01:00
1e39a25f83 37 - Add The Guardian as RSS feed provider, implement RSS parsing 2026-03-08 11:02:46 +01:00
0123e20b1d Fix 28 pre-existing test failures across test suite 2026-03-08 02:54:10 +01:00
58b07830ec 61 - Address code review findings for Action consolidation 2026-03-08 02:53:46 +01:00
69dfb897cf 61 - Remove onboarding create endpoints, use standard model endpoints 2026-03-08 01:55:28 +01:00
c28ac317a5 61 - Refactor Livewire Onboarding to use Actions, fix double-encryption bug 2026-03-08 01:48:09 +01:00
025794c852 61 - Refactor model controllers to use Actions and FormRequests 2026-03-08 01:42:21 +01:00
47659ff8c0 61 - Add Action classes for create operations with unit tests 2026-03-08 01:18:12 +01:00
3ed49cfbbe 77 - Fix VRT homepage parser language support 2026-03-07 19:26:33 +01:00
866f8d02d3 64 - Reload page after article refresh
Dispatch a Livewire event on refresh that triggers a 10-second
setTimeout via Alpine.js, then reloads the page to show newly
fetched articles.
2026-03-07 18:30:28 +01:00
b658f847fb Ignore vite build cache 2026-03-07 15:41:17 +01:00
35e4260c87 76 - Lock feed language to channel language in onboarding wizard 2026-03-07 10:43:48 +01:00
57 changed files with 2208 additions and 798 deletions

28
.env.testing Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/.vite
/.phpunit.cache
/bootstrap/ssr
/node_modules

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,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
*/

View file

@ -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);
}
}

View file

@ -2,12 +2,16 @@
namespace App\Http\Controllers\Api\V1;
use App\Actions\CreateChannelAction;
use App\Http\Requests\StorePlatformChannelRequest;
use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformChannel;
use App\Models\PlatformAccount;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use RuntimeException;
class PlatformChannelsController extends BaseController
{
@ -30,55 +34,26 @@ public function index(): JsonResponse
/**
* Store a newly created platform channel
*/
public function store(Request $request): JsonResponse
public function store(StorePlatformChannelRequest $request, CreateChannelAction $createChannelAction): JsonResponse
{
try {
$validated = $request->validate([
'platform_instance_id' => 'required|exists:platform_instances,id',
'channel_id' => 'required|string|max:255',
'name' => 'required|string|max:255',
'display_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? true;
// Get the platform instance to check for active accounts
$platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']);
// Check if there are active platform accounts for this instance
$activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url)
->where('is_active', true)
->get();
if ($activeAccounts->isEmpty()) {
return $this->sendError(
'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.',
[],
422
);
}
$channel = 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);
}
}

View file

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

View file

@ -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(

View file

@ -12,13 +12,15 @@ public function authorize(): bool
}
/**
* @return array<string, string>
* @return array<string, mixed>
*/
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'

View file

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

View file

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

View file

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

View file

@ -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')

View file

@ -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()

View file

@ -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');
}
}

View file

@ -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!';

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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();
}
}
/**

View file

@ -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,
];
/**

View file

@ -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<int, class-string<HomepageParserInterface>>
*/
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);
}
}

View file

@ -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');

View file

@ -0,0 +1,110 @@
<?php
namespace App\Services\Parsers;
class GuardianArticlePageParser
{
public static function extractTitle(string $html): ?string
{
// Try meta title first
if (preg_match('/<meta property="og:title" content="([^"]+)"/i', $html, $matches)) {
return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
}
// Try any h1 tag
if (preg_match('/<h1[^>]*>([^<]+)<\/h1>/i', $html, $matches)) {
return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8');
}
// Try title tag
if (preg_match('/<title>([^<]+)<\/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;
}
}

View file

@ -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';
}
}

View file

@ -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()

View file

@ -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

View file

@ -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,
],
],
],
/*

View file

@ -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],
],
/*

View file

@ -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>

View file

@ -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">

View file

@ -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())

View file

@ -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">

View file

@ -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');

View file

@ -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"

View file

@ -49,8 +49,8 @@ public function test_index_returns_feeds_ordered_by_active_status_then_name(): v
public function test_store_creates_vrt_feed_successfully(): void
{
$language = Language::factory()->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',

View file

@ -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

View file

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

View file

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

View file

@ -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']);
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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());

View file

@ -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());
}
}

View file

@ -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</title>
<link>https://www.theguardian.com/international</link>
<item>
<title>First Article Title</title>
<link>https://www.theguardian.com/world/2026/mar/08/first-article</link>
<description>First article description</description>
<pubDate>Sun, 08 Mar 2026 12:00:00 GMT</pubDate>
</item>
<item>
<title>Second Article Title</title>
<link>https://www.theguardian.com/world/2026/mar/08/second-article</link>
<description>Second article description</description>
<pubDate>Sun, 08 Mar 2026 11:00:00 GMT</pubDate>
</item>
</channel>
</rss>
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('<?xml version="1.0"?><rss><channel><title>Empty</title></channel></rss>', 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();
}
}

View file

@ -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';

View file

@ -0,0 +1,285 @@
<?php
namespace Tests\Unit\Services\Parsers;
use App\Services\Parsers\GuardianArticlePageParser;
use Tests\TestCase;
class GuardianArticlePageParserTest extends TestCase
{
public function test_extract_title_from_og_meta_tag(): void
{
$html = '<html><head><meta property="og:title" content="Guardian Article Title"/></head><body></body></html>';
$title = GuardianArticlePageParser::extractTitle($html);
$this->assertEquals('Guardian Article Title', $title);
}
public function test_extract_title_from_h1_tag(): void
{
$html = '<html><body><h1>H1 Title Test</h1></body></html>';
$title = GuardianArticlePageParser::extractTitle($html);
$this->assertEquals('H1 Title Test', $title);
}
public function test_extract_title_from_title_tag(): void
{
$html = '<html><head><title>Page Title Test</title></head><body></body></html>';
$title = GuardianArticlePageParser::extractTitle($html);
$this->assertEquals('Page Title Test', $title);
}
public function test_extract_title_with_html_entities(): void
{
$html = '<html><head><meta property="og:title" content="Test &amp; Article &quot;Title&quot;"/></head></html>';
$title = GuardianArticlePageParser::extractTitle($html);
$this->assertEquals('Test & Article "Title"', $title);
}
public function test_extract_title_returns_null_when_not_found(): void
{
$html = '<html><body><p>No title here</p></body></html>';
$title = GuardianArticlePageParser::extractTitle($html);
$this->assertNull($title);
}
public function test_extract_description_from_og_meta_tag(): void
{
$html = '<html><head><meta property="og:description" content="Guardian article description"/></head></html>';
$description = GuardianArticlePageParser::extractDescription($html);
$this->assertEquals('Guardian article description', $description);
}
public function test_extract_description_from_paragraph(): void
{
$html = '<html><body><p>This is the first paragraph description.</p></body></html>';
$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 = '<html><body><div>No description here</div></body></html>';
$description = GuardianArticlePageParser::extractDescription($html);
$this->assertNull($description);
}
public function test_extract_full_article_from_guardian_article_body(): void
{
$html = '
<html>
<body>
<div class="article-body-commercial-selector">
<p>First paragraph of the article.</p>
<p>Second paragraph of the article.</p>
</div>
</body>
</html>
';
$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 = '
<html>
<body>
<p>First general paragraph.</p>
<p>Second general paragraph.</p>
</body>
</html>
';
$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 = '
<html>
<body>
<div class="article-body-commercial-selector">
<p>Content paragraph.</p>
<p> </p>
<p></p>
<p>Another content paragraph.</p>
</div>
</body>
</html>
';
$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 = '
<html>
<body>
<div class="article-body-commercial-selector">
<p>This has <strong>bold text</strong> and <em>italic text</em>.</p>
<p>This has <a href="#">a link</a> inside.</p>
</div>
</body>
</html>
';
$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 = '
<html>
<head>
<script>console.log("test");</script>
<style>.test { color: red; }</style>
</head>
<body>
<div class="article-body-commercial-selector">
<p>Clean content.</p>
</div>
<script>alert("bad");</script>
</body>
</html>
';
$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 = '<html><body><div>No paragraphs here</div></body></html>';
$fullArticle = GuardianArticlePageParser::extractFullArticle($html);
$this->assertNull($fullArticle);
}
public function test_extract_thumbnail_from_og_image(): void
{
$html = '<html><head><meta property="og:image" content="https://i.guim.co.uk/img/test.jpg"/></head></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 = '<html><body><img src="https://i.guim.co.uk/img/article-image.png" alt="test"/></body></html>';
$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 = '<html><body><div>No images here</div></body></html>';
$thumbnail = GuardianArticlePageParser::extractThumbnail($html);
$this->assertNull($thumbnail);
}
public function test_extract_data_returns_all_components(): void
{
$html = '
<html>
<head>
<meta property="og:title" content="Guardian Test Article"/>
<meta property="og:description" content="Test description"/>
<meta property="og:image" content="https://i.guim.co.uk/img/image.jpg"/>
</head>
<body>
<div class="article-body-commercial-selector">
<p>Full article content here.</p>
</div>
</body>
</html>
';
$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 = '<html><body><div>Minimal content</div></body></html>';
$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 = '
<html>
<body>
<div class="article-body-commercial-selector">
<p><strong>The prime minister has announced a new climate policy that aims to reduce carbon emissions by 50% by 2030.</strong></p>
<p>The announcement came during a press conference at Downing Street on Tuesday afternoon.</p>
<p>Environmental groups have cautiously welcomed the move, while industry leaders have expressed concern about the timeline.</p>
</div>
</body>
</html>
';
$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('<strong>', $fullArticle);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace Tests\Unit\Services\Parsers;
use App\Contracts\ArticleParserInterface;
use App\Services\Parsers\GuardianArticleParser;
use Tests\TestCase;
class GuardianArticleParserTest extends TestCase
{
private GuardianArticleParser $parser;
protected function setUp(): void
{
parent::setUp();
$this->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 = '
<html>
<head>
<meta property="og:title" content="Test Title"/>
<meta property="og:description" content="Test Description"/>
</head>
<body><p>Content</p></body>
</html>
';
$data = $this->parser->extractData($html);
$this->assertIsArray($data);
$this->assertArrayHasKey('title', $data);
$this->assertEquals('Test Title', $data['title']);
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Tests\Unit\Services\Parsers;
use App\Services\Parsers\VrtHomepageParser;
use PHPUnit\Framework\TestCase;
class VrtHomepageParserTest extends TestCase
{
public function test_extracts_english_article_urls_from_relative_links(): void
{
$html = <<<'HTML'
<a href="/vrtnws/en/2026/03/03/da-vinci-botticelli-and-cranach-shine-at-the-bozar/">
<img src="https://images.vrt.be/example.jpg" alt="">
<span>Culture</span>
<h2>Da Vinci, Botticelli and Cranach shine at the Bozar</h2>
<time>10 hours ago</time>
</a>
<a href="/vrtnws/en/2026/03/06/work-to-remove-7-nazi-sea-mines-to-get-underway-on-monday/">
<img src="https://images.vrt.be/example2.jpg" alt="">
<span>Home News</span>
<h2>Work to remove 7 Nazi sea mines to get underway on Monday</h2>
<time>Fri 6 Mar</time>
</a>
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'
<a href="https://www.vrt.be/vrtnws/nl/2026/03/07/cuba-nadert-het-einde-en-zal-snel-onderhandelen-zegt-presiden/">
<img src="https://images.vrt.be/example.jpg">
<span>Latijns-Amerika</span>
<h3>Cuba nadert het einde</h3>
<time>1 uur geleden</time>
</a>
<a href="https://www.vrt.be/vrtnws/nl/2026/03/07/planckendael-aap-ontsnapt/">
<img src="https://images.vrt.be/example2.jpg">
<span>Binnenland</span>
<h3>Goudkopleeuwaapje even ontsnapt</h3>
<time>49 minuten geleden</time>
</a>
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'
<a href="/vrtnws/en/2026/03/03/some-english-article/">Article</a>
HTML;
$urls = VrtHomepageParser::extractArticleUrls($html, 'nl');
$this->assertEmpty($urls);
}
public function test_deduplicates_urls(): void
{
$html = <<<'HTML'
<a href="/vrtnws/en/2026/03/03/same-article/">Article</a>
<a href="/vrtnws/en/2026/03/03/same-article/">Article again</a>
HTML;
$urls = VrtHomepageParser::extractArticleUrls($html, 'en');
$this->assertCount(1, $urls);
}
public function test_returns_empty_array_for_html_without_article_links(): void
{
$html = '<html><body><a href="/about">About</a></body></html>';
$urls = VrtHomepageParser::extractArticleUrls($html, 'en');
$this->assertEmpty($urls);
}
public function test_handles_mixed_relative_and_absolute_links(): void
{
$html = <<<'HTML'
<a href="/vrtnws/nl/2026/03/07/relative-article/">Relative</a>
<a href="https://www.vrt.be/vrtnws/nl/2026/03/07/absolute-article/">Absolute</a>
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'
<a href="/vrtnws/en/2026/03/03/test-article/">Test</a>
<a href="/vrtnws/nl/2026/03/03/dutch-article/">Dutch</a>
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);
}
}

View file

@ -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',
]);