Compare commits
13 commits
2a0653981c
...
cf2fa647f5
| Author | SHA1 | Date | |
|---|---|---|---|
| cf2fa647f5 | |||
| 677d1cab6e | |||
| 1e39a25f83 | |||
| 0123e20b1d | |||
| 58b07830ec | |||
| 69dfb897cf | |||
| c28ac317a5 | |||
| 025794c852 | |||
| 47659ff8c0 | |||
| 3ed49cfbbe | |||
| 866f8d02d3 | |||
| b658f847fb | |||
| 35e4260c87 |
57 changed files with 2208 additions and 798 deletions
28
.env.testing
Normal file
28
.env.testing
Normal 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
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
/.vite
|
||||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
|
|
|
|||
47
app/Actions/CreateChannelAction.php
Normal file
47
app/Actions/CreateChannelAction.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
36
app/Actions/CreateFeedAction.php
Normal file
36
app/Actions/CreateFeedAction.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/Actions/CreatePlatformAccountAction.php
Normal file
53
app/Actions/CreatePlatformAccountAction.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/Actions/CreateRouteAction.php
Normal file
26
app/Actions/CreateRouteAction.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
36
app/Http/Requests/StorePlatformAccountRequest.php
Normal file
36
app/Http/Requests/StorePlatformAccountRequest.php
Normal 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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/StorePlatformChannelRequest.php
Normal file
26
app/Http/Requests/StorePlatformChannelRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/StoreRouteRequest.php
Normal file
26
app/Http/Requests/StoreRouteRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
110
app/Services/Parsers/GuardianArticlePageParser.php
Normal file
110
app/Services/Parsers/GuardianArticlePageParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/Services/Parsers/GuardianArticleParser.php
Normal file
23
app/Services/Parsers/GuardianArticleParser.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
24
phpunit.xml
24
phpunit.xml
|
|
@ -18,17 +18,17 @@
|
|||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:5VABFQKtzx6flRFn7rQUQYI/G8xLnkUSYPVaYz2s/4M="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<server name="CACHE_STORE" value="array" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<server name="MAIL_MAILER" value="array" force="true"/>
|
||||
<server name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<server name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
11
shell.nix
11
shell.nix
|
|
@ -65,6 +65,16 @@ pkgs.mkShell {
|
|||
podman-compose -f $COMPOSE_FILE restart "$@"
|
||||
}
|
||||
|
||||
dev-rebuild() {
|
||||
echo "Rebuilding services (down -v + up)..."
|
||||
podman-compose -f $COMPOSE_FILE down -v
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@"
|
||||
echo ""
|
||||
podman-compose -f $COMPOSE_FILE ps
|
||||
echo ""
|
||||
echo "App available at: http://localhost:8000"
|
||||
}
|
||||
|
||||
dev-logs() {
|
||||
podman-compose -f $COMPOSE_FILE logs -f app "$@"
|
||||
}
|
||||
|
|
@ -123,6 +133,7 @@ pkgs.mkShell {
|
|||
echo "Commands:"
|
||||
echo " dev-up [services] Start all or specific services"
|
||||
echo " dev-down [-v] Stop services (-v removes volumes)"
|
||||
echo " dev-rebuild Fresh start (down -v + up)"
|
||||
echo " dev-restart Restart services"
|
||||
echo " dev-logs Tail app logs"
|
||||
echo " dev-logs-db Tail database logs"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
tests/Unit/Actions/CreateChannelActionTest.php
Normal file
86
tests/Unit/Actions/CreateChannelActionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
91
tests/Unit/Actions/CreateFeedActionTest.php
Normal file
91
tests/Unit/Actions/CreateFeedActionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
104
tests/Unit/Actions/CreatePlatformAccountActionTest.php
Normal file
104
tests/Unit/Actions/CreatePlatformAccountActionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
tests/Unit/Actions/CreateRouteActionTest.php
Normal file
76
tests/Unit/Actions/CreateRouteActionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
42
tests/Unit/Models/SettingTest.php
Normal file
42
tests/Unit/Models/SettingTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
164
tests/Unit/Services/ArticleFetcherRssTest.php
Normal file
164
tests/Unit/Services/ArticleFetcherRssTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
285
tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php
Normal file
285
tests/Unit/Services/Parsers/GuardianArticlePageParserTest.php
Normal 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 & Article "Title""/></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);
|
||||
}
|
||||
}
|
||||
63
tests/Unit/Services/Parsers/GuardianArticleParserTest.php
Normal file
63
tests/Unit/Services/Parsers/GuardianArticleParserTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
123
tests/Unit/Services/Parsers/VrtHomepageParserTest.php
Normal file
123
tests/Unit/Services/Parsers/VrtHomepageParserTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue