Release v1.2.0 #87

Merged
myrmidex merged 9 commits from release/v1.2.0 into main 2026-03-08 21:30:58 +01:00
176 changed files with 1989 additions and 1437 deletions

113
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,113 @@
name: CI
on:
push:
branches: ['release/*']
pull_request:
branches: [main]
jobs:
ci:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_sqlite, mbstring, xml, dom
coverage: pcov
- name: Cache Composer dependencies
uses: https://data.forgejo.org/actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Prepare environment
run: cp .env.testing .env
- name: Lint
run: vendor/bin/pint --test
- name: Static analysis
run: vendor/bin/phpstan analyse
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
- name: Parse coverage
if: github.event_name == 'pull_request'
id: coverage
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage.xml");
if ($xml === false || !isset($xml->project->metrics)) {
echo "0";
exit;
}
$metrics = $xml->project->metrics;
$statements = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COVERAGE: ${{ steps.coverage.outputs.percentage }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
COMMIT_SHA: ${{ github.sha }}
run: |
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
MARKER="<!-- ffr-ci-coverage-report -->"
BODY="${MARKER}
## Code Coverage Report
| Metric | Value |
|--------|-------|
| **Line Coverage** | ${COVERAGE}% |
_Updated by CI — commit ${COMMIT_SHA}_"
# Find existing coverage comment
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API_URL}?limit=50" | \
php -r '
$comments = json_decode(file_get_contents("php://stdin"), true);
if (!is_array($comments)) exit;
foreach ($comments as $c) {
if (str_contains($c["body"], "<!-- ffr-ci-coverage-report -->")) {
echo $c["id"];
exit;
}
}
' || true)
if [ -n "$EXISTING" ]; then
# Update existing comment
curl -sf -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
else
# Create new comment
curl -sf -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${API_URL}" > /dev/null
fi

View file

@ -11,6 +11,7 @@ public function canParse(string $url): bool;
/** /**
* Extract article data from HTML * Extract article data from HTML
*
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function extractData(string $html): array; public function extractData(string $html): array;

View file

@ -11,6 +11,7 @@ public function canParse(string $url): bool;
/** /**
* Extract article URLs from homepage HTML * Extract article URLs from homepage HTML
*
* @return array<int, string> * @return array<int, string>
*/ */
public function extractArticleUrls(string $html): array; public function extractArticleUrls(string $html): array;

View file

@ -5,4 +5,18 @@
enum PlatformEnum: string enum PlatformEnum: string
{ {
case LEMMY = 'lemmy'; case LEMMY = 'lemmy';
public function channelLabel(): string
{
return match ($this) {
self::LEMMY => 'Community',
};
}
public function channelLabelPlural(): string
{
return match ($this) {
self::LEMMY => 'Communities',
};
}
} }

View file

@ -0,0 +1,18 @@
<?php
namespace App\Events;
use App\Enums\LogLevelEnum;
use Illuminate\Foundation\Events\Dispatchable;
class ActionPerformed
{
use Dispatchable;
public function __construct(
public string $message,
public LogLevelEnum $level = LogLevelEnum::INFO,
/** @var array<string, mixed> */
public array $context = [],
) {}
}

View file

@ -12,6 +12,5 @@ class ExceptionLogged
public function __construct( public function __construct(
public Log $log public Log $log
) { ) {}
}
} }

View file

@ -17,6 +17,5 @@ public function __construct(
public string $message, public string $message,
/** @var array<string, mixed> */ /** @var array<string, mixed> */
public array $context = [] public array $context = []
) { ) {}
}
} }

View file

@ -11,7 +11,5 @@ class NewArticleFetched
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Article $article) public function __construct(public Article $article) {}
{
}
} }

View file

@ -4,6 +4,4 @@
use Exception; use Exception;
class ChannelException extends Exception class ChannelException extends Exception {}
{
}

View file

@ -11,7 +11,7 @@ class PublishException extends Exception
{ {
public function __construct( public function __construct(
private readonly Article $article, private readonly Article $article,
private readonly PlatformEnum|null $platform, private readonly ?PlatformEnum $platform,
?Throwable $previous = null ?Throwable $previous = null
) { ) {
$message = "Failed to publish article #$article->id"; $message = "Failed to publish article #$article->id";

View file

@ -4,6 +4,4 @@
use Exception; use Exception;
class RoutingException extends Exception class RoutingException extends Exception {}
{
}

View file

@ -3,13 +3,12 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\ArticleResource; use App\Http\Resources\ArticleResource;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article; use App\Models\Article;
use App\Models\Setting; use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
class ArticlesController extends BaseController class ArticlesController extends BaseController
{ {

View file

@ -5,7 +5,6 @@
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;

View file

@ -23,6 +23,8 @@ public function sendResponse(mixed $result, string $message = 'Success', int $co
/** /**
* Error response method * Error response method
*
* @param array<string, mixed> $errorMessages
*/ */
public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse
{ {
@ -40,6 +42,8 @@ public function sendError(string $error, array $errorMessages = [], int $code =
/** /**
* Validation error response method * Validation error response method
*
* @param array<string, mixed> $errors
*/ */
public function sendValidationError(array $errors): JsonResponse public function sendValidationError(array $errors): JsonResponse
{ {

View file

@ -3,9 +3,6 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -40,7 +37,6 @@ public function stats(Request $request): JsonResponse
'current_period' => $period, 'current_period' => $period,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
throw $e;
return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500); return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
} }
} }

View file

@ -10,8 +10,8 @@
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use InvalidArgumentException;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
class FeedsController extends BaseController class FeedsController extends BaseController
{ {
@ -37,7 +37,7 @@ public function index(Request $request): JsonResponse
'total' => $feeds->total(), 'total' => $feeds->total(),
'from' => $feeds->firstItem(), 'from' => $feeds->firstItem(),
'to' => $feeds->lastItem(), 'to' => $feeds->lastItem(),
] ],
], 'Feeds retrieved successfully.'); ], 'Feeds retrieved successfully.');
} }

View file

@ -84,8 +84,10 @@ public function options(): JsonResponse
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
// Get feed providers from config // Get feed providers from config
$feedProviders = collect(config('feed.providers', [])) /** @var array<string, array<string, mixed>> $providers */
->filter(fn($provider) => $provider['is_active']) $providers = config('feed.providers', []);
$feedProviders = collect($providers)
->filter(fn (array $provider) => $provider['is_active'])
->values(); ->values();
return $this->sendResponse([ return $this->sendResponse([

View file

@ -53,6 +53,7 @@ public function store(StorePlatformAccountRequest $request, CreatePlatformAccoun
if (str_contains($e->getMessage(), 'Rate limited by')) { if (str_contains($e->getMessage(), 'Rate limited by')) {
return $this->sendError($e->getMessage(), [], 429); return $this->sendError($e->getMessage(), [], 429);
} }
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (Exception $e) { } catch (Exception $e) {
return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422); return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422);

View file

@ -5,8 +5,8 @@
use App\Actions\CreateChannelAction; use App\Actions\CreateChannelAction;
use App\Http\Requests\StorePlatformChannelRequest; use App\Http\Requests\StorePlatformChannelRequest;
use App\Http\Resources\PlatformChannelResource; use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformChannel;
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use Exception; use Exception;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -144,6 +144,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
'priority' => 'nullable|integer|min:1|max:100', 'priority' => 'nullable|integer|min:1|max:100',
]); ]);
/** @var PlatformAccount $platformAccount */
$platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']); $platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']);
// Check if account is already attached // Check if account is already attached

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -19,7 +20,9 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
} }
if ($request->user()->markEmailAsVerified()) { if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user())); /** @var MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));
} }
return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); return redirect()->intended(route('dashboard', absolute: false).'?verified=1');

View file

@ -23,7 +23,7 @@ public function rules(): array
'provider' => "required|in:{$providers}", 'provider' => "required|in:{$providers}",
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
} }
} }

View file

@ -23,7 +23,7 @@ public function rules(): array
'type' => 'required|in:website,rss', 'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id', 'language_id' => 'required|exists:languages,id',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
} }
} }

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\ArticlePublication
*/
class ArticlePublicationResource extends JsonResource class ArticlePublicationResource extends JsonResource
{ {
/** /**
@ -17,8 +20,8 @@ public function toArray(Request $request): array
return [ return [
'id' => $this->id, 'id' => $this->id,
'article_id' => $this->article_id, 'article_id' => $this->article_id,
'status' => $this->status, 'platform' => $this->platform,
'published_at' => $this->published_at?->toISOString(), 'published_at' => $this->published_at->toISOString(),
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(),
]; ];

View file

@ -6,10 +6,13 @@
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @property int $id * @mixin \App\Models\Article
*/ */
class ArticleResource extends JsonResource class ArticleResource extends JsonResource
{ {
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
@ -19,12 +22,8 @@ public function toArray(Request $request): array
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status, 'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(),
'validated_at' => $this->validated_at?->toISOString(), 'validated_at' => $this->validated_at?->toISOString(),
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Feed
*/
class FeedResource extends JsonResource class FeedResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,12 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformAccount
*/
/**
* @mixin \App\Models\PlatformAccount
*/
class PlatformAccountResource extends JsonResource class PlatformAccountResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformChannel
*/
class PlatformChannelResource extends JsonResource class PlatformChannelResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformInstance
*/
class PlatformInstanceResource extends JsonResource class PlatformInstanceResource extends JsonResource
{ {
/** /**

View file

@ -5,6 +5,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Route
*/
class RouteResource extends JsonResource class RouteResource extends JsonResource
{ {
/** /**
@ -15,7 +18,6 @@ class RouteResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id,
'feed_id' => $this->feed_id, 'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id, 'platform_channel_id' => $this->platform_channel_id,
'is_active' => $this->is_active, 'is_active' => $this->is_active,

View file

@ -25,7 +25,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
$logSaver->info('Starting feed article fetch', null, [ $logSaver->info('Starting feed article fetch', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'feed_url' => $this->feed->url 'feed_url' => $this->feed->url,
]); ]);
$articles = $articleFetcher->getArticlesFromFeed($this->feed); $articles = $articleFetcher->getArticlesFromFeed($this->feed);
@ -33,7 +33,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
$logSaver->info('Feed article fetch completed', null, [ $logSaver->info('Feed article fetch completed', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'articles_count' => $articles->count() 'articles_count' => $articles->count(),
]); ]);
$this->feed->update(['last_fetched_at' => now()]); $this->feed->update(['last_fetched_at' => now()]);
@ -56,7 +56,7 @@ public static function dispatchForAllActiveFeeds(): void
$logSaver->info('Dispatched feed discovery job', null, [ $logSaver->info('Dispatched feed discovery job', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_name' => $feed->name, 'feed_name' => $feed->name,
'delay_minutes' => $delayMinutes 'delay_minutes' => $delayMinutes,
]); ]);
}); });
} }

View file

@ -2,6 +2,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
@ -12,7 +14,7 @@
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
class PublishNextArticleJob implements ShouldQueue, ShouldBeUnique class PublishNextArticleJob implements ShouldBeUnique, ShouldQueue
{ {
use Queueable; use Queueable;
@ -28,6 +30,7 @@ public function __construct()
/** /**
* Execute the job. * Execute the job.
*
* @throws PublishException * @throws PublishException
*/ */
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
@ -52,11 +55,11 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
return; return;
} }
logger()->info('Publishing next article from scheduled job', [ ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
'url' => $article->url, 'url' => $article->url,
'created_at' => $article->created_at 'created_at' => $article->created_at,
]); ]);
// Fetch article data // Fetch article data
@ -65,14 +68,14 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
try { try {
$publishingService->publishToRoutedChannels($article, $extractedData); $publishingService->publishToRoutedChannels($article, $extractedData);
logger()->info('Successfully published article', [ ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title 'title' => $article->title,
]); ]);
} catch (PublishException $e) { } catch (PublishException $e) {
logger()->error('Failed to publish article', [ ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;

View file

@ -15,7 +15,7 @@
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique class SyncChannelPostsJob implements ShouldBeUnique, ShouldQueue
{ {
use Queueable; use Queueable;
@ -78,7 +78,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
} catch (Exception $e) { } catch (Exception $e) {
$logSaver->error('Failed to sync channel posts', $this->channel, [ $logSaver->error('Failed to sync channel posts', $this->channel, [
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;

View file

@ -0,0 +1,21 @@
<?php
namespace App\Listeners;
use App\Events\ActionPerformed;
use App\Services\Log\LogSaver;
use Exception;
class LogActionListener
{
public function __construct(private LogSaver $logSaver) {}
public function handle(ActionPerformed $event): void
{
try {
$this->logSaver->log($event->level, $event->message, context: $event->context);
} catch (Exception $e) {
error_log('Failed to log action to database: '.$e->getMessage());
}
}
}

View file

@ -5,9 +5,9 @@
use App\Events\ExceptionLogged; use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Models\Log; use App\Models\Log;
class LogExceptionToDatabase class LogExceptionToDatabase
{ {
public function handle(ExceptionOccurred $event): void public function handle(ExceptionOccurred $event): void
{ {
// Truncate the message to prevent database errors // Truncate the message to prevent database errors
@ -24,15 +24,15 @@ public function handle(ExceptionOccurred $event): void
'file' => $event->exception->getFile(), 'file' => $event->exception->getFile(),
'line' => $event->exception->getLine(), 'line' => $event->exception->getLine(),
'trace' => $event->exception->getTraceAsString(), 'trace' => $event->exception->getTraceAsString(),
...$event->context ...$event->context,
] ],
]); ]);
ExceptionLogged::dispatch($log); ExceptionLogged::dispatch($log);
} catch (\Exception $e) { } catch (\Exception $e) {
// Prevent infinite recursion by not logging this exception // Prevent infinite recursion by not logging this exception
// Optionally log to file or other non-database destination // Optionally log to file or other non-database destination
error_log("Failed to log exception to database: " . $e->getMessage()); error_log('Failed to log exception to database: '.$e->getMessage());
} }
} }
} }

View file

@ -2,6 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ArticleApproved; use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
@ -40,14 +42,14 @@ public function handle(ArticleApproved $event): void
if ($publications->isNotEmpty()) { if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']); $article->update(['publish_status' => 'published']);
logger()->info('Published approved article', [ ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
} else { } else {
$article->update(['publish_status' => 'error']); $article->update(['publish_status' => 'error']);
logger()->warning('No publications created for approved article', [ ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
@ -55,7 +57,7 @@ public function handle(ArticleApproved $event): void
} catch (Exception $e) { } catch (Exception $e) {
$article->update(['publish_status' => 'error']); $article->update(['publish_status' => 'error']);
logger()->error('Failed to publish approved article', [ ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);

View file

@ -2,6 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
@ -37,10 +39,11 @@ public function handle(NewArticleFetched $event): void
try { try {
$article = $this->validationService->validate($article); $article = $this->validationService->validate($article);
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Article validation failed', [ ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
return; return;
} }

View file

@ -2,9 +2,9 @@
namespace App\Livewire; namespace App\Livewire;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article; use App\Models\Article;
use App\Models\Setting; use App\Models\Setting;
use App\Jobs\ArticleDiscoveryJob;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -39,7 +39,7 @@ public function refresh(): void
$this->dispatch('refresh-started'); $this->dispatch('refresh-started');
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$articles = Article::with(['feed', 'articlePublication']) $articles = Article::with(['feed', 'articlePublication'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')

View file

@ -51,7 +51,7 @@ public function detachAccount(int $channelId, int $accountId): void
$channel->platformAccounts()->detach($accountId); $channel->platformAccounts()->detach($accountId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get(); $channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
$allAccounts = PlatformAccount::where('is_active', true)->get(); $allAccounts = PlatformAccount::where('is_active', true)->get();

View file

@ -19,7 +19,7 @@ public function setPeriod(string $period): void
$this->period = $period; $this->period = $period;
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$service = app(DashboardStatsService::class); $service = app(DashboardStatsService::class);

View file

@ -14,7 +14,7 @@ public function toggle(int $feedId): void
$feed->save(); $feed->save();
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$feeds = Feed::orderBy('name')->get(); $feeds = Feed::orderBy('name')->get();

View file

@ -29,36 +29,54 @@ class Onboarding extends Component
// Platform form // Platform form
public string $instanceUrl = ''; public string $instanceUrl = '';
public string $username = ''; public string $username = '';
public string $password = ''; public string $password = '';
/** @var array<string, mixed>|null */
public ?array $existingAccount = null; public ?array $existingAccount = null;
// Feed form // Feed form
public string $feedName = ''; public string $feedName = '';
public string $feedProvider = 'vrt'; public string $feedProvider = 'vrt';
public ?int $feedLanguageId = null; public ?int $feedLanguageId = null;
public string $feedDescription = ''; public string $feedDescription = '';
// Channel form // Channel form
public string $channelName = ''; public string $channelName = '';
public ?int $platformInstanceId = null; public ?int $platformInstanceId = null;
public ?int $channelLanguageId = null; public ?int $channelLanguageId = null;
public string $channelDescription = ''; public string $channelDescription = '';
// Route form // Route form
public ?int $routeFeedId = null; public ?int $routeFeedId = null;
public ?int $routeChannelId = null; public ?int $routeChannelId = null;
public int $routePriority = 50; public int $routePriority = 50;
// State // State
/** @var array<string, string> */
public array $formErrors = []; public array $formErrors = [];
public bool $isLoading = false; public bool $isLoading = false;
#[\Livewire\Attributes\Locked] #[\Livewire\Attributes\Locked]
public ?int $previousChannelLanguageId = null; public ?int $previousChannelLanguageId = null;
protected CreatePlatformAccountAction $createPlatformAccountAction; protected CreatePlatformAccountAction $createPlatformAccountAction;
protected CreateFeedAction $createFeedAction; protected CreateFeedAction $createFeedAction;
protected CreateChannelAction $createChannelAction; protected CreateChannelAction $createChannelAction;
protected CreateRouteAction $createRouteAction; protected CreateRouteAction $createRouteAction;
public function boot( public function boot(
@ -319,6 +337,9 @@ public function completeOnboarding(): void
/** /**
* Get language codes that have at least one active provider. * Get language codes that have at least one active provider.
*/ */
/**
* @return list<string>
*/
public function getAvailableLanguageCodes(): array public function getAvailableLanguageCodes(): array
{ {
$providers = config('feed.providers', []); $providers = config('feed.providers', []);
@ -329,7 +350,7 @@ public function getAvailableLanguageCodes(): array
continue; continue;
} }
foreach (array_keys($provider['languages'] ?? []) as $code) { foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true; $languageCodes[(string) $code] = true;
} }
} }
@ -339,6 +360,9 @@ public function getAvailableLanguageCodes(): array
/** /**
* Get providers available for the current channel language. * Get providers available for the current channel language.
*/ */
/**
* @return array<int, array<string, string>>
*/
public function getProvidersForLanguage(): array public function getProvidersForLanguage(): array
{ {
if (! $this->channelLanguageId) { if (! $this->channelLanguageId) {
@ -378,10 +402,11 @@ public function getChannelLanguage(): ?Language
if (! $this->channelLanguageId) { if (! $this->channelLanguageId) {
return null; return null;
} }
return Language::find($this->channelLanguageId); return Language::find($this->channelLanguageId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
// For channel step: only show languages that have providers // For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes(); $availableCodes = $this->getAvailableLanguageCodes();

View file

@ -11,12 +11,16 @@
class Routes extends Component class Routes extends Component
{ {
public bool $showCreateModal = false; public bool $showCreateModal = false;
public ?int $editingFeedId = null; public ?int $editingFeedId = null;
public ?int $editingChannelId = null; public ?int $editingChannelId = null;
// Create form // Create form
public ?int $newFeedId = null; public ?int $newFeedId = null;
public ?int $newChannelId = null; public ?int $newChannelId = null;
public int $newPriority = 50; public int $newPriority = 50;
// Edit form // Edit form
@ -24,6 +28,7 @@ class Routes extends Component
// Keyword management // Keyword management
public string $newKeyword = ''; public string $newKeyword = '';
public bool $showKeywordInput = false; public bool $showKeywordInput = false;
public function openCreateModal(): void public function openCreateModal(): void
@ -53,6 +58,7 @@ public function createRoute(): void
if ($exists) { if ($exists) {
$this->addError('newFeedId', 'This route already exists.'); $this->addError('newFeedId', 'This route already exists.');
return; return;
} }
@ -153,9 +159,9 @@ public function deleteKeyword(int $keywordId): void
Keyword::destroy($keywordId); Keyword::destroy($keywordId);
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
$routes = Route::with(['feed', 'platformChannel']) $routes = Route::with(['feed', 'platformChannel.platformInstance'])
->orderBy('priority', 'desc') ->orderBy('priority', 'desc')
->get(); ->get();
@ -168,7 +174,8 @@ public function render()
$routes = $routes->map(function ($route) use ($allKeywords) { $routes = $routes->map(function ($route) use ($allKeywords) {
$key = $route->feed_id.'-'.$route->platform_channel_id; $key = $route->feed_id.'-'.$route->platform_channel_id;
$route->keywords = $allKeywords->get($key, collect()); $route->setRelation('keywords', $allKeywords->get($key, collect()));
return $route; return $route;
}); });
@ -179,7 +186,7 @@ public function render()
$editingKeywords = collect(); $editingKeywords = collect();
if ($this->editingFeedId && $this->editingChannelId) { if ($this->editingFeedId && $this->editingChannelId) {
$editingRoute = Route::with(['feed', 'platformChannel']) $editingRoute = Route::with(['feed', 'platformChannel.platformInstance'])
->where('feed_id', $this->editingFeedId) ->where('feed_id', $this->editingFeedId)
->where('platform_channel_id', $this->editingChannelId) ->where('platform_channel_id', $this->editingChannelId)
->first(); ->first();

View file

@ -8,10 +8,13 @@
class Settings extends Component class Settings extends Component
{ {
public bool $articleProcessingEnabled = true; public bool $articleProcessingEnabled = true;
public bool $publishingApprovalsEnabled = false; public bool $publishingApprovalsEnabled = false;
public int $articlePublishingInterval = 5; public int $articlePublishingInterval = 5;
public ?string $successMessage = null; public ?string $successMessage = null;
public ?string $errorMessage = null; public ?string $errorMessage = null;
public function mount(): void public function mount(): void
@ -60,7 +63,7 @@ public function clearMessages(): void
$this->errorMessage = null; $this->errorMessage = null;
} }
public function render() public function render(): \Illuminate\Contracts\View\View
{ {
return view('livewire.settings')->layout('layouts.app'); return view('livewire.settings')->layout('layouts.app');
} }

View file

@ -15,15 +15,20 @@
* @method static firstOrCreate(array<string, mixed> $array) * @method static firstOrCreate(array<string, mixed> $array)
* @method static where(string $string, string $url) * @method static where(string $string, string $url)
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
* @property integer $id *
* @property int $id
* @property int $feed_id * @property int $feed_id
* @property Feed $feed * @property Feed $feed
* @property string $url * @property string $url
* @property string $title
* @property string|null $description
* @property string $approval_status
* @property string $publish_status
* @property bool|null $is_valid * @property bool|null $is_valid
* @property Carbon|null $validated_at * @property Carbon|null $validated_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property ArticlePublication $articlePublication * @property ArticlePublication|null $articlePublication
*/ */
class Article extends Model class Article extends Model
{ {
@ -79,7 +84,7 @@ public function isRejected(): bool
return $this->approval_status === 'rejected'; return $this->approval_status === 'rejected';
} }
public function approve(string $approvedBy = null): void public function approve(?string $approvedBy = null): void
{ {
$this->update([ $this->update([
'approval_status' => 'approved', 'approval_status' => 'approved',
@ -89,7 +94,7 @@ public function approve(string $approvedBy = null): void
event(new ArticleApproved($this)); event(new ArticleApproved($this));
} }
public function reject(string $rejectedBy = null): void public function reject(?string $rejectedBy = null): void
{ {
$this->update([ $this->update([
'approval_status' => 'rejected', 'approval_status' => 'rejected',

View file

@ -8,9 +8,16 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property integer $article_id * @property int $id
* @property integer $platform_channel_id * @property int $article_id
* @property integer $post_id * @property int $platform_channel_id
* @property string $post_id
* @property string $platform
* @property string $published_by
* @property array<string, mixed>|null $publication_data
* @property \Illuminate\Support\Carbon $published_at
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* *
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
*/ */

View file

@ -16,7 +16,7 @@
* @property string $url * @property string $url
* @property string $type * @property string $type
* @property string $provider * @property string $provider
* @property int $language_id * @property int|null $language_id
* @property Language|null $language * @property Language|null $language
* @property string $description * @property string $description
* @property array<string, mixed> $settings * @property array<string, mixed> $settings
@ -24,6 +24,7 @@
* @property Carbon|null $last_fetched_at * @property Carbon|null $last_fetched_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
*
* @method static orderBy(string $string, string $string1) * @method static orderBy(string $string, string $string1)
* @method static where(string $string, true $true) * @method static where(string $string, true $true)
* @method static findOrFail(mixed $feed_id) * @method static findOrFail(mixed $feed_id)
@ -32,7 +33,9 @@ class Feed extends Model
{ {
/** @use HasFactory<FeedFactory> */ /** @use HasFactory<FeedFactory> */
use HasFactory; use HasFactory;
private const RECENT_FETCH_THRESHOLD_HOURS = 2; private const RECENT_FETCH_THRESHOLD_HOURS = 2;
private const DAILY_FETCH_THRESHOLD_HOURS = 24; private const DAILY_FETCH_THRESHOLD_HOURS = 24;
protected $fillable = [ protected $fillable = [
@ -44,13 +47,13 @@ class Feed extends Model
'description', 'description',
'settings', 'settings',
'is_active', 'is_active',
'last_fetched_at' 'last_fetched_at',
]; ];
protected $casts = [ protected $casts = [
'settings' => 'array', 'settings' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'last_fetched_at' => 'datetime' 'last_fetched_at' => 'datetime',
]; ];
public function getTypeDisplayAttribute(): string public function getTypeDisplayAttribute(): string
@ -79,12 +82,12 @@ public function getStatusAttribute(): string
} elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) { } elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) {
return "Fetched {$hoursAgo}h ago"; return "Fetched {$hoursAgo}h ago";
} else { } else {
return "Fetched " . $this->last_fetched_at->diffForHumans(); return 'Fetched '.$this->last_fetched_at->diffForHumans();
} }
} }
/** /**
* @return BelongsToMany<PlatformChannel, $this, Route> * @return BelongsToMany<PlatformChannel, $this>
*/ */
public function channels(): BelongsToMany public function channels(): BelongsToMany
{ {
@ -94,7 +97,7 @@ public function channels(): BelongsToMany
} }
/** /**
* @return BelongsToMany<PlatformChannel, $this, Route> * @return BelongsToMany<PlatformChannel, $this>
*/ */
public function activeChannels(): BelongsToMany public function activeChannels(): BelongsToMany
{ {

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Database\Factories\KeywordFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -20,17 +21,18 @@
*/ */
class Keyword extends Model class Keyword extends Model
{ {
/** @use HasFactory<KeywordFactory> */
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',
'keyword', 'keyword',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**
@ -48,5 +50,4 @@ public function platformChannel(): BelongsTo
{ {
return $this->belongsTo(PlatformChannel::class); return $this->belongsTo(PlatformChannel::class);
} }
} }

View file

@ -17,11 +17,11 @@ class Language extends Model
'short_code', 'short_code',
'name', 'name',
'native_name', 'native_name',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -3,12 +3,14 @@
namespace App\Models; namespace App\Models;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use Database\Factories\LogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
* @method static create(array $array) * @method static create(array<string, mixed> $array)
*
* @property LogLevelEnum $level * @property LogLevelEnum $level
* @property string $message * @property string $message
* @property array<string, mixed> $context * @property array<string, mixed> $context
@ -17,6 +19,7 @@
*/ */
class Log extends Model class Log extends Model
{ {
/** @use HasFactory<LogFactory> */
use HasFactory; use HasFactory;
protected $table = 'logs'; protected $table = 'logs';

View file

@ -2,15 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Enums\PlatformEnum;
use Database\Factories\PlatformAccountFactory; use Database\Factories\PlatformAccountFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use App\Enums\PlatformEnum;
/** /**
* @property int $id * @property int $id
@ -18,13 +18,14 @@
* @property string $instance_url * @property string $instance_url
* @property string $username * @property string $username
* @property string $password * @property string $password
* @property string $settings * @property array<string, mixed> $settings
* @property bool $is_active * @property bool $is_active
* @property Carbon $last_tested_at * @property Carbon|null $last_tested_at
* @property string $status * @property string $status
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property Collection<int, PlatformChannel> $activeChannels * @property Collection<int, PlatformChannel> $activeChannels
*
* @method static where(string $string, PlatformEnum $platform) * @method static where(string $string, PlatformEnum $platform)
* @method static orderBy(string $string) * @method static orderBy(string $string)
* @method static create(array<string, mixed> $validated) * @method static create(array<string, mixed> $validated)
@ -42,14 +43,14 @@ class PlatformAccount extends Model
'settings', 'settings',
'is_active', 'is_active',
'last_tested_at', 'last_tested_at',
'status' 'status',
]; ];
protected $casts = [ protected $casts = [
'platform' => PlatformEnum::class, 'platform' => PlatformEnum::class,
'settings' => 'array', 'settings' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'last_tested_at' => 'datetime' 'last_tested_at' => 'datetime',
]; ];
// Encrypt password when storing // Encrypt password when storing
@ -93,7 +94,6 @@ protected function password(): Attribute
)->withoutObjectCaching(); )->withoutObjectCaching();
} }
// Get the active accounts for a platform (returns collection) // Get the active accounts for a platform (returns collection)
/** /**
* @return Collection<int, PlatformAccount> * @return Collection<int, PlatformAccount>

View file

@ -10,15 +10,16 @@
/** /**
* @method static findMany(mixed $channel_ids) * @method static findMany(mixed $channel_ids)
* @method static create(array $array) * @method static create(array<string, mixed> $array)
* @property integer $id *
* @property integer $platform_instance_id * @property int $id
* @property int $platform_instance_id
* @property PlatformInstance $platformInstance * @property PlatformInstance $platformInstance
* @property integer $channel_id * @property int $channel_id
* @property string $name * @property string $name
* @property int $language_id * @property int $language_id
* @property Language|null $language * @property Language|null $language
* @property boolean $is_active * @property bool $is_active
*/ */
class PlatformChannel extends Model class PlatformChannel extends Model
{ {
@ -34,11 +35,11 @@ class PlatformChannel extends Model
'channel_id', 'channel_id',
'description', 'description',
'language_id', 'language_id',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**
@ -74,7 +75,7 @@ public function getFullNameAttribute(): string
} }
/** /**
* @return BelongsToMany<Feed, $this, Route> * @return BelongsToMany<Feed, $this>
*/ */
public function feeds(): BelongsToMany public function feeds(): BelongsToMany
{ {
@ -84,7 +85,7 @@ public function feeds(): BelongsToMany
} }
/** /**
* @return BelongsToMany<Feed, $this, Route> * @return BelongsToMany<Feed, $this>
*/ */
public function activeFeeds(): BelongsToMany public function activeFeeds(): BelongsToMany
{ {

View file

@ -12,7 +12,9 @@
*/ */
class PlatformChannelPost extends Model class PlatformChannelPost extends Model
{ {
/** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<PlatformChannelPost>> */
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'platform', 'platform',
'channel_id', 'channel_id',

View file

@ -12,11 +12,12 @@
/** /**
* @method static updateOrCreate(array<string, mixed> $array, $instanceData) * @method static updateOrCreate(array<string, mixed> $array, $instanceData)
* @method static where(string $string, mixed $operator) * @method static where(string $string, mixed $operator)
*
* @property PlatformEnum $platform * @property PlatformEnum $platform
* @property string $url * @property string $url
* @property string $name * @property string $name
* @property string $description * @property string $description
* @property boolean $is_active * @property bool $is_active
*/ */
class PlatformInstance extends Model class PlatformInstance extends Model
{ {
@ -28,12 +29,12 @@ class PlatformInstance extends Model
'url', 'url',
'name', 'name',
'description', 'description',
'is_active' 'is_active',
]; ];
protected $casts = [ protected $casts = [
'platform' => PlatformEnum::class, 'platform' => PlatformEnum::class,
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -4,9 +4,9 @@
use Database\Factories\RouteFactory; use Database\Factories\RouteFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
@ -26,17 +26,18 @@ class Route extends Model
// Laravel doesn't handle composite primary keys well, so we'll use regular queries // Laravel doesn't handle composite primary keys well, so we'll use regular queries
protected $primaryKey = null; protected $primaryKey = null;
public $incrementing = false; public $incrementing = false;
protected $fillable = [ protected $fillable = [
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',
'is_active', 'is_active',
'priority' 'priority',
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean' 'is_active' => 'boolean',
]; ];
/** /**

View file

@ -2,16 +2,18 @@
namespace App\Models; namespace App\Models;
use Database\Factories\SettingFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* @method static updateOrCreate(string[] $array, array $array1) * @method static updateOrCreate(array<string, string> $array, array<string, mixed> $array1)
* @method static create(string[] $array) * @method static create(array<string, string> $array)
* @method static where(string $string, string $key) * @method static where(string $string, string $key)
*/ */
class Setting extends Model class Setting extends Model
{ {
/** @use HasFactory<SettingFactory> */
use HasFactory; use HasFactory;
protected $fillable = ['key', 'value']; protected $fillable = ['key', 'value'];

View file

@ -11,7 +11,7 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasApiTokens; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.

View file

@ -2,13 +2,15 @@
namespace App\Modules\Lemmy; namespace App\Modules\Lemmy;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class LemmyRequest class LemmyRequest
{ {
private string $instance; private string $instance;
private ?string $token; private ?string $token;
private string $scheme = 'https'; private string $scheme = 'https';
public function __construct(string $instance, ?string $token = null) public function __construct(string $instance, ?string $token = null)
@ -45,6 +47,7 @@ public function withScheme(string $scheme): self
if (in_array($scheme, ['http', 'https'], true)) { if (in_array($scheme, ['http', 'https'], true)) {
$this->scheme = $scheme; $this->scheme = $scheme;
} }
return $this; return $this;
} }
@ -83,6 +86,7 @@ public function post(string $endpoint, array $data = []): Response
public function withToken(string $token): self public function withToken(string $token): self
{ {
$this->token = $token; $this->token = $token;
return $this; return $this;
} }
} }

View file

@ -61,6 +61,7 @@ public function login(string $username, string $password): ?string
} }
$data = $response->json(); $data = $response->json();
return $data['jwt'] ?? null; return $data['jwt'] ?? null;
} catch (Exception $e) { } catch (Exception $e) {
// Re-throw rate limit exceptions immediately // Re-throw rate limit exceptions immediately
@ -93,6 +94,7 @@ public function getCommunityId(string $communityName, string $token): int
} }
$data = $response->json(); $data = $response->json();
return $data['community_view']['community']['id'] ?? throw new Exception('Community not found'); return $data['community_view']['community']['id'] ?? throw new Exception('Community not found');
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Community lookup failed', ['error' => $e->getMessage()]); logger()->error('Community lookup failed', ['error' => $e->getMessage()]);
@ -107,14 +109,15 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
$response = $request->get('post/list', [ $response = $request->get('post/list', [
'community_id' => $platformChannelId, 'community_id' => $platformChannelId,
'limit' => 50, 'limit' => 50,
'sort' => 'New' 'sort' => 'New',
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
logger()->warning('Failed to sync channel posts', [ logger()->warning('Failed to sync channel posts', [
'status' => $response->status(), 'status' => $response->status(),
'platform_channel_id' => $platformChannelId 'platform_channel_id' => $platformChannelId,
]); ]);
return; return;
} }
@ -137,13 +140,13 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
logger()->info('Synced channel posts', [ logger()->info('Synced channel posts', [
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $platformChannelId,
'posts_count' => count($posts) 'posts_count' => count($posts),
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Exception while syncing channel posts', [ logger()->error('Exception while syncing channel posts', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'platform_channel_id' => $platformChannelId 'platform_channel_id' => $platformChannelId,
]); ]);
} }
} }
@ -198,17 +201,20 @@ public function getLanguages(): array
if (! $response->successful()) { if (! $response->successful()) {
logger()->warning('Failed to fetch site languages', [ logger()->warning('Failed to fetch site languages', [
'status' => $response->status() 'status' => $response->status(),
]); ]);
return []; return [];
} }
$data = $response->json(); $data = $response->json();
return $data['all_languages'] ?? []; return $data['all_languages'] ?? [];
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Exception while fetching languages', [ logger()->error('Exception while fetching languages', [
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return []; return [];
} }
} }

View file

@ -12,6 +12,7 @@
class LemmyPublisher class LemmyPublisher
{ {
private LemmyApiService $api; private LemmyApiService $api;
private PlatformAccount $account; private PlatformAccount $account;
public function __construct(PlatformAccount $account) public function __construct(PlatformAccount $account)
@ -23,6 +24,7 @@ public function __construct(PlatformAccount $account)
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return array<string, mixed> * @return array<string, mixed>
*
* @throws PlatformAuthException * @throws PlatformAuthException
* @throws Exception * @throws Exception
*/ */
@ -37,6 +39,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
// If the cached token was stale, refresh and retry once // If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account); $token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article);
} }
throw $e; throw $e;
@ -65,5 +68,4 @@ private function createPost(string $token, array $extractedData, PlatformChannel
$languageId $languageId
); );
} }
} }

View file

@ -3,7 +3,9 @@
namespace App\Providers; namespace App\Providers;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Listeners\LogActionListener;
use App\Listeners\LogExceptionToDatabase; use App\Listeners\LogExceptionToDatabase;
use Error; use Error;
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
@ -14,12 +16,15 @@
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void {}
{
}
public function boot(): void public function boot(): void
{ {
Event::listen(
ActionPerformed::class,
LogActionListener::class,
);
Event::listen( Event::listen(
ExceptionOccurred::class, ExceptionOccurred::class,
LogExceptionToDatabase::class, LogExceptionToDatabase::class,

View file

@ -4,9 +4,9 @@
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Http\HttpFetcher;
use App\Services\Factories\ArticleParserFactory; use App\Services\Factories\ArticleParserFactory;
use App\Services\Factories\HomepageParserFactory; use App\Services\Factories\HomepageParserFactory;
use App\Services\Http\HttpFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -28,9 +28,9 @@ public function getArticlesFromFeed(Feed $feed): Collection
return $this->getArticlesFromWebsiteFeed($feed); return $this->getArticlesFromWebsiteFeed($feed);
} }
$this->logSaver->warning("Unsupported feed type", null, [ $this->logSaver->warning('Unsupported feed type', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_type' => $feed->type 'feed_type' => $feed->type,
]); ]);
return collect(); return collect();
@ -54,7 +54,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
} }
if ($rss === false || ! isset($rss->channel->item)) { if ($rss === false || ! isset($rss->channel->item)) {
$this->logSaver->warning("Failed to parse RSS feed XML", null, [ $this->logSaver->warning('Failed to parse RSS feed XML', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
]); ]);
@ -72,7 +72,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
return $articles; return $articles;
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error("Failed to fetch articles from RSS feed", null, [ $this->logSaver->error('Failed to fetch articles from RSS feed', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@ -92,9 +92,9 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
$parser = HomepageParserFactory::getParserForFeed($feed); $parser = HomepageParserFactory::getParserForFeed($feed);
if (! $parser) { if (! $parser) {
$this->logSaver->warning("No parser available for feed URL", null, [ $this->logSaver->warning('No parser available for feed URL', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url 'feed_url' => $feed->url,
]); ]);
return collect(); return collect();
@ -107,10 +107,10 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
->map(fn (string $url) => $this->saveArticle($url, $feed->id)); ->map(fn (string $url) => $this->saveArticle($url, $feed->id));
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error("Failed to fetch articles from website feed", null, [ $this->logSaver->error('Failed to fetch articles from website feed', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return collect(); return collect();
@ -130,7 +130,7 @@ public function fetchArticleData(Article $article): array
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->error('Exception while fetching article data', null, [ $this->logSaver->error('Exception while fetching article data', null, [
'url' => $article->url, 'url' => $article->url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return []; return [];
@ -156,7 +156,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article
return $article; return $article;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logSaver->error("Failed to create article", null, [ $this->logSaver->error('Failed to create article', null, [
'url' => $url, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),

View file

@ -28,7 +28,7 @@ public function validate(Article $article): Article
if (! isset($articleData['full_article']) || empty($articleData['full_article'])) { if (! isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url,
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
@ -67,7 +67,7 @@ private function validateByKeywords(string $full_article): bool
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
]; ];
foreach ($keywords as $keyword) { foreach ($keywords as $keyword) {

View file

@ -52,8 +52,12 @@ public function refreshToken(PlatformAccount $account): string
/** /**
* Authenticate with Lemmy API and return user data with JWT * Authenticate with Lemmy API and return user data with JWT
*
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
/**
* @return array<string, mixed>
*/
public function authenticate(string $instanceUrl, string $username, string $password): array public function authenticate(string $instanceUrl, string $username, string $password): array
{ {
try { try {
@ -75,8 +79,8 @@ public function authenticate(string $instanceUrl, string $username, string $pass
'id' => 0, // Would need API call to get actual user info 'id' => 0, // Would need API call to get actual user info
'display_name' => null, 'display_name' => null,
'bio' => null, 'bio' => null,
] ],
] ],
]; ];
} catch (PlatformAuthException $e) { } catch (PlatformAuthException $e) {
// Re-throw PlatformAuthExceptions as-is to avoid nesting // Re-throw PlatformAuthExceptions as-is to avoid nesting

View file

@ -9,10 +9,12 @@
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route; use App\Models\Route;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class DashboardStatsService class DashboardStatsService
{ {
/**
* @return array<string, mixed>
*/
public function getStats(string $period = 'today'): array public function getStats(string $period = 'today'): array
{ {
$dateRange = $this->getDateRange($period); $dateRange = $this->getDateRange($period);
@ -73,6 +75,9 @@ private function getDateRange(string $period): ?array
}; };
} }
/**
* @return array<string, int>
*/
public function getSystemStats(): array public function getSystemStats(): array
{ {
$totalFeeds = Feed::query()->count(); $totalFeeds = Feed::query()->count();

View file

@ -4,9 +4,9 @@
use App\Contracts\ArticleParserInterface; use App\Contracts\ArticleParserInterface;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Parsers\VrtArticleParser;
use App\Services\Parsers\BelgaArticleParser; use App\Services\Parsers\BelgaArticleParser;
use App\Services\Parsers\GuardianArticleParser; use App\Services\Parsers\GuardianArticleParser;
use App\Services\Parsers\VrtArticleParser;
use Exception; use Exception;
class ArticleParserFactory class ArticleParserFactory
@ -26,7 +26,7 @@ class ArticleParserFactory
public static function getParser(string $url): ArticleParserInterface public static function getParser(string $url): ArticleParserInterface
{ {
foreach (self::$parsers as $parserClass) { foreach (self::$parsers as $parserClass) {
$parser = new $parserClass(); $parser = new $parserClass;
if ($parser->canParse($url)) { if ($parser->canParse($url)) {
return $parser; return $parser;
@ -47,12 +47,13 @@ public static function getParserForFeed(Feed $feed, string $parserType = 'articl
return null; return null;
} }
/** @var class-string<ArticleParserInterface> $parserClass */
$parserClass = $providerConfig['parsers'][$parserType]; $parserClass = $providerConfig['parsers'][$parserType];
if (! class_exists($parserClass)) { if (! class_exists($parserClass)) {
return null; return null;
} }
return new $parserClass(); return new $parserClass;
} }
/** /**
@ -61,7 +62,8 @@ public static function getParserForFeed(Feed $feed, string $parserType = 'articl
public static function getSupportedSources(): array public static function getSupportedSources(): array
{ {
return array_map(function ($parserClass) { return array_map(function ($parserClass) {
$parser = new $parserClass(); $parser = new $parserClass;
return $parser->getSourceName(); return $parser->getSourceName();
}, self::$parsers); }, self::$parsers);
} }

View file

@ -18,12 +18,13 @@ public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
return null; return null;
} }
/** @var class-string<HomepageParserInterface> $parserClass */
$parserClass = $providerConfig['parsers']['homepage']; $parserClass = $providerConfig['parsers']['homepage'];
if (! class_exists($parserClass)) { if (! class_exists($parserClass)) {
return null; return null;
} }
$language = $feed->language?->short_code ?? 'en'; $language = $feed->language->short_code ?? 'en';
return new $parserClass($language); return new $parserClass($language);
} }

View file

@ -2,8 +2,8 @@
namespace App\Services\Http; namespace App\Services\Http;
use Illuminate\Support\Facades\Http;
use Exception; use Exception;
use Illuminate\Support\Facades\Http;
class HttpFetcher class HttpFetcher
{ {
@ -23,7 +23,7 @@ public static function fetchHtml(string $url): string
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('HTTP fetch failed', [ logger()->error('HTTP fetch failed', [
'url' => $url, 'url' => $url,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
throw $e; throw $e;
@ -48,20 +48,20 @@ public static function fetchMultipleUrls(array $urls): array
->reject(fn ($response, $index) => $response instanceof Exception) ->reject(fn ($response, $index) => $response instanceof Exception)
->map(function ($response, $index) use ($urls) { ->map(function ($response, $index) use ($urls) {
$url = $urls[$index]; $url = $urls[$index];
/** @var \Illuminate\Http\Client\Response $response */
try { try {
if ($response->successful()) { if ($response->successful()) {
return [ return [
'url' => $url, 'url' => $url,
'html' => $response->body(), 'html' => $response->body(),
'success' => true 'success' => true,
]; ];
} else { } else {
return [ return [
'url' => $url, 'url' => $url,
'html' => null, 'html' => null,
'success' => false, 'success' => false,
'status' => $response->status() 'status' => $response->status(),
]; ];
} }
} catch (Exception) { } catch (Exception) {
@ -69,11 +69,10 @@ public static function fetchMultipleUrls(array $urls): array
'url' => $url, 'url' => $url,
'html' => null, 'html' => null,
'success' => false, 'success' => false,
'error' => 'Exception occurred' 'error' => 'Exception occurred',
]; ];
} }
}) })
->filter(fn($result) => $result !== null)
->toArray(); ->toArray();
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]); logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]);

View file

@ -43,7 +43,7 @@ public function debug(string $message, ?PlatformChannel $channel = null, array $
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void public function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
{ {
$logContext = $context; $logContext = $context;

View file

@ -1,48 +0,0 @@
<?php
namespace App\Services\Parsers;
class BelgaHomepageParser
{
/**
* @return array<int, string>
*/
public static function extractArticleUrls(string $html): array
{
// Find all relative article links (most articles use relative paths)
preg_match_all('/<a[^>]+href="(\/[a-z0-9-]+)"/', $html, $matches);
// Blacklist of non-article paths
$blacklistPaths = [
'/',
'/de',
'/feed',
'/search',
'/category',
'/about',
'/contact',
'/privacy',
'/terms',
];
$urls = collect($matches[1])
->unique()
->filter(function ($path) use ($blacklistPaths) {
// Exclude exact matches and paths starting with blacklisted paths
foreach ($blacklistPaths as $blacklistedPath) {
if ($path === $blacklistedPath || str_starts_with($path, $blacklistedPath . '/')) {
return false;
}
}
return true;
})
->map(function ($path) {
// Convert relative paths to absolute URLs
return 'https://www.belganewsagency.eu' . $path;
})
->values()
->toArray();
return $urls;
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace App\Services\Parsers;
use App\Contracts\HomepageParserInterface;
class BelgaHomepageParserAdapter implements HomepageParserInterface
{
public function __construct(
private string $language = 'en',
) {}
public function canParse(string $url): bool
{
return str_contains($url, 'belganewsagency.eu');
}
public function extractArticleUrls(string $html): array
{
return BelgaHomepageParser::extractArticleUrls($html);
}
public function getHomepageUrl(): string
{
return 'https://www.belganewsagency.eu/';
}
public function getSourceName(): string
{
return 'Belga News Agency';
}
}

View file

@ -12,15 +12,13 @@
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use RuntimeException; use RuntimeException;
class ArticlePublishingService class ArticlePublishingService
{ {
public function __construct(private LogSaver $logSaver) public function __construct(private LogSaver $logSaver) {}
{
}
/** /**
* Factory seam to create publisher instances (helps testing without network calls) * Factory seam to create publisher instances (helps testing without network calls)
*/ */
@ -28,9 +26,11 @@ protected function makePublisher(mixed $account): LemmyPublisher
{ {
return new LemmyPublisher($account); return new LemmyPublisher($account);
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication> * @return Collection<int, ArticlePublication>
*
* @throws PublishException * @throws PublishException
*/ */
public function publishToRoutedChannels(Article $article, array $extractedData): Collection public function publishToRoutedChannels(Article $article, array $extractedData): Collection
@ -60,7 +60,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
if (! $account) { if (! $account) {
$this->logSaver->warning('No active account for channel', $channel, [ $this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'route_priority' => $route->priority 'route_priority' => $route->priority,
]); ]);
return null; return null;
@ -73,6 +73,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
/** /**
* Check if a route matches an article based on keywords * Check if a route matches an article based on keywords
*
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */
private function routeMatchesArticle(Route $route, array $extractedData): bool private function routeMatchesArticle(Route $route, array $extractedData): bool
@ -145,14 +146,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
]); ]);
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
'article_id' => $article->id 'article_id' => $article->id,
]); ]);
return $publication; return $publication;
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->warning('Failed to publish to channel', $channel, [ $this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
return null; return null;

View file

@ -11,6 +11,7 @@ class RoutingValidationService
{ {
/** /**
* @param Collection<int, PlatformChannel> $channels * @param Collection<int, PlatformChannel> $channels
*
* @throws RoutingMismatchException * @throws RoutingMismatchException
*/ */
public function validateLanguageCompatibility(Feed $feed, Collection $channels): void public function validateLanguageCompatibility(Feed $feed, Collection $channels): void

View file

@ -3,8 +3,8 @@
namespace App\Services; namespace App\Services;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Route;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\Setting; use App\Models\Setting;
class SystemStatusService class SystemStatusService

View file

@ -29,6 +29,7 @@
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.5.3" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {

View file

@ -33,13 +33,12 @@
'code' => 'belga', 'code' => 'belga',
'name' => 'Belga News Agency', 'name' => 'Belga News Agency',
'description' => 'Belgian national news agency', 'description' => 'Belgian national news agency',
'type' => 'website', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
'languages' => [ 'languages' => [
'en' => ['url' => 'https://www.belganewsagency.eu/'], 'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
], ],
'parsers' => [ 'parsers' => [
'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class,
'article' => \App\Services\Parsers\BelgaArticleParser::class, 'article' => \App\Services\Parsers\BelgaArticleParser::class,
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class, 'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
], ],

View file

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\ArticlePublication;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View file

@ -75,7 +75,8 @@ public function belga(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'provider' => 'belga', 'provider' => 'belga',
'url' => 'https://www.belganewsagency.eu/', 'url' => 'https://www.belganewsagency.eu/feed',
'type' => 'rss',
]); ]);
} }
} }

View file

@ -34,7 +34,7 @@ public function inactive(): static
]); ]);
} }
public function community(string $name = null): static public function community(?string $name = null): static
{ {
$communityName = $name ?: $this->faker->word(); $communityName = $name ?: $this->faker->word();

View file

@ -2,9 +2,9 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Route;
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
class RouteFactory extends Factory class RouteFactory extends Factory

View file

@ -17,8 +17,7 @@ public function run(): void
'name' => 'Belgae Social', 'name' => 'Belgae Social',
'description' => 'A Belgian Lemmy instance on the fediverse', 'description' => 'A Belgian Lemmy instance on the fediverse',
], ],
])->each (fn ($instanceData) => ])->each(fn ($instanceData) => PlatformInstance::updateOrCreate(
PlatformInstance::updateOrCreate(
[ [
'platform' => $instanceData['platform'], 'platform' => $instanceData['platform'],
'url' => $instanceData['url'], 'url' => $instanceData['url'],

View file

@ -3,7 +3,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class SettingsSeeder extends Seeder class SettingsSeeder extends Seeder

109
phpstan-baseline.neon Normal file
View file

@ -0,0 +1,109 @@
parameters:
ignoreErrors:
-
message: '#^Call to an undefined static method App\\Models\\Feed\:\:withTrashed\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: tests/Feature/DatabaseIntegrationTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformAccount\:\:\$pivot\.$#'
identifier: property.notFound
count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 2
path: tests/Unit/Actions/CreateFeedActionTest.php
-
message: '#^Access to an undefined property App\\Models\\Route\:\:\$id\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Actions/CreateRouteActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertFalse\(\) with false will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\LogLevelEnum\:\:DEBUG and App\\Enums\\LogLevelEnum\:\:DEBUG will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\LogLevelEnum\:\:DEBUG and App\\Enums\\LogLevelEnum\:\:INFO will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: tests/Unit/Enums/LogLevelEnumTest.php
-
message: '#^Strict comparison using \=\=\= between App\\Enums\\PlatformEnum\:\:LEMMY and App\\Enums\\PlatformEnum\:\:LEMMY will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: tests/Unit/Enums/PlatformEnumTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Models/FeedTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
identifier: property.notFound
count: 6
path: tests/Unit/Models/PlatformAccountTest.php
-
message: '#^Access to an undefined property App\\Models\\Feed\:\:\$pivot\.$#'
identifier: property.notFound
count: 2
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformAccount\:\:\$pivot\.$#'
identifier: property.notFound
count: 6
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/PlatformChannelTest.php
-
message: '#^Access to an undefined property App\\Models\\Language\:\:\$pivot\.$#'
identifier: property.notFound
count: 7
path: tests/Unit/Models/PlatformInstanceTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/PlatformInstanceTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Models/RouteTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Services/ArticleFetcherTest.php

View file

@ -1,5 +1,7 @@
includes: includes:
- vendor/larastan/larastan/extension.neon - vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-mockery/extension.neon
- phpstan-baseline.neon
parameters: parameters:
level: 7 level: 7
@ -10,3 +12,7 @@ parameters:
excludePaths: excludePaths:
- bootstrap/*.php - bootstrap/*.php
- storage/* - storage/*
ignoreErrors:
- identifier: method.alreadyNarrowedType
- identifier: function.alreadyNarrowedType

View file

@ -47,7 +47,7 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
<span>&bull;</span> <span>&bull;</span>
<span>Feed: {{ $route->feed?->name }}</span> <span>Feed: {{ $route->feed?->name }}</span>
<span>&bull;</span> <span>&bull;</span>
<span>Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span> <span>{{ $route->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span>
<span>&bull;</span> <span>&bull;</span>
<span>Created: {{ $route->created_at->format('M d, Y') }}</span> <span>Created: {{ $route->created_at->format('M d, Y') }}</span>
</div> </div>
@ -246,7 +246,7 @@ class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transp
<strong>Feed:</strong> {{ $editingRoute->feed?->name }} <strong>Feed:</strong> {{ $editingRoute->feed?->name }}
</p> </p>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
<strong>Channel:</strong> {{ $editingRoute->platformChannel?->display_name ?? $editingRoute->platformChannel?->name }} <strong>{{ $editingRoute->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}:</strong> {{ $editingRoute->platformChannel?->display_name ?? $editingRoute->platformChannel?->name }}
</p> </p>
</div> </div>

View file

@ -4,12 +4,12 @@
use App\Http\Controllers\Api\V1\AuthController; use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\FeedsController; use App\Http\Controllers\Api\V1\FeedsController;
use App\Http\Controllers\Api\V1\KeywordsController;
use App\Http\Controllers\Api\V1\LogsController; use App\Http\Controllers\Api\V1\LogsController;
use App\Http\Controllers\Api\V1\OnboardingController; use App\Http\Controllers\Api\V1\OnboardingController;
use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformAccountsController;
use App\Http\Controllers\Api\V1\PlatformChannelsController; use App\Http\Controllers\Api\V1\PlatformChannelsController;
use App\Http\Controllers\Api\V1\RoutingController; use App\Http\Controllers\Api\V1\RoutingController;
use App\Http\Controllers\Api\V1\KeywordsController;
use App\Http\Controllers\Api\V1\SettingsController; use App\Http\Controllers\Api\V1\SettingsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;

View file

@ -2,11 +2,6 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -31,7 +26,7 @@ public function test_api_routes_are_publicly_accessible(): void
'/api/v1/feeds', '/api/v1/feeds',
'/api/v1/routing', '/api/v1/routing',
'/api/v1/settings', '/api/v1/settings',
'/api/v1/logs' '/api/v1/logs',
]; ];
foreach ($routes as $route) { foreach ($routes as $route) {
@ -49,7 +44,7 @@ public function test_fallback_route_returns_api_message(): void
$response->assertStatus(404); $response->assertStatus(404);
$response->assertJson([ $response->assertJson([
'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.', 'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.',
'api_base' => '/api/v1' 'api_base' => '/api/v1',
]); ]);
} }
} }

View file

@ -27,12 +27,12 @@ public function test_user_model_creates_successfully(): void
{ {
$user = User::factory()->create([ $user = User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com' 'email' => 'test@example.com',
]); ]);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com' 'email' => 'test@example.com',
]); ]);
$this->assertEquals('Test User', $user->name); $this->assertEquals('Test User', $user->name);
@ -43,38 +43,40 @@ public function test_language_model_creates_successfully(): void
{ {
$language = Language::factory()->create([ $language = Language::factory()->create([
'name' => 'English', 'name' => 'English',
'short_code' => 'en' 'short_code' => 'en',
]); ]);
$this->assertDatabaseHas('languages', [ $this->assertDatabaseHas('languages', [
'name' => 'English', 'name' => 'English',
'short_code' => 'en' 'short_code' => 'en',
]); ]);
} }
public function test_platform_instance_model_creates_successfully(): void public function test_platform_instance_model_creates_successfully(): void
{ {
/** @var PlatformInstance $instance */
$instance = PlatformInstance::factory()->create([ $instance = PlatformInstance::factory()->create([
'name' => 'Test Instance', 'name' => 'Test Instance',
'url' => 'https://test.lemmy.world' 'url' => 'https://test.lemmy.world',
]); ]);
$this->assertDatabaseHas('platform_instances', [ $this->assertDatabaseHas('platform_instances', [
'name' => 'Test Instance', 'name' => 'Test Instance',
'url' => 'https://test.lemmy.world' 'url' => 'https://test.lemmy.world',
]); ]);
} }
public function test_platform_account_model_creates_successfully(): void public function test_platform_account_model_creates_successfully(): void
{ {
/** @var PlatformAccount $account */
$account = PlatformAccount::factory()->create([ $account = PlatformAccount::factory()->create([
'username' => 'testuser', 'username' => 'testuser',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
'username' => 'testuser', 'username' => 'testuser',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertEquals('testuser', $account->username); $this->assertEquals('testuser', $account->username);
@ -89,14 +91,14 @@ public function test_platform_channel_model_creates_successfully(): void
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id, 'language_id' => $language->id,
'name' => 'Test Channel', 'name' => 'Test Channel',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertDatabaseHas('platform_channels', [ $this->assertDatabaseHas('platform_channels', [
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id, 'language_id' => $language->id,
'name' => 'Test Channel', 'name' => 'Test Channel',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertEquals($instance->id, $channel->platformInstance->id); $this->assertEquals($instance->id, $channel->platformInstance->id);
@ -111,14 +113,14 @@ public function test_feed_model_creates_successfully(): void
'language_id' => $language->id, 'language_id' => $language->id,
'name' => 'Test Feed', 'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
'language_id' => $language->id, 'language_id' => $language->id,
'name' => 'Test Feed', 'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertEquals($language->id, $feed->language->id); $this->assertEquals($language->id, $feed->language->id);
@ -132,14 +134,14 @@ public function test_article_model_creates_successfully(): void
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'title' => 'Test Article', 'title' => 'Test Article',
'url' => 'https://example.com/article', 'url' => 'https://example.com/article',
'approval_status' => 'pending' 'approval_status' => 'pending',
]); ]);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'title' => 'Test Article', 'title' => 'Test Article',
'url' => 'https://example.com/article', 'url' => 'https://example.com/article',
'approval_status' => 'pending' 'approval_status' => 'pending',
]); ]);
$this->assertEquals($feed->id, $article->feed->id); $this->assertEquals($feed->id, $article->feed->id);
@ -155,14 +157,14 @@ public function test_article_publication_model_creates_successfully(): void
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'post_id' => 'test-post-123', 'post_id' => 'test-post-123',
'published_at' => now(), 'published_at' => now(),
'published_by' => 'test-user' 'published_by' => 'test-user',
]); ]);
$this->assertDatabaseHas('article_publications', [ $this->assertDatabaseHas('article_publications', [
'article_id' => $article->id, 'article_id' => $article->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'post_id' => 'test-post-123', 'post_id' => 'test-post-123',
'published_by' => 'test-user' 'published_by' => 'test-user',
]); ]);
$this->assertEquals($article->id, $publication->article->id); $this->assertEquals($article->id, $publication->article->id);
@ -174,16 +176,17 @@ public function test_route_model_creates_successfully(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create(); $channel = PlatformChannel::factory()->create();
/** @var Route $route */
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
$this->assertDatabaseHas('routes', [ $this->assertDatabaseHas('routes', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
$this->assertEquals($feed->id, $route->feed->id); $this->assertEquals($feed->id, $route->feed->id);
@ -228,14 +231,14 @@ public function test_keyword_model_creates_successfully(): void
->forChannel($channel) ->forChannel($channel)
->create([ ->create([
'keyword' => 'test keyword', 'keyword' => 'test keyword',
'is_active' => true 'is_active' => true,
]); ]);
$this->assertDatabaseHas('keywords', [ $this->assertDatabaseHas('keywords', [
'keyword' => 'test keyword', 'keyword' => 'test keyword',
'is_active' => true, 'is_active' => true,
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id 'platform_channel_id' => $channel->id,
]); ]);
} }
@ -245,12 +248,12 @@ public function test_log_model_creates_successfully(): void
'level' => 'info', 'level' => 'info',
'message' => 'Test log message', 'message' => 'Test log message',
'context' => json_encode(['key' => 'value']), 'context' => json_encode(['key' => 'value']),
'logged_at' => now() 'logged_at' => now(),
]); ]);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => 'info', 'level' => 'info',
'message' => 'Test log message' 'message' => 'Test log message',
]); ]);
} }
@ -258,12 +261,12 @@ public function test_setting_model_creates_successfully(): void
{ {
$setting = Setting::create([ $setting = Setting::create([
'key' => 'test_setting', 'key' => 'test_setting',
'value' => 'test_value' 'value' => 'test_value',
]); ]);
$this->assertDatabaseHas('settings', [ $this->assertDatabaseHas('settings', [
'key' => 'test_setting', 'key' => 'test_setting',
'value' => 'test_value' 'value' => 'test_value',
]); ]);
} }
@ -290,7 +293,7 @@ public function test_platform_account_channels_many_to_many_relationship(): void
$this->assertDatabaseHas('platform_account_channels', [ $this->assertDatabaseHas('platform_account_channels', [
'platform_account_id' => $account->id, 'platform_account_id' => $account->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
} }

View file

@ -78,7 +78,7 @@ public function test_command_skips_when_article_processing_disabled(): void
Queue::fake(); Queue::fake();
Setting::create([ Setting::create([
'key' => 'article_processing_enabled', 'key' => 'article_processing_enabled',
'value' => '0' 'value' => '0',
]); ]);
// Act // Act

View file

@ -2,9 +2,7 @@
namespace Tests\Feature\Http\Console\Commands; namespace Tests\Feature\Http\Console\Commands;
use App\Jobs\SyncChannelPostsJob;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\PendingCommand; use Illuminate\Testing\PendingCommand;
use Tests\TestCase; use Tests\TestCase;
@ -36,6 +34,7 @@ public function test_command_returns_failure_exit_code_for_unsupported_platform(
public function test_command_accepts_lemmy_platform_argument(): void public function test_command_accepts_lemmy_platform_argument(): void
{ {
// Act - Test that the command accepts lemmy as a valid platform argument // Act - Test that the command accepts lemmy as a valid platform argument
/** @var PendingCommand $exitCode */
$exitCode = $this->artisan('channel:sync lemmy'); $exitCode = $this->artisan('channel:sync lemmy');
// Assert - Command should succeed (not fail with argument validation error) // Assert - Command should succeed (not fail with argument validation error)
@ -46,6 +45,7 @@ public function test_command_accepts_lemmy_platform_argument(): void
public function test_command_handles_default_platform(): void public function test_command_handles_default_platform(): void
{ {
// Act - Test that the command works with default platform (should be lemmy) // Act - Test that the command works with default platform (should be lemmy)
/** @var PendingCommand $exitCode */
$exitCode = $this->artisan('channel:sync'); $exitCode = $this->artisan('channel:sync');
// Assert - Command should succeed with default platform // Assert - Command should succeed with default platform

View file

@ -4,7 +4,6 @@
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -33,7 +32,7 @@ public function test_index_returns_successful_response(): void
'publishing_approvals_enabled', 'publishing_approvals_enabled',
], ],
], ],
'message' 'message',
]); ]);
} }
@ -53,7 +52,7 @@ public function test_index_returns_articles_with_pagination(): void
'total' => 25, 'total' => 25,
'last_page' => 3, 'last_page' => 3,
], ],
] ],
]); ]);
$this->assertCount(10, $response->json('data.articles')); $this->assertCount(10, $response->json('data.articles'));
@ -74,7 +73,7 @@ public function test_index_respects_per_page_limit(): void
'pagination' => [ 'pagination' => [
'per_page' => 100, // Should be capped at 100 'per_page' => 100, // Should be capped at 100
], ],
] ],
]); ]);
} }
@ -85,13 +84,13 @@ public function test_index_orders_articles_by_created_at_desc(): void
$firstArticle = Article::factory()->create([ $firstArticle = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'created_at' => now()->subHours(2), 'created_at' => now()->subHours(2),
'title' => 'First Article' 'title' => 'First Article',
]); ]);
$secondArticle = Article::factory()->create([ $secondArticle = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'created_at' => now()->subHour(), 'created_at' => now()->subHour(),
'title' => 'Second Article' 'title' => 'Second Article',
]); ]);
$response = $this->getJson('/api/v1/articles'); $response = $this->getJson('/api/v1/articles');
@ -108,7 +107,7 @@ public function test_approve_article_successfully(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending' 'approval_status' => 'pending',
]); ]);
$response = $this->postJson("/api/v1/articles/{$article->id}/approve"); $response = $this->postJson("/api/v1/articles/{$article->id}/approve");
@ -116,7 +115,7 @@ public function test_approve_article_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Article approved and queued for publishing.' 'message' => 'Article approved and queued for publishing.',
]); ]);
$article->refresh(); $article->refresh();
@ -135,7 +134,7 @@ public function test_reject_article_successfully(): void
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending' 'approval_status' => 'pending',
]); ]);
$response = $this->postJson("/api/v1/articles/{$article->id}/reject"); $response = $this->postJson("/api/v1/articles/{$article->id}/reject");
@ -143,7 +142,7 @@ public function test_reject_article_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Article rejected.' 'message' => 'Article rejected.',
]); ]);
$article->refresh(); $article->refresh();
@ -165,9 +164,9 @@ public function test_index_includes_settings(): void
->assertJsonStructure([ ->assertJsonStructure([
'data' => [ 'data' => [
'settings' => [ 'settings' => [
'publishing_approvals_enabled' 'publishing_approvals_enabled',
] ],
] ],
]); ]);
} }
} }

View file

@ -38,7 +38,7 @@ public function test_stats_returns_successful_response(): void
'available_periods', 'available_periods',
'current_period', 'current_period',
], ],
'message' 'message',
]); ]);
} }
@ -54,7 +54,7 @@ public function test_stats_with_different_periods(): void
'success' => true, 'success' => true,
'data' => [ 'data' => [
'current_period' => $period, 'current_period' => $period,
] ],
]); ]);
} }
} }
@ -80,7 +80,7 @@ public function test_stats_with_sample_data(): void
ArticlePublication::factory()->create([ ArticlePublication::factory()->create([
'article_id' => $articles->first()->id, 'article_id' => $articles->first()->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'published_at' => now() 'published_at' => now(),
]); ]);
$response = $this->getJson('/api/v1/dashboard/stats?period=all'); $response = $this->getJson('/api/v1/dashboard/stats?period=all');
@ -93,7 +93,7 @@ public function test_stats_with_sample_data(): void
'articles_fetched' => $initialArticles + 3, 'articles_fetched' => $initialArticles + 3,
'articles_published' => $initialPublications + 1, 'articles_published' => $initialPublications + 1,
], ],
] ],
]); ]);
// Just verify structure and that we have more items than we started with // Just verify structure and that we have more items than we started with
@ -126,7 +126,7 @@ public function test_stats_returns_empty_data_with_no_records(): void
'total_routes' => 0, 'total_routes' => 0,
'active_routes' => 0, 'active_routes' => 0,
], ],
] ],
]); ]);
} }
} }

View file

@ -20,9 +20,9 @@ public function test_index_returns_successful_response(): void
'success', 'success',
'data' => [ 'data' => [
'feeds', 'feeds',
'pagination' 'pagination',
], ],
'message' 'message',
]); ]);
} }
@ -69,7 +69,7 @@ public function test_store_creates_vrt_feed_successfully(): void
'url' => 'https://www.vrt.be/vrtnws/en/', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'website', 'type' => 'website',
'is_active' => true, 'is_active' => true,
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
@ -98,16 +98,16 @@ public function test_store_creates_belga_feed_successfully(): void
'message' => 'Feed created successfully!', 'message' => 'Feed created successfully!',
'data' => [ 'data' => [
'name' => 'Belga Test Feed', 'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/', 'url' => 'https://www.belganewsagency.eu/feed',
'type' => 'website', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
'name' => 'Belga Test Feed', 'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/', 'url' => 'https://www.belganewsagency.eu/feed',
'type' => 'website', 'type' => 'rss',
]); ]);
} }
@ -133,7 +133,7 @@ public function test_store_creates_guardian_feed_successfully(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
'type' => 'rss', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
@ -160,7 +160,7 @@ public function test_store_sets_default_active_status(): void
->assertJson([ ->assertJson([
'data' => [ 'data' => [
'is_active' => true, // Should default to true 'is_active' => true, // Should default to true
] ],
]); ]);
} }
@ -201,7 +201,7 @@ public function test_show_returns_feed_successfully(): void
'data' => [ 'data' => [
'id' => $feed->id, 'id' => $feed->id,
'name' => $feed->name, 'name' => $feed->name,
] ],
]); ]);
} }
@ -232,7 +232,7 @@ public function test_update_modifies_feed_successfully(): void
'message' => 'Feed updated successfully!', 'message' => 'Feed updated successfully!',
'data' => [ 'data' => [
'name' => 'Updated Name', 'name' => 'Updated Name',
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
@ -260,7 +260,7 @@ public function test_update_preserves_active_status_when_not_provided(): void
->assertJson([ ->assertJson([
'data' => [ 'data' => [
'is_active' => false, // Should preserve original value 'is_active' => false, // Should preserve original value
] ],
]); ]);
} }
@ -298,7 +298,7 @@ public function test_toggle_activates_inactive_feed(): void
'message' => 'Feed activated successfully!', 'message' => 'Feed activated successfully!',
'data' => [ 'data' => [
'is_active' => true, 'is_active' => true,
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
@ -319,7 +319,7 @@ public function test_toggle_deactivates_active_feed(): void
'message' => 'Feed deactivated successfully!', 'message' => 'Feed deactivated successfully!',
'data' => [ 'data' => [
'is_active' => false, 'is_active' => false,
] ],
]); ]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [

View file

@ -14,7 +14,9 @@ class KeywordsControllerTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
protected Feed $feed; protected Feed $feed;
protected PlatformChannel $channel; protected PlatformChannel $channel;
protected Route $route; protected Route $route;
protected function setUp(): void protected function setUp(): void
@ -28,7 +30,7 @@ protected function setUp(): void
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'is_active' => true, 'is_active' => true,
'priority' => 50 'priority' => 50,
]); ]);
} }
@ -38,7 +40,7 @@ public function test_can_get_keywords_for_route(): void
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'keyword' => 'test keyword', 'keyword' => 'test keyword',
'is_active' => true 'is_active' => true,
]); ]);
$response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords"); $response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords");
@ -52,9 +54,9 @@ public function test_can_get_keywords_for_route(): void
'keyword', 'keyword',
'is_active', 'is_active',
'feed_id', 'feed_id',
'platform_channel_id' 'platform_channel_id',
] ],
] ],
]) ])
->assertJsonPath('data.0.keyword', 'test keyword'); ->assertJsonPath('data.0.keyword', 'test keyword');
} }
@ -63,7 +65,7 @@ public function test_can_create_keyword_for_route(): void
{ {
$keywordData = [ $keywordData = [
'keyword' => 'new keyword', 'keyword' => 'new keyword',
'is_active' => true 'is_active' => true,
]; ];
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData); $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData);
@ -76,8 +78,8 @@ public function test_can_create_keyword_for_route(): void
'keyword', 'keyword',
'is_active', 'is_active',
'feed_id', 'feed_id',
'platform_channel_id' 'platform_channel_id',
] ],
]) ])
->assertJsonPath('data.keyword', 'new keyword') ->assertJsonPath('data.keyword', 'new keyword')
->assertJsonPath('data.is_active', true); ->assertJsonPath('data.is_active', true);
@ -86,7 +88,7 @@ public function test_can_create_keyword_for_route(): void
'keyword' => 'new keyword', 'keyword' => 'new keyword',
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'is_active' => true 'is_active' => true,
]); ]);
} }
@ -95,11 +97,11 @@ public function test_cannot_create_duplicate_keyword_for_route(): void
Keyword::factory()->create([ Keyword::factory()->create([
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'keyword' => 'duplicate keyword' 'keyword' => 'duplicate keyword',
]); ]);
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [ $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [
'keyword' => 'duplicate keyword' 'keyword' => 'duplicate keyword',
]); ]);
$response->assertStatus(409) $response->assertStatus(409)
@ -109,14 +111,15 @@ public function test_cannot_create_duplicate_keyword_for_route(): void
public function test_can_update_keyword(): void public function test_can_update_keyword(): void
{ {
/** @var Keyword $keyword */
$keyword = Keyword::factory()->create([ $keyword = Keyword::factory()->create([
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'is_active' => true 'is_active' => true,
]); ]);
$response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [ $response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [
'is_active' => false 'is_active' => false,
]); ]);
$response->assertStatus(200) $response->assertStatus(200)
@ -124,15 +127,16 @@ public function test_can_update_keyword(): void
$this->assertDatabaseHas('keywords', [ $this->assertDatabaseHas('keywords', [
'id' => $keyword->id, 'id' => $keyword->id,
'is_active' => false 'is_active' => false,
]); ]);
} }
public function test_can_delete_keyword(): void public function test_can_delete_keyword(): void
{ {
/** @var Keyword $keyword */
$keyword = Keyword::factory()->create([ $keyword = Keyword::factory()->create([
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id 'platform_channel_id' => $this->channel->id,
]); ]);
$response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}");
@ -140,16 +144,17 @@ public function test_can_delete_keyword(): void
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseMissing('keywords', [ $this->assertDatabaseMissing('keywords', [
'id' => $keyword->id 'id' => $keyword->id,
]); ]);
} }
public function test_can_toggle_keyword(): void public function test_can_toggle_keyword(): void
{ {
/** @var Keyword $keyword */
$keyword = Keyword::factory()->create([ $keyword = Keyword::factory()->create([
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
'platform_channel_id' => $this->channel->id, 'platform_channel_id' => $this->channel->id,
'is_active' => true 'is_active' => true,
]); ]);
$response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle"); $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle");
@ -159,7 +164,7 @@ public function test_can_toggle_keyword(): void
$this->assertDatabaseHas('keywords', [ $this->assertDatabaseHas('keywords', [
'id' => $keyword->id, 'id' => $keyword->id,
'is_active' => false 'is_active' => false,
]); ]);
} }
@ -168,9 +173,10 @@ public function test_cannot_access_keyword_from_different_route(): void
$otherFeed = Feed::factory()->create(); $otherFeed = Feed::factory()->create();
$otherChannel = PlatformChannel::factory()->create(); $otherChannel = PlatformChannel::factory()->create();
/** @var Keyword $keyword */
$keyword = Keyword::factory()->create([ $keyword = Keyword::factory()->create([
'feed_id' => $otherFeed->id, 'feed_id' => $otherFeed->id,
'platform_channel_id' => $otherChannel->id 'platform_channel_id' => $otherChannel->id,
]); ]);
$response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}");

View file

@ -37,7 +37,7 @@ public function test_index_returns_successful_response(): void
'context', 'context',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
], ],
'pagination' => [ 'pagination' => [
'current_page', 'current_page',
@ -46,12 +46,12 @@ public function test_index_returns_successful_response(): void
'total', 'total',
'from', 'from',
'to', 'to',
] ],
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Logs retrieved successfully.' 'message' => 'Logs retrieved successfully.',
]); ]);
} }
@ -147,8 +147,8 @@ public function test_index_handles_empty_logs(): void
'total' => 0, 'total' => 0,
'current_page' => 1, 'current_page' => 1,
'last_page' => 1, 'last_page' => 1,
] ],
] ],
]); ]);
} }

View file

@ -30,7 +30,7 @@ protected function setUp(): void
]); ]);
} }
public function test_status_shows_needs_onboarding_when_no_components_exist() public function test_status_shows_needs_onboarding_when_no_components_exist(): void
{ {
$response = $this->getJson('/api/v1/onboarding/status'); $response = $this->getJson('/api/v1/onboarding/status');
@ -49,7 +49,7 @@ public function test_status_shows_needs_onboarding_when_no_components_exist()
]); ]);
} }
public function test_status_shows_feed_step_when_platform_account_exists() public function test_status_shows_feed_step_when_platform_account_exists(): void
{ {
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
@ -69,7 +69,7 @@ public function test_status_shows_feed_step_when_platform_account_exists()
]); ]);
} }
public function test_status_shows_channel_step_when_platform_account_and_feed_exist() public function test_status_shows_channel_step_when_platform_account_and_feed_exist(): void
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
@ -91,7 +91,7 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex
]); ]);
} }
public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist() public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist(): void
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
@ -114,7 +114,7 @@ public function test_status_shows_route_step_when_platform_account_feed_and_chan
]); ]);
} }
public function test_status_shows_no_onboarding_needed_when_all_components_exist() public function test_status_shows_no_onboarding_needed_when_all_components_exist(): void
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
@ -138,7 +138,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
]); ]);
} }
public function test_status_shows_no_onboarding_needed_when_skipped() public function test_status_shows_no_onboarding_needed_when_skipped(): void
{ {
// No components exist but onboarding is skipped // No components exist but onboarding is skipped
Setting::create([ Setting::create([
@ -163,7 +163,7 @@ public function test_status_shows_no_onboarding_needed_when_skipped()
]); ]);
} }
public function test_options_returns_languages_and_platform_instances() public function test_options_returns_languages_and_platform_instances(): void
{ {
PlatformInstance::factory()->create([ PlatformInstance::factory()->create([
'platform' => 'lemmy', 'platform' => 'lemmy',
@ -179,34 +179,34 @@ public function test_options_returns_languages_and_platform_instances()
'success', 'success',
'data' => [ 'data' => [
'languages' => [ 'languages' => [
'*' => ['id', 'short_code', 'name', 'native_name', 'is_active'] '*' => ['id', 'short_code', 'name', 'native_name', 'is_active'],
], ],
'platform_instances' => [ 'platform_instances' => [
'*' => ['id', 'platform', 'url', 'name', 'description', 'is_active'] '*' => ['id', 'platform', 'url', 'name', 'description', 'is_active'],
] ],
] ],
]); ]);
} }
public function test_complete_onboarding_returns_success() public function test_complete_onboarding_returns_success(): void
{ {
$response = $this->postJson('/api/v1/onboarding/complete'); $response = $this->postJson('/api/v1/onboarding/complete');
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'data' => ['completed' => true] 'data' => ['completed' => true],
]); ]);
} }
public function test_skip_onboarding_creates_setting() public function test_skip_onboarding_creates_setting(): void
{ {
$response = $this->postJson('/api/v1/onboarding/skip'); $response = $this->postJson('/api/v1/onboarding/skip');
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'data' => ['skipped' => true] 'data' => ['skipped' => true],
]); ]);
$this->assertDatabaseHas('settings', [ $this->assertDatabaseHas('settings', [
@ -215,7 +215,7 @@ public function test_skip_onboarding_creates_setting()
]); ]);
} }
public function test_skip_onboarding_updates_existing_setting() public function test_skip_onboarding_updates_existing_setting(): void
{ {
// Create existing setting with false value // Create existing setting with false value
Setting::create([ Setting::create([
@ -236,7 +236,7 @@ public function test_skip_onboarding_updates_existing_setting()
$this->assertEquals(1, Setting::where('key', 'onboarding_skipped')->count()); $this->assertEquals(1, Setting::where('key', 'onboarding_skipped')->count());
} }
public function test_reset_skip_removes_setting() public function test_reset_skip_removes_setting(): void
{ {
// Create skipped setting // Create skipped setting
Setting::create([ Setting::create([
@ -249,7 +249,7 @@ public function test_reset_skip_removes_setting()
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'data' => ['reset' => true] 'data' => ['reset' => true],
]); ]);
$this->assertDatabaseMissing('settings', [ $this->assertDatabaseMissing('settings', [
@ -257,18 +257,18 @@ public function test_reset_skip_removes_setting()
]); ]);
} }
public function test_reset_skip_works_when_no_setting_exists() public function test_reset_skip_works_when_no_setting_exists(): void
{ {
$response = $this->postJson('/api/v1/onboarding/reset-skip'); $response = $this->postJson('/api/v1/onboarding/reset-skip');
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'data' => ['reset' => true] 'data' => ['reset' => true],
]); ]);
} }
public function test_onboarding_flow_integration() public function test_onboarding_flow_integration(): void
{ {
// 1. Initial status - needs onboarding // 1. Initial status - needs onboarding
$response = $this->getJson('/api/v1/onboarding/status'); $response = $this->getJson('/api/v1/onboarding/status');

View file

@ -33,12 +33,12 @@ public function test_index_returns_successful_response(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform accounts retrieved successfully.' 'message' => 'Platform accounts retrieved successfully.',
]); ]);
} }
@ -75,11 +75,11 @@ public function test_store_creates_platform_account_successfully(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform account created successfully!' 'message' => 'Platform account created successfully!',
]); ]);
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
@ -115,7 +115,7 @@ public function test_show_returns_platform_account_successfully(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
@ -123,7 +123,7 @@ public function test_show_returns_platform_account_successfully(): void
'data' => [ 'data' => [
'id' => $account->id, 'id' => $account->id,
'username' => $account->username, 'username' => $account->username,
] ],
]); ]);
} }
@ -134,7 +134,7 @@ public function test_update_modifies_platform_account_successfully(): void
$updateData = [ $updateData = [
'instance_url' => 'https://updated.example.com', 'instance_url' => 'https://updated.example.com',
'username' => 'updateduser', 'username' => 'updateduser',
'settings' => ['updated' => 'value'] 'settings' => ['updated' => 'value'],
]; ];
$response = $this->putJson("/api/v1/platform-accounts/{$account->id}", $updateData); $response = $this->putJson("/api/v1/platform-accounts/{$account->id}", $updateData);
@ -142,7 +142,7 @@ public function test_update_modifies_platform_account_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform account updated successfully!' 'message' => 'Platform account updated successfully!',
]); ]);
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
@ -161,11 +161,11 @@ public function test_destroy_deletes_platform_account_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform account deleted successfully!' 'message' => 'Platform account deleted successfully!',
]); ]);
$this->assertDatabaseMissing('platform_accounts', [ $this->assertDatabaseMissing('platform_accounts', [
'id' => $account->id 'id' => $account->id,
]); ]);
} }
@ -182,7 +182,7 @@ public function test_set_active_activates_platform_account(): void
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
'id' => $account->id, 'id' => $account->id,
'is_active' => true 'is_active' => true,
]); ]);
} }
@ -190,12 +190,12 @@ public function test_set_active_deactivates_other_accounts_of_same_platform(): v
{ {
$activeAccount = PlatformAccount::factory()->create([ $activeAccount = PlatformAccount::factory()->create([
'platform' => 'lemmy', 'platform' => 'lemmy',
'is_active' => true 'is_active' => true,
]); ]);
$newAccount = PlatformAccount::factory()->create([ $newAccount = PlatformAccount::factory()->create([
'platform' => 'lemmy', 'platform' => 'lemmy',
'is_active' => false 'is_active' => false,
]); ]);
$response = $this->postJson("/api/v1/platform-accounts/{$newAccount->id}/set-active"); $response = $this->postJson("/api/v1/platform-accounts/{$newAccount->id}/set-active");
@ -204,12 +204,12 @@ public function test_set_active_deactivates_other_accounts_of_same_platform(): v
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
'id' => $activeAccount->id, 'id' => $activeAccount->id,
'is_active' => false 'is_active' => false,
]); ]);
$this->assertDatabaseHas('platform_accounts', [ $this->assertDatabaseHas('platform_accounts', [
'id' => $newAccount->id, 'id' => $newAccount->id,
'is_active' => true 'is_active' => true,
]); ]);
} }
} }

View file

@ -34,13 +34,13 @@ public function test_index_returns_successful_response(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
'platform_instance' 'platform_instance',
] ],
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channels retrieved successfully.' 'message' => 'Platform channels retrieved successfully.',
]); ]);
} }
@ -51,7 +51,7 @@ public function test_store_creates_platform_channel_successfully(): void
// Create a platform account for this instance first // Create a platform account for this instance first
PlatformAccount::factory()->create([ PlatformAccount::factory()->create([
'instance_url' => $instance->url, 'instance_url' => $instance->url,
'is_active' => true 'is_active' => true,
]); ]);
$data = [ $data = [
@ -76,11 +76,11 @@ public function test_store_creates_platform_channel_successfully(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channel created successfully and linked to platform account!' 'message' => 'Platform channel created successfully and linked to platform account!',
]); ]);
$this->assertDatabaseHas('platform_channels', [ $this->assertDatabaseHas('platform_channels', [
@ -102,7 +102,7 @@ public function test_store_validates_platform_instance_exists(): void
{ {
$data = [ $data = [
'platform_instance_id' => 999, 'platform_instance_id' => 999,
'name' => 'Test Channel' 'name' => 'Test Channel',
]; ];
$response = $this->postJson('/api/v1/platform-channels', $data); $response = $this->postJson('/api/v1/platform-channels', $data);
@ -132,8 +132,8 @@ public function test_show_returns_platform_channel_successfully(): void
'is_active', 'is_active',
'created_at', 'created_at',
'updated_at', 'updated_at',
'platform_instance' 'platform_instance',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
@ -141,7 +141,7 @@ public function test_show_returns_platform_channel_successfully(): void
'data' => [ 'data' => [
'id' => $channel->id, 'id' => $channel->id,
'name' => $channel->name, 'name' => $channel->name,
] ],
]); ]);
} }
@ -154,7 +154,7 @@ public function test_update_modifies_platform_channel_successfully(): void
'name' => 'Updated Channel', 'name' => 'Updated Channel',
'display_name' => 'Updated Display Name', 'display_name' => 'Updated Display Name',
'description' => 'Updated description', 'description' => 'Updated description',
'is_active' => false 'is_active' => false,
]; ];
$response = $this->putJson("/api/v1/platform-channels/{$channel->id}", $updateData); $response = $this->putJson("/api/v1/platform-channels/{$channel->id}", $updateData);
@ -162,7 +162,7 @@ public function test_update_modifies_platform_channel_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channel updated successfully!' 'message' => 'Platform channel updated successfully!',
]); ]);
$this->assertDatabaseHas('platform_channels', [ $this->assertDatabaseHas('platform_channels', [
@ -183,11 +183,11 @@ public function test_destroy_deletes_platform_channel_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channel deleted successfully!' 'message' => 'Platform channel deleted successfully!',
]); ]);
$this->assertDatabaseMissing('platform_channels', [ $this->assertDatabaseMissing('platform_channels', [
'id' => $channel->id 'id' => $channel->id,
]); ]);
} }
@ -196,7 +196,7 @@ public function test_toggle_activates_inactive_channel(): void
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'is_active' => false 'is_active' => false,
]); ]);
$response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle"); $response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle");
@ -204,12 +204,12 @@ public function test_toggle_activates_inactive_channel(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channel activated successfully!' 'message' => 'Platform channel activated successfully!',
]); ]);
$this->assertDatabaseHas('platform_channels', [ $this->assertDatabaseHas('platform_channels', [
'id' => $channel->id, 'id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
} }
@ -218,7 +218,7 @@ public function test_toggle_deactivates_active_channel(): void
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'is_active' => true 'is_active' => true,
]); ]);
$response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle"); $response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle");
@ -226,12 +226,12 @@ public function test_toggle_deactivates_active_channel(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Platform channel deactivated successfully!' 'message' => 'Platform channel deactivated successfully!',
]); ]);
$this->assertDatabaseHas('platform_channels', [ $this->assertDatabaseHas('platform_channels', [
'id' => $channel->id, 'id' => $channel->id,
'is_active' => false 'is_active' => false,
]); ]);
} }
} }

View file

@ -23,13 +23,13 @@ public function test_index_returns_successful_response(): void
$feeds = Feed::factory()->count(3)->create(['language_id' => $language->id]); $feeds = Feed::factory()->count(3)->create(['language_id' => $language->id]);
$channels = PlatformChannel::factory()->count(3)->create([ $channels = PlatformChannel::factory()->count(3)->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
foreach ($feeds as $index => $feed) { foreach ($feeds as $index => $feed) {
Route::factory()->create([ Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channels[$index]->id 'platform_channel_id' => $channels[$index]->id,
]); ]);
} }
@ -47,12 +47,12 @@ public function test_index_returns_successful_response(): void
'priority', 'priority',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configurations retrieved successfully.' 'message' => 'Routing configurations retrieved successfully.',
]); ]);
} }
@ -63,14 +63,14 @@ public function test_store_creates_routing_configuration_successfully(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$data = [ $data = [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true, 'is_active' => true,
'priority' => 5 'priority' => 5,
]; ];
$response = $this->postJson('/api/v1/routing', $data); $response = $this->postJson('/api/v1/routing', $data);
@ -86,11 +86,11 @@ public function test_store_creates_routing_configuration_successfully(): void
'priority', 'priority',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration created successfully!' 'message' => 'Routing configuration created successfully!',
]); ]);
$this->assertDatabaseHas('routes', [ $this->assertDatabaseHas('routes', [
@ -115,12 +115,12 @@ public function test_store_validates_feed_exists(): void
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$data = [ $data = [
'feed_id' => 999, 'feed_id' => 999,
'platform_channel_id' => $channel->id 'platform_channel_id' => $channel->id,
]; ];
$response = $this->postJson('/api/v1/routing', $data); $response = $this->postJson('/api/v1/routing', $data);
@ -136,7 +136,7 @@ public function test_store_validates_platform_channel_exists(): void
$data = [ $data = [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => 999 'platform_channel_id' => 999,
]; ];
$response = $this->postJson('/api/v1/routing', $data); $response = $this->postJson('/api/v1/routing', $data);
@ -152,12 +152,12 @@ public function test_show_returns_routing_configuration_successfully(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id 'platform_channel_id' => $channel->id,
]); ]);
$response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}"); $response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}");
@ -173,11 +173,11 @@ public function test_show_returns_routing_configuration_successfully(): void
'priority', 'priority',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ],
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration retrieved successfully.' 'message' => 'Routing configuration retrieved successfully.',
]); ]);
} }
@ -188,7 +188,7 @@ public function test_show_returns_404_for_nonexistent_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}"); $response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}");
@ -196,7 +196,7 @@ public function test_show_returns_404_for_nonexistent_routing(): void
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'message' => 'Routing configuration not found.' 'message' => 'Routing configuration not found.',
]); ]);
} }
@ -207,19 +207,19 @@ public function test_update_modifies_routing_configuration_successfully(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true, 'is_active' => true,
'priority' => 1 'priority' => 1,
]); ]);
$updateData = [ $updateData = [
'is_active' => false, 'is_active' => false,
'priority' => 10 'priority' => 10,
]; ];
$response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", $updateData); $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", $updateData);
@ -227,7 +227,7 @@ public function test_update_modifies_routing_configuration_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration updated successfully!' 'message' => 'Routing configuration updated successfully!',
]); ]);
$this->assertDatabaseHas('routes', [ $this->assertDatabaseHas('routes', [
@ -245,17 +245,17 @@ public function test_update_returns_404_for_nonexistent_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", [ $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", [
'is_active' => false 'is_active' => false,
]); ]);
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'message' => 'Routing configuration not found.' 'message' => 'Routing configuration not found.',
]); ]);
} }
@ -266,12 +266,12 @@ public function test_destroy_deletes_routing_configuration_successfully(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id 'platform_channel_id' => $channel->id,
]); ]);
$response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}"); $response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}");
@ -279,12 +279,12 @@ public function test_destroy_deletes_routing_configuration_successfully(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration deleted successfully!' 'message' => 'Routing configuration deleted successfully!',
]); ]);
$this->assertDatabaseMissing('routes', [ $this->assertDatabaseMissing('routes', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id 'platform_channel_id' => $channel->id,
]); ]);
} }
@ -295,7 +295,7 @@ public function test_destroy_returns_404_for_nonexistent_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}"); $response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}");
@ -303,7 +303,7 @@ public function test_destroy_returns_404_for_nonexistent_routing(): void
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'message' => 'Routing configuration not found.' 'message' => 'Routing configuration not found.',
]); ]);
} }
@ -314,13 +314,13 @@ public function test_toggle_activates_inactive_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => false 'is_active' => false,
]); ]);
$response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle");
@ -328,13 +328,13 @@ public function test_toggle_activates_inactive_routing(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration activated successfully!' 'message' => 'Routing configuration activated successfully!',
]); ]);
$this->assertDatabaseHas('routes', [ $this->assertDatabaseHas('routes', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
} }
@ -345,13 +345,13 @@ public function test_toggle_deactivates_active_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$route = Route::factory()->create([ $route = Route::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true 'is_active' => true,
]); ]);
$response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle");
@ -359,13 +359,13 @@ public function test_toggle_deactivates_active_routing(): void
$response->assertStatus(200) $response->assertStatus(200)
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Routing configuration deactivated successfully!' 'message' => 'Routing configuration deactivated successfully!',
]); ]);
$this->assertDatabaseHas('routes', [ $this->assertDatabaseHas('routes', [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => false 'is_active' => false,
]); ]);
} }
@ -376,7 +376,7 @@ public function test_toggle_returns_404_for_nonexistent_routing(): void
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create(['language_id' => $language->id]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id,
]); ]);
$response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle");
@ -384,7 +384,7 @@ public function test_toggle_returns_404_for_nonexistent_routing(): void
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'message' => 'Routing configuration not found.' 'message' => 'Routing configuration not found.',
]); ]);
} }
} }

View file

@ -22,11 +22,11 @@ public function test_index_returns_current_settings(): void
'publishing_approvals_enabled', 'publishing_approvals_enabled',
'article_publishing_interval', 'article_publishing_interval',
], ],
'message' 'message',
]) ])
->assertJson([ ->assertJson([
'success' => true, 'success' => true,
'message' => 'Settings retrieved successfully.' 'message' => 'Settings retrieved successfully.',
]); ]);
} }
@ -42,7 +42,7 @@ public function test_update_modifies_article_processing_setting(): void
'message' => 'Settings updated successfully.', 'message' => 'Settings updated successfully.',
'data' => [ 'data' => [
'article_processing_enabled' => false, 'article_processing_enabled' => false,
] ],
]); ]);
} }
@ -58,7 +58,7 @@ public function test_update_modifies_publishing_approvals_setting(): void
'message' => 'Settings updated successfully.', 'message' => 'Settings updated successfully.',
'data' => [ 'data' => [
'publishing_approvals_enabled' => true, 'publishing_approvals_enabled' => true,
] ],
]); ]);
} }
@ -72,7 +72,7 @@ public function test_update_validates_boolean_values(): void
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors([ ->assertJsonValidationErrors([
'article_processing_enabled', 'article_processing_enabled',
'publishing_approvals_enabled' 'publishing_approvals_enabled',
]); ]);
} }
@ -88,7 +88,7 @@ public function test_update_accepts_partial_updates(): void
'success' => true, 'success' => true,
'data' => [ 'data' => [
'article_processing_enabled' => true, 'article_processing_enabled' => true,
] ],
]); ]);
// Should still have structure for all settings // Should still have structure for all settings
@ -97,7 +97,7 @@ public function test_update_accepts_partial_updates(): void
'article_processing_enabled', 'article_processing_enabled',
'publishing_approvals_enabled', 'publishing_approvals_enabled',
'article_publishing_interval', 'article_publishing_interval',
] ],
]); ]);
} }

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Events\ActionPerformed;
use App\Events\ArticleApproved; use App\Events\ArticleApproved;
// use App\Events\ArticleReadyToPublish; // Class no longer exists // use App\Events\ArticleReadyToPublish; // Class no longer exists
use App\Events\ExceptionLogged; use App\Events\ExceptionLogged;
@ -18,11 +19,10 @@
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Log; use App\Models\Log;
use App\Models\Setting;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Services\Log\LogSaver; use App\Models\Setting;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Article\ValidationService; use App\Services\Log\LogSaver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -44,7 +44,7 @@ public function test_article_discovery_job_processes_successfully(): void
$feed = Feed::factory()->create(['is_active' => true]); $feed = Feed::factory()->create(['is_active' => true]);
$logSaver = app(LogSaver::class); $logSaver = app(LogSaver::class);
$job = new ArticleDiscoveryJob(); $job = new ArticleDiscoveryJob;
$job->handle($logSaver); $job->handle($logSaver);
// Should dispatch individual feed jobs // Should dispatch individual feed jobs
@ -57,7 +57,7 @@ public function test_article_discovery_for_feed_job_processes_feed(): void
$feed = Feed::factory()->create([ $feed = Feed::factory()->create([
'url' => 'https://example.com/feed', 'url' => 'https://example.com/feed',
'is_active' => true 'is_active' => true,
]); ]);
// Mock the ArticleFetcher service in the container // Mock the ArticleFetcher service in the container
@ -94,10 +94,9 @@ public function test_sync_channel_posts_job_processes_successfully(): void
$this->assertTrue(true); $this->assertTrue(true);
} }
public function test_publish_next_article_job_has_correct_configuration(): void public function test_publish_next_article_job_has_correct_configuration(): void
{ {
$job = new PublishNextArticleJob(); $job = new PublishNextArticleJob;
$this->assertEquals('publishing', $job->queue); $this->assertEquals('publishing', $job->queue);
$this->assertInstanceOf(PublishNextArticleJob::class, $job); $this->assertInstanceOf(PublishNextArticleJob::class, $job);
@ -164,12 +163,12 @@ public function test_exception_logged_event_is_dispatched(): void
$log = Log::factory()->create([ $log = Log::factory()->create([
'level' => 'error', 'level' => 'error',
'message' => 'Test error', 'message' => 'Test error',
'context' => json_encode(['key' => 'value']) 'context' => json_encode(['key' => 'value']),
]); ]);
event(new ExceptionLogged($log)); event(new ExceptionLogged($log));
Event::assertDispatched(ExceptionLogged::class, function (ExceptionLogged $event) use ($log) { Event::assertDispatched(ExceptionLogged::class, function (ExceptionLogged $event) {
return $event->log->message === 'Test error'; return $event->log->message === 'Test error';
}); });
} }
@ -195,7 +194,7 @@ public function test_validate_article_listener_processes_new_article(): void
->andReturn([ ->andReturn([
'title' => 'Belgian News', 'title' => 'Belgian News',
'description' => 'News from Belgium', 'description' => 'News from Belgium',
'full_article' => 'This is a test article about Belgium and Belgian politics.' 'full_article' => 'This is a test article about Belgium and Belgian politics.',
]); ]);
$listener = app(ValidateArticleListener::class); $listener = app(ValidateArticleListener::class);
@ -248,10 +247,10 @@ public function test_log_exception_to_database_listener_creates_log(): void
$log = Log::factory()->create([ $log = Log::factory()->create([
'level' => 'error', 'level' => 'error',
'message' => 'Test exception message', 'message' => 'Test exception message',
'context' => json_encode(['error' => 'details']) 'context' => json_encode(['error' => 'details']),
]); ]);
$listener = new LogExceptionToDatabase(); $listener = new LogExceptionToDatabase;
$exception = new \Exception('Test exception message'); $exception = new \Exception('Test exception message');
$event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message'); $event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message');
@ -259,7 +258,7 @@ public function test_log_exception_to_database_listener_creates_log(): void
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => 'error', 'level' => 'error',
'message' => 'Test exception message' 'message' => 'Test exception message',
]); ]);
$savedLog = Log::where('message', 'Test exception message')->first(); $savedLog = Log::where('message', 'Test exception message')->first();
@ -270,6 +269,9 @@ public function test_log_exception_to_database_listener_creates_log(): void
public function test_event_listener_registration_works(): void public function test_event_listener_registration_works(): void
{ {
// Test that events are properly bound to listeners // Test that events are properly bound to listeners
$listeners = Event::getListeners(ActionPerformed::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(NewArticleFetched::class); $listeners = Event::getListeners(NewArticleFetched::class);
$this->assertNotEmpty($listeners); $this->assertNotEmpty($listeners);
@ -287,7 +289,7 @@ public function test_event_listener_registration_works(): void
public function test_job_retry_configuration(): void public function test_job_retry_configuration(): void
{ {
$job = new PublishNextArticleJob(); $job = new PublishNextArticleJob;
// Test that job has unique configuration // Test that job has unique configuration
$this->assertObjectHasProperty('uniqueFor', $job); $this->assertObjectHasProperty('uniqueFor', $job);
@ -300,9 +302,9 @@ public function test_job_queue_configuration(): void
$channel = PlatformChannel::factory()->create(); $channel = PlatformChannel::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]); $article = Article::factory()->create(['feed_id' => $feed->id]);
$discoveryJob = new ArticleDiscoveryJob(); $discoveryJob = new ArticleDiscoveryJob;
$feedJob = new ArticleDiscoveryForFeedJob($feed); $feedJob = new ArticleDiscoveryForFeedJob($feed);
$publishJob = new PublishNextArticleJob(); $publishJob = new PublishNextArticleJob;
$syncJob = new SyncChannelPostsJob($channel); $syncJob = new SyncChannelPostsJob($channel);
// Test queue assignments // Test queue assignments

Some files were not shown because too many files have changed in this diff Show more