Release v1.3.0 #100

Merged
myrmidex merged 20 commits from release/v1.3.0 into main 2026-03-18 20:30:29 +01:00
111 changed files with 4002 additions and 2030 deletions

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ApprovalStatusEnum: string
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NotificationSeverityEnum: string
{
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum NotificationTypeEnum: string
{
case GENERAL = 'general';
case FEED_STALE = 'feed_stale';
case PUBLISH_FAILED = 'publish_failed';
case CREDENTIAL_EXPIRED = 'credential_expired';
public function label(): string
{
return match ($this) {
self::GENERAL => 'General',
self::FEED_STALE => 'Feed Stale',
self::PUBLISH_FAILED => 'Publish Failed',
self::CREDENTIAL_EXPIRED => 'Credential Expired',
};
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PublishStatusEnum: string
{
case UNPUBLISHED = 'unpublished';
case PUBLISHING = 'publishing';
case PUBLISHED = 'published';
case ERROR = 'error';
}

View file

@ -2,16 +2,13 @@
namespace App\Events;
use App\Models\Article;
use App\Models\RouteArticle;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ArticleApproved
class RouteArticleApproved
{
use Dispatchable, SerializesModels;
public function __construct(public Article $article)
{
//
}
public function __construct(public RouteArticle $routeArticle) {}
}

View file

@ -40,40 +40,6 @@ public function index(Request $request): JsonResponse
]);
}
/**
* Approve an article
*/
public function approve(Article $article): JsonResponse
{
try {
$article->approve('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article approved and queued for publishing.'
);
} catch (Exception $e) {
return $this->sendError('Failed to approve article: '.$e->getMessage(), [], 500);
}
}
/**
* Reject an article
*/
public function reject(Article $article): JsonResponse
{
try {
$article->reject('manual');
return $this->sendResponse(
new ArticleResource($article->fresh(['feed', 'articlePublication'])),
'Article rejected.'
);
} catch (Exception $e) {
return $this->sendError('Failed to reject article: '.$e->getMessage(), [], 500);
}
}
/**
* Manually refresh articles from all active feeds
*/

View file

@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Enums\ApprovalStatusEnum;
use App\Http\Resources\RouteArticleResource;
use App\Models\RouteArticle;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RouteArticlesController extends BaseController
{
public function index(Request $request): JsonResponse
{
$perPage = min($request->get('per_page', 15), 100);
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc');
if ($request->has('status')) {
$status = ApprovalStatusEnum::tryFrom($request->get('status'));
if ($status) {
$query->where('approval_status', $status);
}
}
$routeArticles = $query->paginate($perPage);
return $this->sendResponse([
'route_articles' => RouteArticleResource::collection($routeArticles->items()),
'pagination' => [
'current_page' => $routeArticles->currentPage(),
'last_page' => $routeArticles->lastPage(),
'per_page' => $routeArticles->perPage(),
'total' => $routeArticles->total(),
'from' => $routeArticles->firstItem(),
'to' => $routeArticles->lastItem(),
],
]);
}
public function approve(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->approve();
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article approved and queued for publishing.'
);
} catch (Exception $e) {
return $this->sendError('Failed to approve route article: '.$e->getMessage(), [], 500);
}
}
public function reject(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->reject();
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article rejected.'
);
} catch (Exception $e) {
return $this->sendError('Failed to reject route article: '.$e->getMessage(), [], 500);
}
}
public function restore(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
'Route article restored to pending.'
);
} catch (Exception $e) {
return $this->sendError('Failed to restore route article: '.$e->getMessage(), [], 500);
}
}
public function clear(): JsonResponse
{
try {
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
return $this->sendResponse(
['rejected_count' => $count],
"Rejected {$count} pending route articles."
);
} catch (Exception $e) {
return $this->sendError('Failed to clear pending route articles: '.$e->getMessage(), [], 500);
}
}
}

View file

@ -11,6 +11,7 @@
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
@ -26,7 +27,7 @@ public function create(Request $request): View
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{

View file

@ -6,6 +6,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
@ -21,7 +22,7 @@ public function create(): View
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{

View file

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
@ -25,7 +26,7 @@ public function create(): View
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{

View file

@ -12,7 +12,7 @@ class HandleAppearance
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
@ -22,7 +23,7 @@ public function authorize(): bool
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@ -35,7 +36,7 @@ public function rules(): array
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function authenticate(): void
{
@ -55,7 +56,7 @@ public function authenticate(): void
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\ArticlePublication;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\ArticlePublication
* @mixin ArticlePublication
*/
class ArticlePublicationResource extends JsonResource
{

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Article
* @mixin Article
*/
class ArticleResource extends JsonResource
{
@ -21,9 +22,6 @@ public function toArray(Request $request): array
'url' => $this->url,
'title' => $this->title,
'description' => $this->description,
'is_valid' => $this->is_valid,
'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'validated_at' => $this->validated_at?->toISOString(),
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
'created_at' => $this->created_at->toISOString(),

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\Feed;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Feed
* @mixin Feed
*/
class FeedResource extends JsonResource
{

View file

@ -2,14 +2,15 @@
namespace App\Http\Resources;
use App\Models\PlatformAccount;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformAccount
* @mixin PlatformAccount
*/
/**
* @mixin \App\Models\PlatformAccount
* @mixin PlatformAccount
*/
class PlatformAccountResource extends JsonResource
{

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\PlatformChannel;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformChannel
* @mixin PlatformChannel
*/
class PlatformChannelResource extends JsonResource
{

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\PlatformInstance;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\PlatformInstance
* @mixin PlatformInstance
*/
class PlatformInstanceResource extends JsonResource
{

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Resources;
use App\Models\RouteArticle;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin RouteArticle
*/
class RouteArticleResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'feed_id' => $this->feed_id,
'platform_channel_id' => $this->platform_channel_id,
'article_id' => $this->article_id,
'approval_status' => $this->approval_status->value,
'publish_status' => $this->publish_status->value,
'validated_at' => $this->validated_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'article' => [
'id' => $this->article->id,
'title' => $this->article->title,
'url' => $this->article->url,
'description' => $this->article->description,
'feed_name' => $this->article->feed->name,
],
'route_name' => $this->feed->name.' → '.$this->platformChannel->name,
];
}
}

View file

@ -2,11 +2,12 @@
namespace App\Http\Resources;
use App\Models\Route;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Route
* @mixin Route
*/
class RouteResource extends JsonResource
{

View file

@ -0,0 +1,51 @@
<?php
namespace App\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Setting;
use App\Services\Notification\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CheckFeedStalenessJob implements ShouldQueue
{
use Queueable;
public function handle(NotificationService $notificationService): void
{
$thresholdHours = Setting::getFeedStalenessThreshold();
if ($thresholdHours === 0) {
return;
}
$staleFeeds = Feed::stale($thresholdHours)->get();
foreach ($staleFeeds as $feed) {
$alreadyNotified = Notification::query()
->where('type', NotificationTypeEnum::FEED_STALE)
->where('notifiable_type', $feed->getMorphClass())
->where('notifiable_id', $feed->getKey())
->unread()
->exists();
if ($alreadyNotified) {
continue;
}
$notificationService->send(
type: NotificationTypeEnum::FEED_STALE,
severity: NotificationSeverityEnum::WARNING,
title: "Feed \"{$feed->name}\" is stale",
message: $feed->last_fetched_at
? "Last fetched {$feed->last_fetched_at->diffForHumans()}. Threshold is {$thresholdHours} hours."
: "This feed has never been fetched. Threshold is {$thresholdHours} hours.",
notifiable: $feed,
);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CleanupArticlesJob implements ShouldQueue
{
use Queueable;
private const RETENTION_DAYS = 30;
public function handle(): void
{
Article::where('created_at', '<', now()->subDays(self::RETENTION_DAYS))
->whereDoesntHave('routeArticles', fn ($q) => $q->whereIn('approval_status', [
ApprovalStatusEnum::PENDING,
ApprovalStatusEnum::APPROVED,
]))
->delete();
}
}

View file

@ -2,13 +2,18 @@
namespace App\Jobs;
use App\Enums\ApprovalStatusEnum;
use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Exceptions\PublishException;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\RouteArticle;
use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -33,7 +38,7 @@ public function __construct()
*
* @throws PublishException
*/
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void
{
$interval = Setting::getArticlePublishingInterval();
@ -45,39 +50,73 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
}
}
// Get the oldest approved article that hasn't been published yet
$article = Article::where('approval_status', 'approved')
->whereDoesntHave('articlePublication')
->oldest('created_at')
// Get the oldest approved route_article that hasn't been published to its channel yet
$routeArticle = RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)
->whereDoesntHave('article.articlePublications', function ($query) {
$query->whereColumn('article_publications.platform_channel_id', 'route_articles.platform_channel_id');
})
->oldest('route_articles.created_at')
->with(['article', 'platformChannel.platformInstance', 'platformChannel.activePlatformAccounts'])
->first();
if (! $article) {
if (! $routeArticle) {
return;
}
$article = $routeArticle->article;
ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
'url' => $article->url,
'created_at' => $article->created_at,
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
]);
// Fetch article data
$extractedData = $articleFetcher->fetchArticleData($article);
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try {
$publishingService->publishToRoutedChannels($article, $extractedData);
$extractedData = $articleFetcher->fetchArticleData($article);
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
}
} catch (PublishException $e) {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
throw $e;
}
}

View file

@ -3,9 +3,13 @@
namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Events\ArticleApproved;
use App\Events\RouteArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -16,51 +20,67 @@ class PublishApprovedArticleListener implements ShouldQueue
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
private ArticlePublishingService $publishingService,
private NotificationService $notificationService,
) {}
public function handle(ArticleApproved $event): void
public function handle(RouteArticleApproved $event): void
{
$article = $event->article->fresh();
$routeArticle = $event->routeArticle;
$article = $routeArticle->article;
// Skip if already published
if ($article->articlePublication()->exists()) {
// Skip if already published to this channel
if ($article->articlePublications()
->where('platform_channel_id', $routeArticle->platform_channel_id)
->exists()
) {
return;
}
// Skip if not approved (safety check)
if (! $article->isApproved()) {
return;
}
$article->update(['publish_status' => 'publishing']);
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$article->update(['publish_status' => 'error']);
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
}
} catch (Exception $e) {
$article->update(['publish_status' => 'error']);
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
}
}
}

View file

@ -5,7 +5,6 @@
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\NewArticleFetched;
use App\Models\Setting;
use App\Services\Article\ValidationService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -26,38 +25,18 @@ public function handle(NewArticleFetched $event): void
return;
}
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) {
return;
}
try {
$article = $this->validationService->validate($article);
$this->validationService->validate($article);
} catch (Exception $e) {
ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
return;
}
if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection)
if ($article->articlePublication()->exists()) {
return;
}
// If approvals are enabled, article waits for manual approval.
// If approvals are disabled, auto-approve and publish.
if (! Setting::isPublishingApprovalsEnabled()) {
$article->approve();
}
}
}
}

View file

@ -2,9 +2,10 @@
namespace App\Livewire;
use App\Enums\ApprovalStatusEnum;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article;
use App\Models\Setting;
use App\Models\RouteArticle;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithPagination;
@ -12,22 +13,44 @@ class Articles extends Component
{
use WithPagination;
public string $tab = 'pending';
public string $search = '';
public bool $isRefreshing = false;
public function approve(int $articleId): void
public function setTab(string $tab): void
{
$article = Article::findOrFail($articleId);
$article->approve();
$this->dispatch('article-updated');
$this->tab = $tab;
$this->search = '';
$this->resetPage();
}
public function reject(int $articleId): void
public function updatedSearch(): void
{
$article = Article::findOrFail($articleId);
$article->reject();
$this->resetPage();
}
$this->dispatch('article-updated');
public function approve(int $routeArticleId): void
{
RouteArticle::findOrFail($routeArticleId)->approve();
}
public function reject(int $routeArticleId): void
{
RouteArticle::findOrFail($routeArticleId)->reject();
}
public function restore(int $routeArticleId): void
{
$routeArticle = RouteArticle::findOrFail($routeArticleId);
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
}
public function clear(): void
{
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
}
public function refresh(): void
@ -39,17 +62,28 @@ public function refresh(): void
$this->dispatch('refresh-started');
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
$articles = Article::with(['feed', 'articlePublication'])
->orderBy('created_at', 'desc')
->paginate(15);
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc');
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
if ($this->tab === 'pending') {
$query->where('approval_status', ApprovalStatusEnum::PENDING);
} elseif ($this->search !== '') {
$search = $this->search;
$query->whereHas('article', function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
$routeArticles = $query->paginate(15);
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
return view('livewire.articles', [
'articles' => $articles,
'approvalsEnabled' => $approvalsEnabled,
'routeArticles' => $routeArticles,
'pendingCount' => $pendingCount,
])->layout('layouts.app');
}
}

View file

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

View file

@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Services\DashboardStatsService;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Dashboard extends Component
@ -19,7 +20,7 @@ public function setPeriod(string $period): void
$this->period = $period;
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
$service = app(DashboardStatsService::class);

View file

@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Models\Feed;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Feeds extends Component
@ -14,7 +15,7 @@ public function toggle(int $feedId): void
$feed->save();
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
$feeds = Feed::orderBy('name')->get();

View file

@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\Notification;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
class NotificationBell extends Component
{
public function markAsRead(int $id): void
{
Notification::findOrFail($id)->markAsRead();
}
public function markAllAsRead(): void
{
Notification::markAllAsRead();
}
#[Computed]
public function unreadCount(): int
{
return Notification::unread()->count();
}
/**
* @return Collection<int, Notification>
*/
#[Computed]
public function notifications(): Collection
{
return Notification::recent()->get();
}
public function render(): View
{
return view('livewire.notification-bell');
}
}

View file

@ -18,7 +18,9 @@
use App\Models\Setting;
use App\Services\OnboardingService;
use Exception;
use Illuminate\Contracts\View\View;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
use RuntimeException;
@ -68,7 +70,7 @@ class Onboarding extends Component
public bool $isLoading = false;
#[\Livewire\Attributes\Locked]
#[Locked]
public ?int $previousChannelLanguageId = null;
protected CreatePlatformAccountAction $createPlatformAccountAction;
@ -406,7 +408,7 @@ public function getChannelLanguage(): ?Language
return Language::find($this->channelLanguageId);
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
// For channel step: only show languages that have providers
$availableCodes = $this->getAvailableLanguageCodes();

View file

@ -6,6 +6,7 @@
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Routes extends Component
@ -26,6 +27,8 @@ class Routes extends Component
// Edit form
public int $editPriority = 50;
public string $editAutoApprove = '';
// Keyword management
public string $newKeyword = '';
@ -81,6 +84,7 @@ public function openEditModal(int $feedId, int $channelId): void
$this->editingFeedId = $feedId;
$this->editingChannelId = $channelId;
$this->editPriority = $route->priority;
$this->editAutoApprove = $route->auto_approve === null ? '' : ($route->auto_approve ? '1' : '0');
$this->newKeyword = '';
$this->showKeywordInput = false;
}
@ -101,9 +105,18 @@ public function updateRoute(): void
'editPriority' => 'required|integer|min:0',
]);
$autoApprove = match ($this->editAutoApprove) {
'1' => true,
'0' => false,
default => null,
};
Route::where('feed_id', $this->editingFeedId)
->where('platform_channel_id', $this->editingChannelId)
->update(['priority' => $this->editPriority]);
->update([
'priority' => $this->editPriority,
'auto_approve' => $autoApprove,
]);
$this->closeEditModal();
}
@ -159,7 +172,7 @@ public function deleteKeyword(int $keywordId): void
Keyword::destroy($keywordId);
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
$routes = Route::with(['feed', 'platformChannel.platformInstance'])
->orderBy('priority', 'desc')

View file

@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Models\Setting;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Settings extends Component
@ -13,6 +14,8 @@ class Settings extends Component
public int $articlePublishingInterval = 5;
public int $feedStalenessThreshold = 48;
public ?string $successMessage = null;
public ?string $errorMessage = null;
@ -22,6 +25,7 @@ public function mount(): void
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
$this->articlePublishingInterval = Setting::getArticlePublishingInterval();
$this->feedStalenessThreshold = Setting::getFeedStalenessThreshold();
}
public function toggleArticleProcessing(): void
@ -48,6 +52,16 @@ public function updateArticlePublishingInterval(): void
$this->showSuccess();
}
public function updateFeedStalenessThreshold(): void
{
$this->validate([
'feedStalenessThreshold' => 'required|integer|min:0',
]);
Setting::setFeedStalenessThreshold($this->feedStalenessThreshold);
$this->showSuccess();
}
protected function showSuccess(): void
{
$this->successMessage = 'Settings updated successfully!';
@ -63,7 +77,7 @@ public function clearMessages(): void
$this->errorMessage = null;
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
return view('livewire.settings')->layout('layouts.app');
}

View file

@ -2,12 +2,12 @@
namespace App\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched;
use Database\Factories\ArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon;
@ -22,9 +22,6 @@
* @property string $url
* @property string $title
* @property string|null $description
* @property string $approval_status
* @property string $publish_status
* @property bool|null $is_valid
* @property Carbon|null $validated_at
* @property Carbon $created_at
* @property Carbon $updated_at
@ -44,9 +41,7 @@ class Article extends Model
'image_url',
'published_at',
'author',
'approval_status',
'validated_at',
'publish_status',
];
/**
@ -55,8 +50,6 @@ class Article extends Model
public function casts(): array
{
return [
'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime',
@ -64,58 +57,6 @@ public function casts(): array
];
}
public function isValid(): bool
{
return $this->validated_at !== null && ! $this->isRejected();
}
public function isApproved(): bool
{
return $this->approval_status === 'approved';
}
public function isPending(): bool
{
return $this->approval_status === 'pending';
}
public function isRejected(): bool
{
return $this->approval_status === 'rejected';
}
public function approve(?string $approvedBy = null): void
{
$this->update([
'approval_status' => 'approved',
]);
// Fire event to trigger publishing
event(new ArticleApproved($this));
}
public function reject(?string $rejectedBy = null): void
{
$this->update([
'approval_status' => 'rejected',
]);
}
public function canBePublished(): bool
{
if (! $this->isValid()) {
return false;
}
// If approval system is disabled, auto-approve valid articles
if (! \App\Models\Setting::isPublishingApprovalsEnabled()) {
return true;
}
// If approval system is enabled, only approved articles can be published
return $this->isApproved();
}
public function getIsPublishedAttribute(): bool
{
return $this->articlePublication()->exists();
@ -129,6 +70,14 @@ public function articlePublication(): HasOne
return $this->hasOne(ArticlePublication::class);
}
/**
* @return HasMany<ArticlePublication, $this>
*/
public function articlePublications(): HasMany
{
return $this->hasMany(ArticlePublication::class);
}
/**
* @return BelongsTo<Feed, $this>
*/
@ -137,6 +86,14 @@ public function feed(): BelongsTo
return $this->belongsTo(Feed::class);
}
/**
* @return HasMany<RouteArticle, $this>
*/
public function routeArticles(): HasMany
{
return $this->hasMany(RouteArticle::class);
}
public function dispatchFetchedEvent(): void
{
event(new NewArticleFetched($this));

View file

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
@ -15,9 +16,9 @@
* @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
* @property Carbon $published_at
* @property Carbon $created_at
* @property Carbon $updated_at
*
* @method static create(array<string, mixed> $array)
*/

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Database\Factories\FeedFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -86,6 +87,19 @@ public function getStatusAttribute(): string
}
}
/**
* @param Builder<Feed> $query
* @return Builder<Feed>
*/
public function scopeStale(Builder $query, int $thresholdHours): Builder
{
return $query->where('is_active', true)
->where(function (Builder $query) use ($thresholdHours) {
$query->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours($thresholdHours));
});
}
/**
* @return BelongsToMany<PlatformChannel, $this>
*/

View file

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use Database\Factories\NotificationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property NotificationTypeEnum $type
* @property NotificationSeverityEnum $severity
* @property string $title
* @property string $message
* @property array<string, mixed>|null $data
* @property string|null $notifiable_type
* @property int|null $notifiable_id
* @property Carbon|null $read_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Notification extends Model
{
/** @use HasFactory<NotificationFactory> */
use HasFactory;
protected $fillable = [
'type',
'severity',
'title',
'message',
'data',
'notifiable_type',
'notifiable_id',
'read_at',
];
protected $casts = [
'type' => NotificationTypeEnum::class,
'severity' => NotificationSeverityEnum::class,
'data' => 'array',
'read_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* @return MorphTo<Model, $this>
*/
public function notifiable(): MorphTo
{
return $this->morphTo();
}
public function isRead(): bool
{
return $this->read_at !== null;
}
public function markAsRead(): void
{
$this->update(['read_at' => now()]);
}
/**
* @param Builder<Notification> $query
* @return Builder<Notification>
*/
public function scopeUnread(Builder $query): Builder
{
return $query->whereNull('read_at');
}
/**
* @param Builder<Notification> $query
* @return Builder<Notification>
*/
public function scopeRecent(Builder $query): Builder
{
return $query->latest()->limit(50);
}
public static function markAllAsRead(): void
{
static::unread()->update(['read_at' => now()]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PlatformEnum;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -12,7 +13,7 @@
*/
class PlatformChannelPost extends Model
{
/** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<PlatformChannelPost>> */
/** @use HasFactory<Factory<PlatformChannelPost>> */
use HasFactory;
protected $fillable = [

View file

@ -14,6 +14,7 @@
* @property int $platform_channel_id
* @property bool $is_active
* @property int $priority
* @property bool|null $auto_approve
* @property Carbon $created_at
* @property Carbon $updated_at
*/
@ -34,10 +35,12 @@ class Route extends Model
'platform_channel_id',
'is_active',
'priority',
'auto_approve',
];
protected $casts = [
'is_active' => 'boolean',
'auto_approve' => 'boolean',
];
/**
@ -64,4 +67,13 @@ public function keywords(): HasMany
return $this->hasMany(Keyword::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
/**
* @return HasMany<RouteArticle, $this>
*/
public function routeArticles(): HasMany
{
return $this->hasMany(RouteArticle::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
}

104
app/Models/RouteArticle.php Normal file
View file

@ -0,0 +1,104 @@
<?php
namespace App\Models;
use App\Enums\ApprovalStatusEnum;
use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved;
use Database\Factories\RouteArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $feed_id
* @property int $platform_channel_id
* @property int $article_id
* @property ApprovalStatusEnum $approval_status
* @property PublishStatusEnum $publish_status
* @property Carbon|null $validated_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class RouteArticle extends Model
{
/** @use HasFactory<RouteArticleFactory> */
use HasFactory;
protected $fillable = [
'feed_id',
'platform_channel_id',
'article_id',
'approval_status',
'publish_status',
'validated_at',
];
protected $casts = [
'approval_status' => ApprovalStatusEnum::class,
'publish_status' => PublishStatusEnum::class,
'validated_at' => 'datetime',
];
/**
* @return BelongsTo<Route, $this>
*/
public function route(): BelongsTo
{
return $this->belongsTo(Route::class, 'feed_id', 'feed_id')
->where('platform_channel_id', $this->platform_channel_id);
}
/**
* @return BelongsTo<Article, $this>
*/
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
/**
* @return BelongsTo<Feed, $this>
*/
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
/**
* @return BelongsTo<PlatformChannel, $this>
*/
public function platformChannel(): BelongsTo
{
return $this->belongsTo(PlatformChannel::class);
}
public function isPending(): bool
{
return $this->approval_status === ApprovalStatusEnum::PENDING;
}
public function isApproved(): bool
{
return $this->approval_status === ApprovalStatusEnum::APPROVED;
}
public function isRejected(): bool
{
return $this->approval_status === ApprovalStatusEnum::REJECTED;
}
public function approve(): void
{
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
event(new RouteArticleApproved($this));
}
public function reject(): void
{
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
}
}

View file

@ -71,4 +71,14 @@ public static function setArticlePublishingInterval(int $minutes): void
{
static::set('article_publishing_interval', (string) $minutes);
}
public static function getFeedStalenessThreshold(): int
{
return (int) static::get('feed_staleness_threshold', 48);
}
public static function setFeedStalenessThreshold(int $hours): void
{
static::set('feed_staleness_threshold', (string) $hours);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -10,7 +11,7 @@
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**

View file

@ -5,8 +5,12 @@
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched;
use App\Events\RouteArticleApproved;
use App\Listeners\LogActionListener;
use App\Listeners\LogExceptionToDatabase;
use App\Listeners\PublishApprovedArticleListener;
use App\Listeners\ValidateArticleListener;
use Error;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event;
@ -31,13 +35,13 @@ public function boot(): void
);
Event::listen(
\App\Events\NewArticleFetched::class,
\App\Listeners\ValidateArticleListener::class,
NewArticleFetched::class,
ValidateArticleListener::class,
);
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
RouteArticleApproved::class,
PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class)

View file

@ -155,7 +155,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article
}
return $article;
} catch (\Exception $e) {
} catch (Exception $e) {
$this->logSaver->error('Failed to create article', null, [
'url' => $url,
'feed_id' => $feedId,

View file

@ -2,7 +2,13 @@
namespace App\Services\Article;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use App\Models\Keyword;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use Illuminate\Support\Collection;
class ValidationService
{
@ -12,11 +18,10 @@ public function __construct(
public function validate(Article $article): Article
{
logger('Checking keywords for article: '.$article->id);
logger('Validating article for routes: '.$article->id);
$articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description)
$updateData = [];
if (! empty($articleData)) {
@ -31,51 +36,81 @@ public function validate(Article $article): Article
'url' => $article->url,
]);
$updateData['approval_status'] = 'rejected';
$updateData['validated_at'] = now();
$article->update($updateData);
return $article->refresh();
}
// Validate content against keywords. If validation fails, reject.
// If validation passes, leave approval_status as-is (pending) —
// the listener decides whether to auto-approve based on settings.
$validationResult = $this->validateByKeywords($articleData['full_article']);
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData);
$this->createRouteArticles($article, $articleData['full_article']);
return $article->refresh();
}
private function validateByKeywords(string $full_article): bool
private function createRouteArticles(Article $article, string $content): void
{
// Belgian news content keywords - broader set for Belgian news relevance
$keywords = [
// Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
$activeRoutes = Route::where('feed_id', $article->feed_id)
->where('is_active', true)
->get();
// Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Batch-load all active keywords for this feed, grouped by channel
$keywordsByChannel = Keyword::where('feed_id', $article->feed_id)
->where('is_active', true)
->get()
->groupBy('platform_channel_id');
// Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
];
// Match keywords against full article content, title, and description
$searchableContent = $content.' '.$article->title.' '.$article->description;
foreach ($activeRoutes as $route) {
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect());
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$status = ApprovalStatusEnum::APPROVED;
}
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
]
);
}
}
/**
* @param Collection<int, Keyword> $keywords
*/
private function evaluateKeywords(Collection $keywords, string $content): ApprovalStatusEnum
{
if ($keywords->isEmpty()) {
return ApprovalStatusEnum::PENDING;
}
foreach ($keywords as $keyword) {
if (stripos($full_article, $keyword) !== false) {
return true;
if (stripos($content, $keyword->keyword) !== false) {
return ApprovalStatusEnum::PENDING;
}
}
return false;
return ApprovalStatusEnum::REJECTED;
}
private function shouldAutoApprove(Route $route): bool
{
if ($route->auto_approve !== null) {
return $route->auto_approve;
}
return ! Setting::isPublishingApprovalsEnabled();
}
}

View file

@ -3,6 +3,7 @@
namespace App\Services\Http;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class HttpFetcher
@ -48,7 +49,7 @@ public static function fetchMultipleUrls(array $urls): array
->reject(fn ($response, $index) => $response instanceof Exception)
->map(function ($response, $index) use ($urls) {
$url = $urls[$index];
/** @var \Illuminate\Http\Client\Response $response */
/** @var Response $response */
try {
if ($response->successful()) {
return [

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Notification;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Model;
class NotificationService
{
/**
* @param array<string, mixed> $data
*/
public function send(
NotificationTypeEnum $type,
NotificationSeverityEnum $severity,
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return Notification::create([
'type' => $type,
'severity' => $severity,
'title' => $title,
'message' => $message,
'data' => $data ?: null,
'notifiable_type' => $notifiable?->getMorphClass(),
'notifiable_id' => $notifiable?->getKey(),
]);
}
/**
* @param array<string, mixed> $data
*/
public function info(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data);
}
/**
* @param array<string, mixed> $data
*/
public function warning(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data);
}
/**
* @param array<string, mixed> $data
*/
public function error(
string $title,
string $message,
?Model $notifiable = null,
array $data = [],
): Notification {
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data);
}
}

View file

@ -8,11 +8,10 @@
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Support\Collection;
use RuntimeException;
class ArticlePublishingService
@ -28,85 +27,37 @@ protected function makePublisher(mixed $account): LemmyPublisher
}
/**
* Publish an article to the channel specified by a route_article record.
*
* @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication>
*
* @throws PublishException
*/
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
{
if (! $article->isValid()) {
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE'));
$article = $routeArticle->article;
$channel = $routeArticle->platformChannel;
if (! $channel) {
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('ROUTE_ARTICLE_MISSING_CHANNEL'));
}
$feed = $article->feed;
// Get active routes with keywords instead of just channels
$activeRoutes = Route::where('feed_id', $feed->id)
->where('is_active', true)
->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords'])
->orderBy('priority', 'desc')
->get();
// Filter routes based on keyword matches
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) {
return $this->routeMatchesArticle($route, $extractedData);
});
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
$channel = $route->platformChannel;
$account = $channel->activePlatformAccounts()->first();
if (! $account) {
$this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id,
'route_priority' => $route->priority,
]);
return null;
}
return $this->publishToChannel($article, $extractedData, $channel, $account);
})
->filter();
}
/**
* Check if a route matches an article based on keywords
*
* @param array<string, mixed> $extractedData
*/
private function routeMatchesArticle(Route $route, array $extractedData): bool
{
// Get active keywords for this route
$activeKeywords = $route->keywords->where('is_active', true);
// If no keywords are defined for this route, the route matches any article
if ($activeKeywords->isEmpty()) {
return true;
if (! $channel->relationLoaded('platformInstance')) {
$channel->load(['platformInstance', 'activePlatformAccounts']);
}
// Get article content for keyword matching
$articleContent = '';
if (isset($extractedData['full_article'])) {
$articleContent = $extractedData['full_article'];
}
if (isset($extractedData['title'])) {
$articleContent .= ' '.$extractedData['title'];
}
if (isset($extractedData['description'])) {
$articleContent .= ' '.$extractedData['description'];
$account = $channel->activePlatformAccounts()->first();
if (! $account) {
$this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id,
'route_article_id' => $routeArticle->id,
]);
return null;
}
// Check if any of the route's keywords match the article content
foreach ($activeKeywords as $keywordModel) {
$keyword = $keywordModel->keyword;
if (stripos($articleContent, $keyword) !== false) {
return true;
}
}
return false;
return $this->publishToChannel($article, $extractedData, $channel, $account);
}
/**
@ -145,7 +96,7 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'publication_data' => $postData,
]);
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
$this->logSaver->info('Published to channel', $channel, [
'article_id' => $article->id,
]);

View file

@ -1,5 +1,7 @@
<?php
use App\Enums\LogLevelEnum;
use App\Events\ExceptionOccurred;
use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\RedirectIfOnboardingComplete;
use Illuminate\Foundation\Application;
@ -22,13 +24,13 @@
->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(function (Throwable $e) {
$level = match (true) {
$e instanceof Error => \App\Enums\LogLevelEnum::CRITICAL,
$e instanceof RuntimeException => \App\Enums\LogLevelEnum::ERROR,
$e instanceof InvalidArgumentException => \App\Enums\LogLevelEnum::WARNING,
default => \App\Enums\LogLevelEnum::ERROR,
$e instanceof Error => LogLevelEnum::CRITICAL,
$e instanceof RuntimeException => LogLevelEnum::ERROR,
$e instanceof InvalidArgumentException => LogLevelEnum::WARNING,
default => LogLevelEnum::ERROR,
};
App\Events\ExceptionOccurred::dispatch(
ExceptionOccurred::dispatch(
$e,
$level,
$e->getMessage(),

View file

@ -1,6 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
AppServiceProvider::class,
HorizonServiceProvider::class,
];

View file

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View file

@ -1,5 +1,13 @@
<?php
use App\Services\Parsers\BelgaArticlePageParser;
use App\Services\Parsers\BelgaArticleParser;
use App\Services\Parsers\GuardianArticlePageParser;
use App\Services\Parsers\GuardianArticleParser;
use App\Services\Parsers\VrtArticlePageParser;
use App\Services\Parsers\VrtArticleParser;
use App\Services\Parsers\VrtHomepageParserAdapter;
return [
/*
|--------------------------------------------------------------------------
@ -24,9 +32,9 @@
'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'],
],
'parsers' => [
'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class,
'article' => \App\Services\Parsers\VrtArticleParser::class,
'article_page' => \App\Services\Parsers\VrtArticlePageParser::class,
'homepage' => VrtHomepageParserAdapter::class,
'article' => VrtArticleParser::class,
'article_page' => VrtArticlePageParser::class,
],
],
'belga' => [
@ -39,8 +47,8 @@
'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
],
'parsers' => [
'article' => \App\Services\Parsers\BelgaArticleParser::class,
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
'article' => BelgaArticleParser::class,
'article_page' => BelgaArticlePageParser::class,
],
],
'guardian' => [
@ -53,8 +61,8 @@
'en' => ['url' => 'https://www.theguardian.com/international/rss'],
],
'parsers' => [
'article' => \App\Services\Parsers\GuardianArticleParser::class,
'article_page' => \App\Services\Parsers\GuardianArticlePageParser::class,
'article' => GuardianArticleParser::class,
'article_page' => GuardianArticlePageParser::class,
],
],
],

View file

@ -1,5 +1,8 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
@ -76,9 +79,9 @@
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View file

@ -2,10 +2,12 @@
namespace Database\Factories;
use App\Models\Article;
use App\Models\Feed;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article>
* @extends Factory<Article>
*/
class ArticleFactory extends Factory
{
@ -17,7 +19,7 @@ class ArticleFactory extends Factory
public function definition(): array
{
return [
'feed_id' => \App\Models\Feed::factory(),
'feed_id' => Feed::factory(),
'url' => $this->faker->url(),
'title' => $this->faker->sentence(),
'description' => $this->faker->paragraph(),
@ -25,8 +27,6 @@ public function definition(): array
'image_url' => $this->faker->optional()->imageUrl(),
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
];
}
}

View file

@ -2,7 +2,9 @@
namespace Database\Factories;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\Factory;
class KeywordFactory extends Factory
@ -12,8 +14,8 @@ class KeywordFactory extends Factory
public function definition(): array
{
return [
'feed_id' => \App\Models\Feed::factory(),
'platform_channel_id' => \App\Models\PlatformChannel::factory(),
'feed_id' => Feed::factory(),
'platform_channel_id' => PlatformChannel::factory(),
'keyword' => $this->faker->word(),
'is_active' => $this->faker->boolean(70), // 70% chance of being active
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
@ -21,14 +23,14 @@ public function definition(): array
];
}
public function forFeed(\App\Models\Feed $feed): static
public function forFeed(Feed $feed): static
{
return $this->state(fn (array $attributes) => [
'feed_id' => $feed->id,
]);
}
public function forChannel(\App\Models\PlatformChannel $channel): static
public function forChannel(PlatformChannel $channel): static
{
return $this->state(fn (array $attributes) => [
'platform_channel_id' => $channel->id,

View file

@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Notification>
*/
class NotificationFactory extends Factory
{
protected $model = Notification::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'type' => fake()->randomElement(NotificationTypeEnum::cases()),
'severity' => fake()->randomElement(NotificationSeverityEnum::cases()),
'title' => fake()->sentence(3),
'message' => fake()->sentence(),
'data' => null,
'read_at' => null,
];
}
public function read(): static
{
return $this->state(['read_at' => now()]);
}
public function unread(): static
{
return $this->state(['read_at' => null]);
}
public function severity(NotificationSeverityEnum $severity): static
{
return $this->state(['severity' => $severity]);
}
public function type(NotificationTypeEnum $type): static
{
return $this->state(['type' => $type]);
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace Database\Factories;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Database\Eloquent\Factories\Factory;
class RouteArticleFactory extends Factory
{
protected $model = RouteArticle::class;
public function definition(): array
{
return [
'feed_id' => Feed::factory(),
'platform_channel_id' => PlatformChannel::factory(),
'article_id' => Article::factory(),
'approval_status' => ApprovalStatusEnum::PENDING,
'validated_at' => null,
];
}
public function configure(): static
{
return $this->afterMaking(function (RouteArticle $routeArticle) {
// Ensure a route exists for this feed+channel combination
Route::firstOrCreate(
[
'feed_id' => $routeArticle->feed_id,
'platform_channel_id' => $routeArticle->platform_channel_id,
],
[
'is_active' => true,
'priority' => 50,
]
);
// Ensure the article belongs to the same feed
if ($routeArticle->article_id) {
$article = Article::find($routeArticle->article_id);
if ($article && $article->feed_id !== $routeArticle->feed_id) {
$article->update(['feed_id' => $routeArticle->feed_id]);
}
}
});
}
public function forRoute(Route $route): static
{
return $this->state(fn (array $attributes) => [
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
]);
}
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::PENDING,
]);
}
public function approved(): static
{
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::APPROVED,
'validated_at' => now(),
]);
}
public function rejected(): static
{
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::REJECTED,
'validated_at' => now(),
]);
}
}

View file

@ -2,12 +2,13 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->id();
$table->string('type');
$table->string('severity');
$table->string('title');
$table->text('message');
$table->json('data')->nullable();
$table->string('notifiable_type')->nullable();
$table->unsignedBigInteger('notifiable_id')->nullable();
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('read_at');
$table->index('created_at');
$table->index(['notifiable_type', 'notifiable_id']);
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('route_articles', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('feed_id');
$table->unsignedBigInteger('platform_channel_id');
$table->foreignId('article_id')->constrained()->onDelete('cascade');
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending');
$table->timestamp('validated_at')->nullable();
$table->timestamps();
$table->foreign(['feed_id', 'platform_channel_id'])
->references(['feed_id', 'platform_channel_id'])
->on('routes')
->onDelete('cascade');
$table->unique(['feed_id', 'platform_channel_id', 'article_id'], 'route_articles_unique');
$table->index(['approval_status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('route_articles');
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('routes', function (Blueprint $table) {
$table->boolean('auto_approve')->nullable()->after('priority');
});
}
public function down(): void
{
Schema::table('routes', function (Blueprint $table) {
$table->dropColumn('auto_approve');
});
}
};

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Migrate existing article approval_status to route_articles
$validatedArticles = DB::table('articles')
->whereIn('approval_status', ['approved', 'rejected'])
->whereNotNull('validated_at')
->get();
foreach ($validatedArticles as $article) {
$routes = DB::table('routes')
->where('feed_id', $article->feed_id)
->where('is_active', true)
->get();
foreach ($routes as $route) {
$exists = DB::table('route_articles')
->where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->where('article_id', $article->id)
->exists();
if ($exists) {
continue;
}
DB::table('route_articles')->insert([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
'approval_status' => $article->approval_status,
'validated_at' => $article->validated_at,
'created_at' => $article->created_at,
'updated_at' => now(),
]);
}
}
// Remove approval_status column from articles
Schema::table('articles', function (Blueprint $table) {
$table->dropIndex(['published_at', 'approval_status']);
$table->dropColumn('approval_status');
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending')->after('feed_id');
$table->index(['published_at', 'approval_status']);
});
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('route_articles', function (Blueprint $table) {
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])
->default('unpublished')
->after('approval_status');
});
Schema::table('articles', function (Blueprint $table) {
$table->dropColumn('publish_status');
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])
->default('unpublished');
});
Schema::table('route_articles', function (Blueprint $table) {
$table->dropColumn('publish_status');
});
}
};

View file

@ -54,6 +54,30 @@ parameters:
count: 1
path: tests/Unit/Enums/PlatformEnumTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed\.$#'
identifier: property.notFound
count: 4
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed_id\.$#'
identifier: property.notFound
count: 4
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 1
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$platform_channel_id\.$#'
identifier: property.notFound
count: 4
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
-
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
identifier: property.notFound

View file

@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
<nav class="mt-5 flex-1 px-2 bg-white">
@include('layouts.navigation-items')
</nav>
<div class="flex-shrink-0 px-4 py-3 border-t border-gray-200">
<livewire:notification-bell />
</div>
<div class="flex-shrink-0 p-4 border-t border-gray-200">
<div class="flex items-center">
<div class="flex-1 min-w-0">
@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring
</button>
<div class="flex-1 px-4 flex justify-between items-center">
<h1 class="text-lg font-medium text-gray-900">FFR</h1>
<livewire:notification-bell />
</div>
</div>

View file

@ -1,19 +1,10 @@
<div class="p-6">
<div class="mb-8 flex items-start justify-between">
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Articles</h1>
<p class="mt-1 text-sm text-gray-500">
Manage and review articles from your feeds
Review and manage article routing
</p>
@if ($approvalsEnabled)
<div class="mt-2 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg>
Approval system enabled
</div>
@endif
</div>
<button
wire:click="refresh"
@ -30,74 +21,135 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
</button>
</div>
<div class="space-y-6">
@forelse ($articles as $article)
<div class="bg-white rounded-lg shadow p-6" wire:key="article-{{ $article->id }}">
{{-- Tab bar --}}
<div class="mb-6 border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button
wire:click="setTab('pending')"
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'pending' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}"
>
Pending
@if ($pendingCount > 0)
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ $pendingCount }}
</span>
@endif
</button>
<button
wire:click="setTab('all')"
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}"
>
All
</button>
</nav>
</div>
{{-- Tab actions --}}
<div class="mb-4 flex items-center justify-between">
@if ($tab === 'pending' && $pendingCount > 0)
<button
wire:click="clear"
wire:confirm="Reject all {{ $pendingCount }} pending route articles?"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Clear All
</button>
@elseif ($tab === 'all')
<div class="flex-1 max-w-sm">
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search articles..."
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
@else
<div></div>
@endif
</div>
{{-- Route articles list --}}
<div class="space-y-4">
@forelse ($routeArticles as $routeArticle)
<div class="bg-white rounded-lg shadow p-5" wire:key="ra-{{ $routeArticle->id }}">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ $article->title ?? 'Untitled Article' }}
<h3 class="text-base font-medium text-gray-900 mb-1">
{{ $routeArticle->article->title ?? 'Untitled Article' }}
</h3>
<p class="text-sm text-gray-600 mb-3 line-clamp-2">
{{ $article->description ?? 'No description available' }}
<p class="text-sm text-gray-600 mb-2 line-clamp-2">
{{ $routeArticle->article->description ?? 'No description available' }}
</p>
<div class="flex items-center space-x-4 text-xs text-gray-500">
<span>Feed: {{ $article->feed?->name ?? 'Unknown' }}</span>
<span>&bull;</span>
<span>{{ $article->created_at->format('M d, Y') }}</span>
</div>
</div>
<div class="flex items-center space-x-3 ml-4">
@if ($article->is_published)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 text-xs text-gray-500">
<span class="inline-flex items-center">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Published
</span>
@elseif ($article->publish_status === 'error')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Publish Error
</span>
@elseif ($article->publish_status === 'publishing')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
<svg class="h-3 w-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Publishing...
</span>
@elseif ($article->approval_status === 'approved')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Approved
</span>
@elseif ($article->approval_status === 'rejected')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Rejected
</span>
@else
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Pending
{{ $routeArticle->feed?->name ?? 'Unknown' }} &rarr; {{ $routeArticle->platformChannel?->name ?? 'Unknown' }}
</span>
<span>{{ $routeArticle->created_at->format('M d, Y H:i') }}</span>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
{{-- Status badge (All tab) --}}
@if ($tab === 'all')
@if ($routeArticle->isApproved())
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Approved
</span>
@elseif ($routeArticle->isRejected())
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Rejected
</span>
@else
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
@endif
@endif
@if ($article->url)
{{-- Action buttons --}}
@if ($routeArticle->isPending())
<button
wire:click="approve({{ $routeArticle->id }})"
class="inline-flex items-center p-1.5 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-md"
title="Approve"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
<button
wire:click="reject({{ $routeArticle->id }})"
class="inline-flex items-center p-1.5 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md"
title="Reject"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
@elseif ($routeArticle->isRejected())
<button
wire:click="restore({{ $routeArticle->id }})"
class="inline-flex items-center p-1.5 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md"
title="Restore to pending"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
</svg>
</button>
@endif
{{-- Link to original --}}
@if ($routeArticle->article->url)
<a
href="{{ $article->url }}"
href="{{ $routeArticle->article->url }}"
target="_blank"
rel="noopener noreferrer"
class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
class="p-1.5 text-gray-400 hover:text-gray-600 rounded-md"
title="View original article"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@ -107,47 +159,34 @@ class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
@endif
</div>
</div>
@if ($article->approval_status === 'pending' && $approvalsEnabled)
<div class="mt-4 flex space-x-3">
<button
wire:click="approve({{ $article->id }})"
wire:loading.attr="disabled"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Approve
</button>
<button
wire:click="reject({{ $article->id }})"
wire:loading.attr="disabled"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
Reject
</button>
</div>
@endif
</div>
@empty
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No articles</h3>
<h3 class="mt-2 text-sm font-medium text-gray-900">
@if ($tab === 'pending')
No pending articles
@else
No articles found
@endif
</h3>
<p class="mt-1 text-sm text-gray-500">
No articles have been fetched yet.
@if ($tab === 'pending')
All route articles have been reviewed.
@elseif ($search !== '')
No results for "{{ $search }}".
@else
No route articles have been created yet.
@endif
</p>
</div>
@endforelse
@if ($articles->hasPages())
@if ($routeArticles->hasPages())
<div class="mt-6">
{{ $articles->links() }}
{{ $routeArticles->links() }}
</div>
@endif
</div>

View file

@ -0,0 +1,90 @@
<div class="relative" x-data="{ open: false }">
<button
@click="open = !open"
class="relative p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Notifications"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
@if ($this->unreadCount > 0)
<span class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-500 rounded-full">
{{ $this->unreadCount > 99 ? '99+' : $this->unreadCount }}
</span>
@endif
</button>
<div
x-show="open"
@click.away="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-80 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"
style="display: none;"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
@if ($this->unreadCount > 0)
<button
wire:click="markAllAsRead"
class="text-xs text-blue-600 hover:text-blue-800"
>
Mark all as read
</button>
@endif
</div>
<div class="max-h-96 overflow-y-auto">
@forelse ($this->notifications as $notification)
<div
class="px-4 py-3 border-b border-gray-50 last:border-b-0 {{ $notification->isRead() ? 'bg-white' : 'bg-blue-50' }}"
wire:key="notification-{{ $notification->id }}"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
@if ($notification->severity === \App\Enums\NotificationSeverityEnum::ERROR)
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
@elseif ($notification->severity === \App\Enums\NotificationSeverityEnum::WARNING)
<svg class="h-5 w-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
@else
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
@endif
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">{{ $notification->title }}</p>
<p class="text-sm text-gray-500 mt-0.5">{{ $notification->message }}</p>
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
@unless ($notification->isRead())
<button
wire:click="markAsRead({{ $notification->id }})"
class="text-xs text-blue-600 hover:text-blue-800"
>
Mark read
</button>
@endunless
</div>
</div>
</div>
</div>
@empty
<div class="px-4 py-8 text-center">
<svg class="mx-auto h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
<p class="mt-2 text-sm text-gray-500">No notifications</p>
</div>
@endforelse
</div>
</div>
</div>

View file

@ -50,6 +50,13 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
<span>{{ $route->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }}</span>
<span>&bull;</span>
<span>Created: {{ $route->created_at->format('M d, Y') }}</span>
@if ($route->auto_approve === true)
<span>&bull;</span>
<span class="text-green-600">Auto-approve: On</span>
@elseif ($route->auto_approve === false)
<span>&bull;</span>
<span class="text-red-600">Auto-approve: Off</span>
@endif
</div>
@if ($route->platformChannel?->description)
<p class="mt-2 text-sm text-gray-500">
@ -265,6 +272,26 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
<p class="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
</div>
<!-- Auto-approve -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Auto-approve</label>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input type="radio" wire:model="editAutoApprove" name="editAutoApprove" value="" class="text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">Use global setting</span>
</label>
<label class="inline-flex items-center">
<input type="radio" wire:model="editAutoApprove" name="editAutoApprove" value="1" class="text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">On</span>
</label>
<label class="inline-flex items-center">
<input type="radio" wire:model="editAutoApprove" name="editAutoApprove" value="0" class="text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">Off</span>
</label>
</div>
<p class="text-sm text-gray-500 mt-1">Override global approval setting for this route</p>
</div>
<!-- Keyword Management -->
<div class="border-t pt-4">
<div class="flex items-center space-x-2 mb-3">

View file

@ -104,6 +104,51 @@ class="flex-shrink-0"
</div>
</div>
<!-- Feed Monitoring Settings -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900 flex items-center">
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Feed Monitoring
</h2>
<p class="mt-1 text-sm text-gray-500">
Configure alerts for feeds that stop returning articles
</p>
</div>
<div class="px-6 py-4 space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900">
Staleness Threshold (hours)
</h3>
<p class="text-sm text-gray-500">
Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable.
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
wire:model="feedStalenessThreshold"
min="0"
step="1"
class="w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
<button
wire:click="updateFeedStalenessThreshold"
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Save
</button>
</div>
</div>
@error('feedStalenessThreshold')
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<!-- Status Messages -->
@if ($successMessage)
<div class="bg-green-50 border border-green-200 rounded-md p-4">

View file

@ -9,6 +9,7 @@
use App\Http\Controllers\Api\V1\OnboardingController;
use App\Http\Controllers\Api\V1\PlatformAccountsController;
use App\Http\Controllers\Api\V1\PlatformChannelsController;
use App\Http\Controllers\Api\V1\RouteArticlesController;
use App\Http\Controllers\Api\V1\RoutingController;
use App\Http\Controllers\Api\V1\SettingsController;
use Illuminate\Support\Facades\Route;
@ -47,8 +48,6 @@
// Articles
Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index');
Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve');
Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject');
Route::post('/articles/refresh', [ArticlesController::class, 'refresh'])->name('api.articles.refresh');
// Platform Accounts
@ -104,6 +103,13 @@
Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy');
Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle');
// Route Articles
Route::get('/route-articles', [RouteArticlesController::class, 'index'])->name('api.route-articles.index');
Route::post('/route-articles/clear', [RouteArticlesController::class, 'clear'])->name('api.route-articles.clear');
Route::post('/route-articles/{routeArticle}/approve', [RouteArticlesController::class, 'approve'])->name('api.route-articles.approve');
Route::post('/route-articles/{routeArticle}/reject', [RouteArticlesController::class, 'reject'])->name('api.route-articles.reject');
Route::post('/route-articles/{routeArticle}/restore', [RouteArticlesController::class, 'restore'])->name('api.route-articles.restore');
// Settings
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');

View file

@ -1,6 +1,8 @@
<?php
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\CheckFeedStalenessJob;
use App\Jobs\CleanupArticlesJob;
use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob;
use Illuminate\Support\Facades\Schedule;
@ -20,3 +22,15 @@
->name('refresh-articles')
->withoutOverlapping()
->onOneServer();
Schedule::job(new CheckFeedStalenessJob)
->hourly()
->name('check-feed-staleness')
->withoutOverlapping()
->onOneServer();
Schedule::job(new CleanupArticlesJob)
->daily()
->name('cleanup-old-articles')
->withoutOverlapping()
->onOneServer();

View file

@ -16,6 +16,8 @@
use App\Models\Route;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -134,14 +136,12 @@ public function test_article_model_creates_successfully(): void
'feed_id' => $feed->id,
'title' => 'Test Article',
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
$this->assertDatabaseHas('articles', [
'feed_id' => $feed->id,
'title' => 'Test Article',
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
$this->assertEquals($feed->id, $article->feed->id);
@ -334,7 +334,7 @@ public function test_model_soft_deletes_work_correctly(): void
public function test_database_constraints_are_enforced(): void
{
// Test foreign key constraints
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectException(QueryException::class);
// Try to create article with non-existent feed_id
Article::factory()->create(['feed_id' => 99999]);
@ -357,7 +357,7 @@ public function test_all_factories_work_correctly(): void
foreach ($models as $model) {
$this->assertNotNull($model);
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Model::class, $model);
$this->assertInstanceOf(Model::class, $model);
}
}
}

View file

@ -102,60 +102,6 @@ public function test_index_orders_articles_by_created_at_desc(): void
$this->assertEquals('First Article', $articles[1]['title']);
}
public function test_approve_article_successfully(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$response = $this->postJson("/api/v1/articles/{$article->id}/approve");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Article approved and queued for publishing.',
]);
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_approve_nonexistent_article_returns_404(): void
{
$response = $this->postJson('/api/v1/articles/999/approve');
$response->assertStatus(404);
}
public function test_reject_article_successfully(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$response = $this->postJson("/api/v1/articles/{$article->id}/reject");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Article rejected.',
]);
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_reject_nonexistent_article_returns_404(): void
{
$response = $this->postJson('/api/v1/articles/999/reject');
$response->assertStatus(404);
}
public function test_index_includes_settings(): void
{
$response = $this->getJson('/api/v1/articles');

View file

@ -0,0 +1,253 @@
<?php
namespace Tests\Feature\Http\Controllers\Api\V1;
use App\Enums\ApprovalStatusEnum;
use App\Events\RouteArticleApproved;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class RouteArticlesControllerTest extends TestCase
{
use RefreshDatabase;
private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusEnum::PENDING): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create(['feed_id' => $feed->id]);
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->create([
'article_id' => $article->id,
'approval_status' => $status,
'validated_at' => now(),
]);
return $routeArticle;
}
public function test_index_returns_successful_response(): void
{
$response = $this->getJson('/api/v1/route-articles');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'route_articles',
'pagination' => [
'current_page',
'last_page',
'per_page',
'total',
'from',
'to',
],
],
'message',
]);
}
public function test_index_returns_route_articles_with_pagination(): void
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
for ($i = 0; $i < 20; $i++) {
$article = Article::factory()->create(['feed_id' => $feed->id]);
RouteArticle::factory()->forRoute($route)->create([
'article_id' => $article->id,
'validated_at' => now(),
]);
}
$response = $this->getJson('/api/v1/route-articles?per_page=10');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'pagination' => [
'per_page' => 10,
'total' => 20,
'last_page' => 2,
],
],
]);
$this->assertCount(10, $response->json('data.route_articles'));
}
public function test_index_filters_by_status(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
$this->createRouteArticle(ApprovalStatusEnum::REJECTED);
$response = $this->getJson('/api/v1/route-articles?status=pending');
$response->assertStatus(200);
$this->assertCount(2, $response->json('data.route_articles'));
}
public function test_index_returns_all_when_no_status_filter(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
$this->createRouteArticle(ApprovalStatusEnum::REJECTED);
$response = $this->getJson('/api/v1/route-articles');
$response->assertStatus(200);
$this->assertCount(3, $response->json('data.route_articles'));
}
public function test_index_includes_article_and_route_data(): void
{
$routeArticle = $this->createRouteArticle();
$response = $this->getJson('/api/v1/route-articles');
$response->assertStatus(200);
$data = $response->json('data.route_articles.0');
$this->assertArrayHasKey('article', $data);
$this->assertArrayHasKey('title', $data['article']);
$this->assertArrayHasKey('url', $data['article']);
$this->assertArrayHasKey('route_name', $data);
$this->assertArrayHasKey('approval_status', $data);
}
public function test_approve_route_article_successfully(): void
{
Event::fake([RouteArticleApproved::class]);
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/approve");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Route article approved and queued for publishing.',
]);
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
Event::assertDispatched(RouteArticleApproved::class, function ($event) use ($routeArticle) {
return $event->routeArticle->id === $routeArticle->id;
});
}
public function test_approve_nonexistent_route_article_returns_404(): void
{
$response = $this->postJson('/api/v1/route-articles/999/approve');
$response->assertStatus(404);
}
public function test_reject_route_article_successfully(): void
{
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/reject");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Route article rejected.',
]);
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status);
}
public function test_reject_nonexistent_route_article_returns_404(): void
{
$response = $this->postJson('/api/v1/route-articles/999/reject');
$response->assertStatus(404);
}
public function test_restore_route_article_successfully(): void
{
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED);
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/restore");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Route article restored to pending.',
]);
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status);
}
public function test_restore_nonexistent_route_article_returns_404(): void
{
$response = $this->postJson('/api/v1/route-articles/999/restore');
$response->assertStatus(404);
}
public function test_clear_rejects_all_pending_route_articles(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
$response = $this->postJson('/api/v1/route-articles/clear');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'rejected_count' => 3,
],
]);
$this->assertEquals(0, RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count());
$this->assertEquals(1, RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)->count());
$this->assertEquals(3, RouteArticle::where('approval_status', ApprovalStatusEnum::REJECTED)->count());
}
public function test_clear_returns_zero_when_no_pending(): void
{
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
$response = $this->postJson('/api/v1/route-articles/clear');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'rejected_count' => 0,
],
]);
}
public function test_index_respects_per_page_limit(): void
{
$response = $this->getJson('/api/v1/route-articles?per_page=150');
$response->assertStatus(200)
->assertJson([
'data' => [
'pagination' => [
'per_page' => 100,
],
],
]);
}
}

View file

@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Jobs\CheckFeedStalenessJob;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Setting;
use App\Services\Notification\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CheckFeedStalenessJobTest extends TestCase
{
use RefreshDatabase;
public function test_stale_feed_creates_notification(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
$this->dispatch();
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::FEED_STALE->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
}
public function test_fresh_feed_does_not_create_notification(): void
{
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(10),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_inactive_feed_does_not_create_notification(): void
{
Feed::factory()->create([
'is_active' => false,
'last_fetched_at' => now()->subHours(100),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_does_not_create_duplicate_notification_when_unread_exists(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
Notification::factory()
->type(NotificationTypeEnum::FEED_STALE)
->unread()
->create([
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 1);
}
public function test_creates_new_notification_when_previous_is_read(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
Notification::factory()
->type(NotificationTypeEnum::FEED_STALE)
->read()
->create([
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 2);
}
public function test_threshold_zero_disables_check(): void
{
Setting::setFeedStalenessThreshold(0);
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(100),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_never_fetched_feed_creates_notification(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => null,
]);
$this->dispatch();
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::FEED_STALE->value,
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
}
private function dispatch(): void
{
(new CheckFeedStalenessJob)->handle(app(NotificationService::class));
}
}

View file

@ -2,24 +2,26 @@
namespace Tests\Feature;
use App\Enums\ApprovalStatusEnum;
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use App\Events\ArticleApproved;
// use App\Events\ArticleReadyToPublish; // Class no longer exists
use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched;
use App\Events\RouteArticleApproved;
use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob;
use App\Listeners\LogExceptionToDatabase;
// use App\Listeners\PublishApprovedArticle; // Class no longer exists
// use App\Listeners\PublishArticle; // Class no longer exists
use App\Listeners\ValidateArticleListener;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\Log;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
@ -61,14 +63,14 @@ public function test_article_discovery_for_feed_job_processes_feed(): void
]);
// Mock the ArticleFetcher service in the container
$mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class);
$mockFetcher = \Mockery::mock(ArticleFetcher::class);
$article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]);
$article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]);
$mockFetcher->shouldReceive('getArticlesFromFeed')
->with($feed)
->andReturn(collect([$article1, $article2]));
$this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
$this->app->instance(ArticleFetcher::class, $mockFetcher);
$logSaver = app(LogSaver::class);
$articleFetcher = app(ArticleFetcher::class);
@ -116,40 +118,13 @@ public function test_new_article_fetched_event_is_dispatched(): void
});
}
public function test_article_approved_event_is_dispatched(): void
{
Event::fake();
$article = Article::factory()->create();
event(new ArticleApproved($article));
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) {
return $event->article->id === $article->id;
});
}
// Test removed - ArticleReadyToPublish class no longer exists
// public function test_article_ready_to_publish_event_is_dispatched(): void
// {
// Event::fake();
// $article = Article::factory()->create();
// event(new ArticleReadyToPublish($article));
// Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) {
// return $event->article->id === $article->id;
// });
// }
public function test_exception_occurred_event_is_dispatched(): void
{
Event::fake();
$exception = new \Exception('Test exception');
event(new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception', ['context' => 'test']));
event(new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception', ['context' => 'test']));
Event::assertDispatched(ExceptionOccurred::class, function (ExceptionOccurred $event) {
return $event->exception->getMessage() === 'Test exception';
@ -175,20 +150,25 @@ public function test_exception_logged_event_is_dispatched(): void
public function test_validate_article_listener_processes_new_article(): void
{
Event::fake([ArticleApproved::class]);
// Disable approvals so listener auto-approves valid articles
Setting::setBool('enable_publishing_approvals', false);
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
Keyword::factory()->active()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $route->platform_channel_id,
'keyword' => 'Belgium',
]);
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
// Mock ArticleFetcher to return valid article data
$mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class);
$this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
$mockFetcher = \Mockery::mock(ArticleFetcher::class);
$this->app->instance(ArticleFetcher::class, $mockFetcher);
$mockFetcher->shouldReceive('fetchArticleData')
->with($article)
->andReturn([
@ -203,45 +183,13 @@ public function test_validate_article_listener_processes_new_article(): void
$listener->handle($event);
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
Event::assertDispatched(ArticleApproved::class);
$this->assertNotNull($article->validated_at);
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
$this->assertNotNull($routeArticle);
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
}
// Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist
// public function test_publish_approved_article_listener_queues_job(): void
// {
// Event::fake();
// $article = Article::factory()->create([
// 'approval_status' => 'approved',
// 'approval_status' => 'approved',
// ]);
// $listener = new PublishApprovedArticle();
// $event = new ArticleApproved($article);
// $listener->handle($event);
// Event::assertDispatched(ArticleReadyToPublish::class);
// }
// Test removed - PublishArticle and ArticleReadyToPublish classes no longer exist
// public function test_publish_article_listener_queues_publish_job(): void
// {
// Queue::fake();
// $article = Article::factory()->create([
// 'approval_status' => 'approved',
// ]);
// $listener = new PublishArticle();
// $event = new ArticleReadyToPublish($article);
// $listener->handle($event);
// Queue::assertPushed(PublishNextArticleJob::class);
// }
public function test_log_exception_to_database_listener_creates_log(): void
{
$log = Log::factory()->create([
@ -252,7 +200,7 @@ public function test_log_exception_to_database_listener_creates_log(): void
$listener = new LogExceptionToDatabase;
$exception = new \Exception('Test exception message');
$event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message');
$event = new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception message');
$listener->handle($event);
@ -263,7 +211,7 @@ public function test_log_exception_to_database_listener_creates_log(): void
$savedLog = Log::where('message', 'Test exception message')->first();
$this->assertNotNull($savedLog);
$this->assertEquals(\App\Enums\LogLevelEnum::ERROR, $savedLog->level);
$this->assertEquals(LogLevelEnum::ERROR, $savedLog->level);
}
public function test_event_listener_registration_works(): void
@ -275,13 +223,8 @@ public function test_event_listener_registration_works(): void
$listeners = Event::getListeners(NewArticleFetched::class);
$this->assertNotEmpty($listeners);
// ArticleApproved event exists but has no listeners after publishing redesign
// $listeners = Event::getListeners(ArticleApproved::class);
// $this->assertNotEmpty($listeners);
// ArticleReadyToPublish no longer exists - removed this check
// $listeners = Event::getListeners(ArticleReadyToPublish::class);
// $this->assertNotEmpty($listeners);
$listeners = Event::getListeners(RouteArticleApproved::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ExceptionOccurred::class);
$this->assertNotEmpty($listeners);

View file

@ -0,0 +1,149 @@
<?php
namespace Tests\Feature\Listeners;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PublishApprovedArticleListenerTest extends TestCase
{
use RefreshDatabase;
private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([
'feed_id' => $feed->id,
'title' => $title,
]);
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $article->id,
]);
return $routeArticle;
}
public function test_exception_during_publishing_creates_error_notification(): void
{
$routeArticle = $this->createApprovedRouteArticle();
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andThrow(new Exception('Connection refused'));
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::ERROR->value,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
$this->assertStringContainsString('Test Article', $notification->title);
$this->assertStringContainsString('Connection refused', $notification->message);
}
public function test_no_publication_created_creates_warning_notification(): void
{
$routeArticle = $this->createApprovedRouteArticle();
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(null);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
$this->assertStringContainsString('Test Article', $notification->title);
}
public function test_successful_publish_does_not_create_notification(): void
{
$routeArticle = $this->createApprovedRouteArticle();
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseCount('notifications', 0);
}
public function test_skips_already_published_to_channel(): void
{
$routeArticle = $this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'article_id' => $routeArticle->article_id,
'platform_channel_id' => $routeArticle->platform_channel_id,
]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertTrue(true);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace Tests\Feature\Livewire;
use App\Enums\ApprovalStatusEnum;
use App\Events\RouteArticleApproved;
use App\Livewire\Articles;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
use Tests\TestCase;
class ArticlesTest extends TestCase
{
use RefreshDatabase;
private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusEnum::PENDING, string $title = 'Test Article'): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'title' => $title]);
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->create([
'article_id' => $article->id,
'approval_status' => $status,
'validated_at' => now(),
]);
return $routeArticle;
}
public function test_renders_successfully(): void
{
Livewire::test(Articles::class)
->assertStatus(200);
}
public function test_defaults_to_pending_tab(): void
{
Livewire::test(Articles::class)
->assertSet('tab', 'pending');
}
public function test_pending_tab_shows_only_pending_route_articles(): void
{
$pending = $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article');
$approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article');
$rejected = $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article');
Livewire::test(Articles::class)
->assertSee('Pending Article')
->assertDontSee('Approved Article')
->assertDontSee('Rejected Article');
}
public function test_all_tab_shows_all_route_articles(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article');
$this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article');
$this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article');
Livewire::test(Articles::class)
->call('setTab', 'all')
->assertSee('Pending Article')
->assertSee('Approved Article')
->assertSee('Rejected Article');
}
public function test_all_tab_search_filters_by_title(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Belgian Politics Update');
$this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Weather Forecast Today');
Livewire::test(Articles::class)
->call('setTab', 'all')
->set('search', 'Belgian')
->assertSee('Belgian Politics Update')
->assertDontSee('Weather Forecast Today');
}
public function test_approve_changes_status_and_dispatches_event(): void
{
Event::fake([RouteArticleApproved::class]);
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
Livewire::test(Articles::class)
->call('approve', $routeArticle->id);
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
Event::assertDispatched(RouteArticleApproved::class);
}
public function test_reject_changes_status(): void
{
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
Livewire::test(Articles::class)
->call('reject', $routeArticle->id);
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status);
}
public function test_restore_changes_status_back_to_pending(): void
{
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED);
Livewire::test(Articles::class)
->call('restore', $routeArticle->id);
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status);
}
public function test_clear_rejects_all_pending_route_articles(): void
{
$pending1 = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
$pending2 = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
$approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED);
Livewire::test(Articles::class)
->call('clear');
$this->assertEquals(ApprovalStatusEnum::REJECTED, $pending1->fresh()->approval_status);
$this->assertEquals(ApprovalStatusEnum::REJECTED, $pending2->fresh()->approval_status);
$this->assertEquals(ApprovalStatusEnum::APPROVED, $approved->fresh()->approval_status);
}
public function test_pending_count_badge_shows_correct_count(): void
{
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
Livewire::test(Articles::class)
->assertSeeInOrder(['Pending', '2']);
}
public function test_switching_tabs_resets_search(): void
{
Livewire::test(Articles::class)
->call('setTab', 'all')
->set('search', 'something')
->call('setTab', 'pending')
->assertSet('search', '')
->assertSet('tab', 'pending');
}
public function test_shows_route_name_in_listing(): void
{
$feed = Feed::factory()->create(['name' => 'VRT News']);
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create(['feed_id' => $feed->id, 'title' => 'Test']);
RouteArticle::factory()->forRoute($route)->create([
'article_id' => $article->id,
'approval_status' => ApprovalStatusEnum::PENDING,
'validated_at' => now(),
]);
Livewire::test(Articles::class)
->assertSee('VRT News');
}
public function test_empty_state_on_pending_tab(): void
{
Livewire::test(Articles::class)
->assertSee('No pending articles');
}
public function test_empty_state_on_all_tab(): void
{
Livewire::test(Articles::class)
->call('setTab', 'all')
->assertSee('No route articles have been created yet.');
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\NotificationBell;
use App\Models\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class NotificationBellTest extends TestCase
{
use RefreshDatabase;
public function test_it_renders_successfully(): void
{
Livewire::test(NotificationBell::class)
->assertStatus(200)
->assertSee('Notifications');
}
public function test_it_shows_unread_count_badge(): void
{
Notification::factory()->count(3)->unread()->create();
Livewire::test(NotificationBell::class)
->assertSee('3');
}
public function test_it_hides_badge_when_no_unread(): void
{
Notification::factory()->count(2)->read()->create();
Livewire::test(NotificationBell::class)
->assertDontSee('Mark all as read');
}
public function test_it_shows_empty_state_when_no_notifications(): void
{
Livewire::test(NotificationBell::class)
->assertSee('No notifications');
}
public function test_mark_as_read(): void
{
$notification = Notification::factory()->unread()->create();
Livewire::test(NotificationBell::class)
->call('markAsRead', $notification->id);
$this->assertTrue($notification->fresh()->isRead());
}
public function test_mark_all_as_read(): void
{
Notification::factory()->count(3)->unread()->create();
$this->assertEquals(3, Notification::unread()->count());
Livewire::test(NotificationBell::class)
->call('markAllAsRead');
$this->assertEquals(0, Notification::unread()->count());
}
public function test_it_displays_notification_title_and_message(): void
{
Notification::factory()->create([
'title' => 'Test Notification Title',
'message' => 'Test notification message body',
]);
Livewire::test(NotificationBell::class)
->assertSee('Test Notification Title')
->assertSee('Test notification message body');
}
public function test_it_caps_badge_at_99_plus(): void
{
Notification::factory()->count(100)->unread()->create();
Livewire::test(NotificationBell::class)
->assertSee('99+');
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\Routes;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class RoutesAutoApproveTest extends TestCase
{
use RefreshDatabase;
private function createRoute(?bool $autoApprove = null): Route
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
return Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
'auto_approve' => $autoApprove,
]);
}
public function test_edit_modal_loads_auto_approve_null(): void
{
$route = $this->createRoute(null);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->assertSet('editAutoApprove', '');
}
public function test_edit_modal_loads_auto_approve_true(): void
{
$route = $this->createRoute(true);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->assertSet('editAutoApprove', '1');
}
public function test_edit_modal_loads_auto_approve_false(): void
{
$route = $this->createRoute(false);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->assertSet('editAutoApprove', '0');
}
public function test_update_route_sets_auto_approve_to_true(): void
{
$route = $this->createRoute(null);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->set('editAutoApprove', '1')
->call('updateRoute');
$updated = Route::where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->first();
$this->assertTrue($updated->auto_approve);
}
public function test_update_route_sets_auto_approve_to_false(): void
{
$route = $this->createRoute(null);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->set('editAutoApprove', '0')
->call('updateRoute');
$updated = Route::where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->first();
$this->assertFalse($updated->auto_approve);
}
public function test_update_route_sets_auto_approve_to_null(): void
{
$route = $this->createRoute(true);
Livewire::test(Routes::class)
->call('openEditModal', $route->feed_id, $route->platform_channel_id)
->set('editAutoApprove', '')
->call('updateRoute');
$updated = Route::where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->first();
$this->assertNull($updated->auto_approve);
}
public function test_route_card_shows_auto_approve_on_badge(): void
{
$this->createRoute(true);
Livewire::test(Routes::class)
->assertSee('Auto-approve: On');
}
public function test_route_card_shows_auto_approve_off_badge(): void
{
$this->createRoute(false);
Livewire::test(Routes::class)
->assertSee('Auto-approve: Off');
}
public function test_route_card_hides_badge_when_using_global_setting(): void
{
$this->createRoute(null);
Livewire::test(Routes::class)
->assertDontSee('Auto-approve: On')
->assertDontSee('Auto-approve: Off');
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\Settings;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
public function test_mount_loads_feed_staleness_threshold(): void
{
Setting::setFeedStalenessThreshold(72);
Livewire::test(Settings::class)
->assertSet('feedStalenessThreshold', 72);
}
public function test_mount_loads_default_feed_staleness_threshold(): void
{
Livewire::test(Settings::class)
->assertSet('feedStalenessThreshold', 48);
}
public function test_update_feed_staleness_threshold_saves_value(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', 24)
->call('updateFeedStalenessThreshold')
->assertHasNoErrors();
$this->assertSame(24, Setting::getFeedStalenessThreshold());
}
public function test_update_feed_staleness_threshold_validates_minimum(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', -1)
->call('updateFeedStalenessThreshold')
->assertHasErrors(['feedStalenessThreshold' => 'min']);
}
public function test_update_feed_staleness_threshold_shows_success_message(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', 24)
->call('updateFeedStalenessThreshold')
->assertSet('successMessage', 'Settings updated successfully!');
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace Tests\Feature;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Feed;
use App\Models\Notification;
use App\Services\Notification\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class NotificationTest extends TestCase
{
use RefreshDatabase;
private NotificationService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new NotificationService;
}
public function test_send_creates_notification_with_all_fields(): void
{
$notification = $this->service->send(
NotificationTypeEnum::FEED_STALE,
NotificationSeverityEnum::WARNING,
'Feed is stale',
'Feed "VRT" has not returned new articles in 24 hours.',
data: ['hours_stale' => 24],
);
$this->assertDatabaseHas('notifications', [
'type' => 'feed_stale',
'severity' => 'warning',
'title' => 'Feed is stale',
]);
$this->assertEquals(['hours_stale' => 24], $notification->data);
$this->assertNull($notification->read_at);
}
public function test_info_convenience_method(): void
{
$notification = $this->service->info('Test title', 'Test message');
$this->assertEquals(NotificationTypeEnum::GENERAL, $notification->type);
$this->assertEquals(NotificationSeverityEnum::INFO, $notification->severity);
}
public function test_warning_convenience_method(): void
{
$notification = $this->service->warning('Warning title', 'Warning message');
$this->assertEquals(NotificationSeverityEnum::WARNING, $notification->severity);
}
public function test_error_convenience_method(): void
{
$notification = $this->service->error('Error title', 'Error message');
$this->assertEquals(NotificationSeverityEnum::ERROR, $notification->severity);
}
public function test_mark_as_read(): void
{
$notification = Notification::factory()->unread()->create();
$this->assertFalse($notification->isRead());
$notification->markAsRead();
$this->assertTrue($notification->fresh()->isRead());
$this->assertNotNull($notification->fresh()->read_at);
}
public function test_mark_all_as_read(): void
{
Notification::factory()->count(3)->unread()->create();
Notification::factory()->count(2)->read()->create();
$this->assertEquals(3, Notification::unread()->count());
Notification::markAllAsRead();
$this->assertEquals(0, Notification::unread()->count());
}
public function test_unread_scope(): void
{
Notification::factory()->count(2)->unread()->create();
Notification::factory()->count(3)->read()->create();
$this->assertEquals(2, Notification::unread()->count());
}
public function test_recent_scope_limits_to_50(): void
{
Notification::factory()->count(60)->create();
$this->assertCount(50, Notification::recent()->get());
}
public function test_enums_cast_correctly(): void
{
$notification = Notification::factory()->create([
'type' => NotificationTypeEnum::PUBLISH_FAILED,
'severity' => NotificationSeverityEnum::ERROR,
]);
$fresh = Notification::find($notification->id);
$this->assertInstanceOf(NotificationTypeEnum::class, $fresh->type);
$this->assertInstanceOf(NotificationSeverityEnum::class, $fresh->severity);
$this->assertEquals(NotificationTypeEnum::PUBLISH_FAILED, $fresh->type);
$this->assertEquals(NotificationSeverityEnum::ERROR, $fresh->severity);
}
public function test_notifiable_morph_to_relationship(): void
{
$feed = Feed::factory()->create();
$notification = $this->service->send(
NotificationTypeEnum::FEED_STALE,
NotificationSeverityEnum::WARNING,
'Feed stale',
'No new articles',
notifiable: $feed,
);
$fresh = Notification::find($notification->id);
$this->assertInstanceOf(Feed::class, $fresh->notifiable);
$this->assertEquals($feed->id, $fresh->notifiable->id);
}
public function test_notification_without_notifiable(): void
{
$notification = $this->service->info('General notice', 'Something happened');
$this->assertNull($notification->notifiable_type);
$this->assertNull($notification->notifiable_id);
$this->assertNull($notification->notifiable);
}
public function test_notification_type_enum_labels(): void
{
$this->assertEquals('General', NotificationTypeEnum::GENERAL->label());
$this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label());
$this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label());
$this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label());
}
public function test_data_stores_null_when_empty(): void
{
$notification = $this->service->info('Title', 'Message');
$this->assertNull($notification->data);
}
public function test_data_stores_array_when_provided(): void
{
$notification = $this->service->info('Title', 'Message', data: ['key' => 'value']);
$this->assertEquals(['key' => 'value'], $notification->data);
}
}

View file

@ -2,82 +2,96 @@
namespace Tests\Feature;
use App\Events\ArticleApproved;
use App\Enums\ApprovalStatusEnum;
use App\Events\NewArticleFetched;
use App\Listeners\ValidateArticleListener;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Article\ValidationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Mockery;
use Tests\TestCase;
class ValidateArticleListenerTest extends TestCase
{
use RefreshDatabase;
public function test_listener_validates_article_and_dispatches_ready_to_publish_event(): void
private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener
{
Event::fake([ArticleApproved::class]);
$articleFetcher = Mockery::mock(ArticleFetcher::class);
$articleFetcher->shouldReceive('fetchArticleData')->andReturn(
$content ? [
'title' => 'Test Title',
'description' => 'Test description',
'full_article' => $content,
] : []
);
// Mock HTTP requests
Http::fake([
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
return new ValidateArticleListener(
new ValidationService($articleFetcher)
);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_listener_validates_article_and_creates_route_articles(): void
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
Keyword::factory()->active()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $route->platform_channel_id,
'keyword' => 'Belgium',
]);
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener->handle($event);
$listener = $this->createListenerWithMockedFetcher('Article about Belgium');
$listener->handle(new NewArticleFetched($article));
$article->refresh();
$this->assertNotNull($article->validated_at);
if ($article->isValid()) {
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) {
return $event->article->id === $article->id;
});
} else {
Event::assertNotDispatched(ArticleApproved::class);
}
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
$this->assertNotNull($routeArticle);
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
}
public function test_listener_skips_already_validated_articles(): void
{
Event::fake([ArticleApproved::class]);
$feed = Feed::factory()->create();
Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'approved',
'validated_at' => now(),
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener = $this->createListenerWithMockedFetcher();
$listener->handle(new NewArticleFetched($article));
$listener->handle($event);
Event::assertNotDispatched(ArticleApproved::class);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
}
public function test_listener_skips_articles_with_existing_publication(): void
{
Event::fake([ArticleApproved::class]);
$feed = Feed::factory()->create();
Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
ArticlePublication::create([
@ -88,38 +102,29 @@ public function test_listener_skips_articles_with_existing_publication(): void
'published_by' => 'test-user',
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener = $this->createListenerWithMockedFetcher();
$listener->handle(new NewArticleFetched($article));
$listener->handle($event);
Event::assertNotDispatched(ArticleApproved::class);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
}
public function test_listener_calls_validation_service(): void
public function test_listener_handles_validation_errors_gracefully(): void
{
Event::fake([ArticleApproved::class]);
$articleFetcher = Mockery::mock(ArticleFetcher::class);
$articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed'));
// Mock HTTP requests
Http::fake([
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
]);
$listener = new ValidateArticleListener(
new ValidationService($articleFetcher)
);
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener->handle(new NewArticleFetched($article));
$listener->handle($event);
// Verify that the article was processed by ValidationService
$article->refresh();
$this->assertNotEquals('pending', $article->approval_status, 'Article should have been validated');
$this->assertContains($article->approval_status, ['approved', 'rejected'], 'Article should have validation result');
$this->assertNull($article->fresh()->validated_at);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
}
}

View file

@ -5,6 +5,7 @@
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use Mockery;
use Mockery\MockInterface;
trait CreatesArticleFetcher
{
@ -21,7 +22,7 @@ protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetc
return new ArticleFetcher($logSaver);
}
/** @return array{ArticleFetcher, \Mockery\MockInterface} */
/** @return array{ArticleFetcher, MockInterface} */
protected function createArticleFetcherWithMockedLogSaver(): array
{
$logSaver = Mockery::mock(LogSaver::class);

View file

@ -10,6 +10,7 @@
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class CreatePlatformAccountActionTest extends TestCase
@ -18,7 +19,7 @@ class CreatePlatformAccountActionTest extends TestCase
private CreatePlatformAccountAction $action;
/** @var LemmyAuthService&\Mockery\MockInterface */
/** @var LemmyAuthService&MockInterface */
private LemmyAuthService $lemmyAuthService;
protected function setUp(): void

View file

@ -5,6 +5,7 @@
use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Tests\TestCase;
class ActionPerformedTest extends TestCase
@ -41,7 +42,7 @@ public function test_event_uses_dispatchable_trait(): void
public function test_event_does_not_use_serializes_models_trait(): void
{
$this->assertNotContains(
\Illuminate\Queue\SerializesModels::class,
SerializesModels::class,
class_uses(ActionPerformed::class),
);
}

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Exceptions;
use App\Exceptions\RoutingException;
use App\Exceptions\RoutingMismatchException;
use App\Models\Feed;
use App\Models\Language;
@ -52,7 +53,7 @@ public function test_exception_extends_routing_exception(): void
$exception = new RoutingMismatchException($feed, $channel);
// Assert
$this->assertInstanceOf(\App\Exceptions\RoutingException::class, $exception);
$this->assertInstanceOf(RoutingException::class, $exception);
}
public function test_exception_with_different_languages(): void

View file

@ -6,6 +6,8 @@
use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Mockery;
@ -34,7 +36,7 @@ public function test_job_implements_should_queue(): void
$feed = Feed::factory()->make();
$job = new ArticleDiscoveryForFeedJob($feed);
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
$this->assertInstanceOf(ShouldQueue::class, $job);
}
public function test_job_uses_queueable_trait(): void
@ -43,7 +45,7 @@ public function test_job_uses_queueable_trait(): void
$job = new ArticleDiscoveryForFeedJob($feed);
$this->assertContains(
\Illuminate\Foundation\Queue\Queueable::class,
Queueable::class,
class_uses($job)
);
}

View file

@ -5,6 +5,7 @@
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Setting;
use App\Services\Log\LogSaver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Mockery;
@ -100,7 +101,7 @@ public function test_job_implements_should_queue(): void
$job = new ArticleDiscoveryJob;
// Assert
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
$this->assertInstanceOf(ShouldQueue::class, $job);
}
public function test_job_uses_queueable_trait(): void

View file

@ -0,0 +1,111 @@
<?php
namespace Tests\Unit\Jobs;
use App\Enums\ApprovalStatusEnum;
use App\Jobs\CleanupArticlesJob;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CleanupArticlesJobTest extends TestCase
{
use RefreshDatabase;
public function test_deletes_old_articles_with_no_route_articles(): void
{
$old = Article::factory()->for(Feed::factory())->create([
'created_at' => now()->subDays(31),
]);
$recent = Article::factory()->for(Feed::factory())->create([
'created_at' => now()->subDays(10),
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseMissing('articles', ['id' => $old->id]);
$this->assertDatabaseHas('articles', ['id' => $recent->id]);
}
public function test_preserves_old_articles_with_pending_route_articles(): void
{
$route = Route::factory()->create();
$article = Article::factory()->for($route->feed)->create([
'created_at' => now()->subDays(31),
]);
RouteArticle::factory()->for($article)->create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'approval_status' => ApprovalStatusEnum::PENDING,
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseHas('articles', ['id' => $article->id]);
}
public function test_preserves_old_articles_with_approved_route_articles(): void
{
$route = Route::factory()->create();
$article = Article::factory()->for($route->feed)->create([
'created_at' => now()->subDays(31),
]);
RouteArticle::factory()->for($article)->create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'approval_status' => ApprovalStatusEnum::APPROVED,
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseHas('articles', ['id' => $article->id]);
}
public function test_deletes_old_articles_with_only_rejected_route_articles(): void
{
$route = Route::factory()->create();
$article = Article::factory()->for($route->feed)->create([
'created_at' => now()->subDays(31),
]);
RouteArticle::factory()->for($article)->create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'approval_status' => ApprovalStatusEnum::REJECTED,
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseMissing('articles', ['id' => $article->id]);
}
public function test_cascade_deletes_route_articles(): void
{
$route = Route::factory()->create();
$article = Article::factory()->for($route->feed)->create([
'created_at' => now()->subDays(31),
]);
$routeArticle = RouteArticle::factory()->for($article)->create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'approval_status' => ApprovalStatusEnum::REJECTED,
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseMissing('route_articles', ['id' => $routeArticle->id]);
}
public function test_preserves_article_at_exact_retention_boundary(): void
{
$boundary = Article::factory()->for(Feed::factory())->create([
'created_at' => now()->subDays(30),
]);
(new CleanupArticlesJob)->handle();
$this->assertDatabaseHas('articles', ['id' => $boundary->id]);
}
}

View file

@ -2,14 +2,23 @@
namespace Tests\Unit\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Exceptions\PublishException;
use App\Jobs\PublishNextArticleJob;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
@ -18,9 +27,31 @@ class PublishNextArticleJobTest extends TestCase
{
use RefreshDatabase;
private NotificationService $notificationService;
protected function setUp(): void
{
parent::setUp();
$this->notificationService = new NotificationService;
}
/**
* @param array<string, mixed> $articleOverrides
* @param array<string, mixed> $routeOverrides
*/
private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
$article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $article->id,
]);
return $routeArticle;
}
public function test_constructor_sets_correct_queue(): void
@ -34,14 +65,14 @@ public function test_job_implements_should_queue(): void
{
$job = new PublishNextArticleJob;
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
$this->assertInstanceOf(ShouldQueue::class, $job);
}
public function test_job_implements_should_be_unique(): void
{
$job = new PublishNextArticleJob;
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
$this->assertInstanceOf(ShouldBeUnique::class, $job);
}
public function test_job_has_unique_for_property(): void
@ -55,193 +86,314 @@ public function test_job_uses_queueable_trait(): void
{
$job = new PublishNextArticleJob;
$this->assertContains(
\Illuminate\Foundation\Queue\Queueable::class,
class_uses($job)
);
$this->assertContains(Queueable::class, class_uses($job));
}
public function test_handle_returns_early_when_no_approved_articles(): void
public function test_handle_returns_early_when_no_approved_route_articles(): void
{
// Arrange - No articles exist
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
// No expectations as handle should return early
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// Act
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
$job->handle($articleFetcherMock, $publishingServiceMock);
// Assert - Should complete without error
$this->assertTrue(true);
}
public function test_handle_returns_early_when_no_unpublished_approved_articles(): void
public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
$routeArticle = $this->createApprovedRouteArticle();
// Create a publication record to mark it as already published
ArticlePublication::factory()->create(['article_id' => $article->id]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
// No expectations as handle should return early
$job = new PublishNextArticleJob;
// Act
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
$job->handle($articleFetcherMock, $publishingServiceMock);
// Assert - Should complete without error
$this->assertTrue(true);
}
public function test_handle_skips_non_approved_articles(): void
{
// Arrange
$feed = Feed::factory()->create();
Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'rejected',
// Mark the article as already published to this channel
ArticlePublication::factory()->create([
'article_id' => $routeArticle->article_id,
'platform_channel_id' => $routeArticle->platform_channel_id,
]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
// No expectations as handle should return early
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// Act
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
$job->handle($articleFetcherMock, $publishingServiceMock);
// Assert - Should complete without error (no approved articles to process)
$this->assertTrue(true);
}
public function test_handle_publishes_oldest_approved_article(): void
public function test_handle_skips_non_approved_route_articles(): void
{
// Arrange
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create(['feed_id' => $feed->id]);
// Create older article first
$olderArticle = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_oldest_approved_route_article(): void
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$olderArticle = Article::factory()->create(['feed_id' => $feed->id]);
$newerArticle = Article::factory()->create(['feed_id' => $feed->id]);
RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $olderArticle->id,
'created_at' => now()->subHours(2),
]);
// Create newer article
$newerArticle = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $newerArticle->id,
'created_at' => now()->subHour(),
]);
$extractedData = ['title' => 'Test Article', 'content' => 'Test content'];
// Mock ArticleFetcher
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->with(Mockery::on(function ($article) use ($olderArticle) {
return $article->id === $olderArticle->id;
}))
->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id))
->andReturn($extractedData);
// Mock ArticlePublishingService
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->with(
Mockery::on(function ($article) use ($olderArticle) {
return $article->id === $olderArticle->id;
}),
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
$extractedData
);
)
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// Act
$job->handle($articleFetcherMock, $publishingServiceMock);
// Assert - Mockery expectations are verified in tearDown
$this->assertTrue(true);
}
public function test_handle_throws_exception_on_publishing_failure(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
$routeArticle = $this->createApprovedRouteArticle();
$article = $routeArticle->article;
$extractedData = ['title' => 'Test Article'];
$publishException = new PublishException($article, null);
// Mock ArticleFetcher
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->with(Mockery::type(Article::class))
->andReturn($extractedData);
// Mock ArticlePublishingService to throw exception
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andThrow($publishException);
$job = new PublishNextArticleJob;
// Assert
$this->expectException(PublishException::class);
// Act
$job->handle($articleFetcherMock, $publishingServiceMock);
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
}
public function test_handle_logs_publishing_start(): void
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article Title',
'url' => 'https://example.com/article',
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(3),
]);
Setting::setArticlePublishingInterval(10);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_beyond_interval(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(15),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
// Mock ArticleFetcher
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
// Mock ArticlePublishingService
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')->once();
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_interval_is_zero(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now(),
]);
Setting::setArticlePublishingInterval(0);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(10),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_no_previous_publications_exist(): void
{
$this->createApprovedRouteArticle();
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_creates_warning_notification_when_no_publication_created(): void
{
$routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']);
$extractedData = ['title' => 'No Route Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(null);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
$this->assertStringContainsString('No Route Article', $notification->title);
}
public function test_handle_creates_notification_on_publish_exception(): void
{
$routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']);
$article = $routeArticle->article;
$extractedData = ['title' => 'Failing Article'];
$publishException = new PublishException($article, null);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andThrow($publishException);
$job = new PublishNextArticleJob;
// Act
$job->handle($articleFetcherMock, $publishingServiceMock);
try {
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
} catch (PublishException) {
// Expected
}
// Assert - Verify the job completes (logging is verified by observing no exceptions)
$this->assertTrue(true);
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::ERROR->value,
'notifiable_type' => $article->getMorphClass(),
'notifiable_id' => $article->id,
]);
$notification = Notification::first();
$this->assertStringContainsString('Failing Article', $notification->title);
}
public function test_job_can_be_serialized(): void
@ -256,186 +408,6 @@ public function test_job_can_be_serialized(): void
$this->assertEquals($job->uniqueFor, $unserialized->uniqueFor);
}
public function test_handle_fetches_article_data_before_publishing(): void
{
// Arrange
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
$extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content'];
// Mock ArticleFetcher with specific expectations
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->with(Mockery::type(Article::class))
->andReturn($extractedData);
// Mock publishing service to receive the extracted data
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once()
->with(Mockery::type(Article::class), $extractedData);
$job = new PublishNextArticleJob;
// Act
$job->handle($articleFetcherMock, $publishingServiceMock);
// Assert - Mockery expectations verified in tearDown
$this->assertTrue(true);
}
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
// Last publication was 3 minutes ago, interval is 10 minutes
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(3),
]);
Setting::setArticlePublishingInterval(10);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
// Neither should be called
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock->shouldNotReceive('publishToRoutedChannels');
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_beyond_interval(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
// Last publication was 15 minutes ago, interval is 10 minutes
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(15),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once();
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock);
$this->assertTrue(true);
}
public function test_handle_publishes_when_interval_is_zero(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
// Last publication was just now, but interval is 0
ArticlePublication::factory()->create([
'published_at' => now(),
]);
Setting::setArticlePublishingInterval(0);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once();
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
// Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(10),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once();
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock);
$this->assertTrue(true);
}
public function test_handle_publishes_when_no_previous_publications_exist(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once();
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock);
$this->assertTrue(true);
}
protected function tearDown(): void
{
Mockery::close();

View file

@ -9,6 +9,9 @@
use App\Models\PlatformInstance;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
@ -39,7 +42,7 @@ public function test_job_implements_should_queue(): void
$channel = PlatformChannel::factory()->make();
$job = new SyncChannelPostsJob($channel);
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
$this->assertInstanceOf(ShouldQueue::class, $job);
}
public function test_job_implements_should_be_unique(): void
@ -47,7 +50,7 @@ public function test_job_implements_should_be_unique(): void
$channel = PlatformChannel::factory()->make();
$job = new SyncChannelPostsJob($channel);
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
$this->assertInstanceOf(ShouldBeUnique::class, $job);
}
public function test_job_uses_queueable_trait(): void
@ -56,7 +59,7 @@ public function test_job_uses_queueable_trait(): void
$job = new SyncChannelPostsJob($channel);
$this->assertContains(
\Illuminate\Foundation\Queue\Queueable::class,
Queueable::class,
class_uses($job)
);
}

View file

@ -5,6 +5,7 @@
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -33,7 +34,7 @@ public function test_casts_published_at_to_datetime(): void
/** @var ArticlePublication $publication */
$publication = ArticlePublication::factory()->create(['published_at' => $timestamp]);
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
$this->assertInstanceOf(Carbon::class, $publication->published_at);
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s'));
}
@ -76,7 +77,7 @@ public function test_publication_creation_with_factory(): void
$this->assertNotNull($publication->article_id);
$this->assertNotNull($publication->platform_channel_id);
$this->assertIsString($publication->post_id);
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
$this->assertInstanceOf(Carbon::class, $publication->published_at);
$this->assertIsString($publication->published_by);
}
@ -111,7 +112,7 @@ public function test_publication_factory_recently_published_state(): void
/** @var ArticlePublication $publication */
$publication = ArticlePublication::factory()->recentlyPublished()->create();
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
$this->assertInstanceOf(Carbon::class, $publication->published_at);
$this->assertTrue($publication->published_at->isAfter(now()->subDay()));
$this->assertTrue($publication->published_at->isBefore(now()->addMinute()));
}
@ -203,7 +204,7 @@ public function test_publication_with_specific_published_at(): void
/** @var ArticlePublication $publication */
$publication = ArticlePublication::factory()->create(['published_at' => $timestamp]);
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
$this->assertInstanceOf(Carbon::class, $publication->published_at);
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s'));
}
@ -230,8 +231,8 @@ public function test_publication_timestamps(): void
$this->assertNotNull($publication->created_at);
$this->assertNotNull($publication->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->updated_at);
$this->assertInstanceOf(Carbon::class, $publication->created_at);
$this->assertInstanceOf(Carbon::class, $publication->updated_at);
}
public function test_multiple_publications_for_same_article(): void

View file

@ -2,12 +2,11 @@
namespace Tests\Unit\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
@ -20,169 +19,9 @@ protected function setUp(): void
{
parent::setUp();
// Mock HTTP requests to prevent external calls
Http::fake([
'*' => Http::response('', 500),
]);
// Don't fake events globally - let individual tests control this
}
public function test_is_valid_returns_false_when_approval_status_is_pending(): void
{
$article = Article::factory()->make([
'approval_status' => 'pending',
]);
$this->assertFalse($article->isValid());
}
public function test_is_valid_returns_false_when_approval_status_is_rejected(): void
{
$article = Article::factory()->make([
'approval_status' => 'rejected',
]);
$this->assertFalse($article->isValid());
}
public function test_is_valid_returns_true_when_validated_and_not_rejected(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertTrue($article->isValid());
}
public function test_is_valid_returns_false_when_not_validated(): void
{
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => null,
]);
$this->assertFalse($article->isValid());
}
public function test_is_approved_returns_true_for_approved_status(): void
{
$article = Article::factory()->make(['approval_status' => 'approved']);
$this->assertTrue($article->isApproved());
}
public function test_is_approved_returns_false_for_non_approved_status(): void
{
$article = Article::factory()->make(['approval_status' => 'pending']);
$this->assertFalse($article->isApproved());
}
public function test_is_pending_returns_true_for_pending_status(): void
{
$article = Article::factory()->make(['approval_status' => 'pending']);
$this->assertTrue($article->isPending());
}
public function test_is_rejected_returns_true_for_rejected_status(): void
{
$article = Article::factory()->make(['approval_status' => 'rejected']);
$this->assertTrue($article->isRejected());
}
public function test_approve_updates_status_and_triggers_event(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
Event::fake();
$article->approve('test_user');
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
Event::assertDispatched(ArticleApproved::class, function ($event) use ($article) {
return $event->article->id === $article->id;
});
}
public function test_approve_without_approved_by_parameter(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
Event::fake();
$article->approve();
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_reject_updates_status(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending',
]);
$article->reject('test_user');
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_can_be_published_returns_false_for_invalid_article(): void
{
$article = Article::factory()->make([
'approval_status' => 'rejected', // rejected = not valid
]);
$this->assertFalse($article->canBePublished());
}
public function test_can_be_published_requires_approval_when_approvals_enabled(): void
{
// Create a setting that enables approvals
Setting::create(['key' => 'enable_publishing_approvals', 'value' => '1']);
$pendingArticle = Article::factory()->make([
'approval_status' => 'pending',
'validated_at' => now(),
]);
$approvedArticle = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertFalse($pendingArticle->canBePublished());
$this->assertTrue($approvedArticle->canBePublished());
}
public function test_can_be_published_returns_true_when_approvals_disabled(): void
{
// Make sure approvals are disabled (default behavior)
Setting::where('key', 'enable_publishing_approvals')->delete();
$article = Article::factory()->make([
'approval_status' => 'approved',
'validated_at' => now(),
]);
$this->assertTrue($article->canBePublished());
}
public function test_feed_relationship(): void
@ -194,6 +33,14 @@ public function test_feed_relationship(): void
$this->assertEquals($feed->id, $article->feed->id);
}
public function test_route_articles_relationship(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$this->assertCount(0, $article->routeArticles);
}
public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void
{
Event::fake([NewArticleFetched::class]);
@ -207,4 +54,23 @@ public function test_dispatch_fetched_event_fires_new_article_fetched_event(): v
return $event->article->id === $article->id;
});
}
public function test_is_published_attribute(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
$this->assertFalse($article->is_published);
}
public function test_validated_at_is_cast_to_datetime(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'validated_at' => now(),
]);
$this->assertInstanceOf(Carbon::class, $article->validated_at);
}
}

View file

@ -7,6 +7,7 @@
use App\Models\Language;
use App\Models\PlatformChannel;
use App\Models\Route;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -51,7 +52,7 @@ public function test_casts_last_fetched_at_to_datetime(): void
$timestamp = now()->subHours(2);
$feed = Feed::factory()->create(['last_fetched_at' => $timestamp]);
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->last_fetched_at);
$this->assertInstanceOf(Carbon::class, $feed->last_fetched_at);
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $feed->last_fetched_at->format('Y-m-d H:i:s'));
}
@ -313,6 +314,56 @@ public function test_feed_settings_can_be_complex_structure(): void
$this->assertTrue($feed->settings['schedule']['enabled']);
}
public function test_scope_stale_includes_active_feeds_with_old_last_fetched_at(): void
{
$staleFeed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(1, $staleFeeds);
$this->assertTrue($staleFeeds->contains('id', $staleFeed->id));
}
public function test_scope_stale_includes_active_feeds_never_fetched(): void
{
$neverFetchedFeed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => null,
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(1, $staleFeeds);
$this->assertTrue($staleFeeds->contains('id', $neverFetchedFeed->id));
}
public function test_scope_stale_excludes_fresh_feeds(): void
{
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(10),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(0, $staleFeeds);
}
public function test_scope_stale_excludes_inactive_feeds(): void
{
Feed::factory()->create([
'is_active' => false,
'last_fetched_at' => now()->subHours(100),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(0, $staleFeeds);
}
public function test_feed_can_have_null_last_fetched_at(): void
{
$feed = Feed::factory()->create(['last_fetched_at' => null]);
@ -326,7 +377,7 @@ public function test_feed_timestamps(): void
$this->assertNotNull($feed->created_at);
$this->assertNotNull($feed->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->updated_at);
$this->assertInstanceOf(Carbon::class, $feed->created_at);
$this->assertInstanceOf(Carbon::class, $feed->updated_at);
}
}

View file

@ -5,6 +5,8 @@
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -213,7 +215,7 @@ public function test_keyword_uniqueness_constraint(): void
]);
// Attempt to create duplicate should fail
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectException(QueryException::class);
Keyword::create([
'feed_id' => $feed->id,
@ -257,8 +259,8 @@ public function test_keyword_timestamps(): void
$this->assertNotNull($keyword->created_at);
$this->assertNotNull($keyword->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $keyword->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $keyword->updated_at);
$this->assertInstanceOf(Carbon::class, $keyword->created_at);
$this->assertInstanceOf(Carbon::class, $keyword->updated_at);
}
public function test_keyword_default_active_state(): void

View file

@ -6,6 +6,7 @@
use App\Models\Language;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -193,8 +194,8 @@ public function test_language_timestamps(): void
$this->assertNotNull($language->created_at);
$this->assertNotNull($language->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at);
$this->assertInstanceOf(Carbon::class, $language->created_at);
$this->assertInstanceOf(Carbon::class, $language->updated_at);
}
public function test_language_can_have_multiple_platform_instances(): void

View file

@ -5,6 +5,7 @@
use App\Enums\PlatformEnum;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -65,7 +66,7 @@ public function test_casts_last_tested_at_to_datetime(): void
$timestamp = now()->subHours(2);
$account = PlatformAccount::factory()->create(['last_tested_at' => $timestamp]);
$this->assertInstanceOf(\Carbon\Carbon::class, $account->last_tested_at);
$this->assertInstanceOf(Carbon::class, $account->last_tested_at);
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s'));
}
@ -366,8 +367,8 @@ public function test_account_timestamps(): void
$this->assertNotNull($account->created_at);
$this->assertNotNull($account->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $account->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $account->updated_at);
$this->assertInstanceOf(Carbon::class, $account->created_at);
$this->assertInstanceOf(Carbon::class, $account->updated_at);
}
public function test_account_can_have_multiple_channels_with_different_priorities(): void

View file

@ -8,6 +8,7 @@
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -305,8 +306,8 @@ public function test_channel_timestamps(): void
$this->assertNotNull($channel->created_at);
$this->assertNotNull($channel->updated_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $channel->created_at);
$this->assertInstanceOf(\Carbon\Carbon::class, $channel->updated_at);
$this->assertInstanceOf(Carbon::class, $channel->created_at);
$this->assertInstanceOf(Carbon::class, $channel->updated_at);
}
public function test_channel_can_have_multiple_accounts_with_different_priorities(): void

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