diff --git a/app/Enums/ApprovalStatusEnum.php b/app/Enums/ApprovalStatusEnum.php new file mode 100644 index 0000000..5095c5d --- /dev/null +++ b/app/Enums/ApprovalStatusEnum.php @@ -0,0 +1,10 @@ + 'General', + self::FEED_STALE => 'Feed Stale', + self::PUBLISH_FAILED => 'Publish Failed', + self::CREDENTIAL_EXPIRED => 'Credential Expired', + }; + } +} diff --git a/app/Enums/PublishStatusEnum.php b/app/Enums/PublishStatusEnum.php new file mode 100644 index 0000000..03260e3 --- /dev/null +++ b/app/Enums/PublishStatusEnum.php @@ -0,0 +1,11 @@ +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 */ diff --git a/app/Http/Controllers/Api/V1/RouteArticlesController.php b/app/Http/Controllers/Api/V1/RouteArticlesController.php new file mode 100644 index 0000000..b41763b --- /dev/null +++ b/app/Http/Controllers/Api/V1/RouteArticlesController.php @@ -0,0 +1,101 @@ +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); + } + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php index e8368bd..fc16e14 100644 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -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 { diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php index bf1ebfa..1bc9c11 100644 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -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 { diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e..44a3930 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -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 { diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php index f1a02bb..1a01684 100644 --- a/app/Http/Middleware/HandleAppearance.php +++ b/app/Http/Middleware/HandleAppearance.php @@ -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 { diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2574642..711e0a1 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -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> + * @return array|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 { diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 3622a8f..e2202dd 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -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> + * @return array|string> */ public function rules(): array { diff --git a/app/Http/Resources/ArticlePublicationResource.php b/app/Http/Resources/ArticlePublicationResource.php index 9640f57..2e28574 100644 --- a/app/Http/Resources/ArticlePublicationResource.php +++ b/app/Http/Resources/ArticlePublicationResource.php @@ -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 { diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php index 36dbbbf..79760b5 100644 --- a/app/Http/Resources/ArticleResource.php +++ b/app/Http/Resources/ArticleResource.php @@ -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(), diff --git a/app/Http/Resources/FeedResource.php b/app/Http/Resources/FeedResource.php index 1c50a90..b2cc7d2 100644 --- a/app/Http/Resources/FeedResource.php +++ b/app/Http/Resources/FeedResource.php @@ -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 { diff --git a/app/Http/Resources/PlatformAccountResource.php b/app/Http/Resources/PlatformAccountResource.php index 6aa9c04..46d7cc6 100644 --- a/app/Http/Resources/PlatformAccountResource.php +++ b/app/Http/Resources/PlatformAccountResource.php @@ -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 { diff --git a/app/Http/Resources/PlatformChannelResource.php b/app/Http/Resources/PlatformChannelResource.php index c0d3338..f3b7f27 100644 --- a/app/Http/Resources/PlatformChannelResource.php +++ b/app/Http/Resources/PlatformChannelResource.php @@ -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 { diff --git a/app/Http/Resources/PlatformInstanceResource.php b/app/Http/Resources/PlatformInstanceResource.php index c95f1f4..45ffa16 100644 --- a/app/Http/Resources/PlatformInstanceResource.php +++ b/app/Http/Resources/PlatformInstanceResource.php @@ -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 { diff --git a/app/Http/Resources/RouteArticleResource.php b/app/Http/Resources/RouteArticleResource.php new file mode 100644 index 0000000..c56869b --- /dev/null +++ b/app/Http/Resources/RouteArticleResource.php @@ -0,0 +1,39 @@ + + */ + 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, + ]; + } +} diff --git a/app/Http/Resources/RouteResource.php b/app/Http/Resources/RouteResource.php index f294f1a..e8d89cb 100644 --- a/app/Http/Resources/RouteResource.php +++ b/app/Http/Resources/RouteResource.php @@ -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 { diff --git a/app/Jobs/CheckFeedStalenessJob.php b/app/Jobs/CheckFeedStalenessJob.php new file mode 100644 index 0000000..69cbc3f --- /dev/null +++ b/app/Jobs/CheckFeedStalenessJob.php @@ -0,0 +1,51 @@ +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, + ); + } + } +} diff --git a/app/Jobs/CleanupArticlesJob.php b/app/Jobs/CleanupArticlesJob.php new file mode 100644 index 0000000..0799064 --- /dev/null +++ b/app/Jobs/CleanupArticlesJob.php @@ -0,0 +1,25 @@ +subDays(self::RETENTION_DAYS)) + ->whereDoesntHave('routeArticles', fn ($q) => $q->whereIn('approval_status', [ + ApprovalStatusEnum::PENDING, + ApprovalStatusEnum::APPROVED, + ])) + ->delete(); + } +} diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 96c6cda..fd9a811 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -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; } } diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index f957809..b08e3b7 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -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, + ); } } } diff --git a/app/Listeners/ValidateArticleListener.php b/app/Listeners/ValidateArticleListener.php index 0ef3d9b..27d4431 100644 --- a/app/Listeners/ValidateArticleListener.php +++ b/app/Listeners/ValidateArticleListener.php @@ -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(); - } } } } diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 8d2de63..2d26538 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -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'); } } diff --git a/app/Livewire/Channels.php b/app/Livewire/Channels.php index a4aff29..49ea79c 100644 --- a/app/Livewire/Channels.php +++ b/app/Livewire/Channels.php @@ -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(); diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 0ffb4e9..c65b6f8 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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); diff --git a/app/Livewire/Feeds.php b/app/Livewire/Feeds.php index 89f8b42..b1f86be 100644 --- a/app/Livewire/Feeds.php +++ b/app/Livewire/Feeds.php @@ -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(); diff --git a/app/Livewire/NotificationBell.php b/app/Livewire/NotificationBell.php new file mode 100644 index 0000000..6c82c08 --- /dev/null +++ b/app/Livewire/NotificationBell.php @@ -0,0 +1,42 @@ +markAsRead(); + } + + public function markAllAsRead(): void + { + Notification::markAllAsRead(); + } + + #[Computed] + public function unreadCount(): int + { + return Notification::unread()->count(); + } + + /** + * @return Collection + */ + #[Computed] + public function notifications(): Collection + { + return Notification::recent()->get(); + } + + public function render(): View + { + return view('livewire.notification-bell'); + } +} diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index 9c17fcf..919514e 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -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(); diff --git a/app/Livewire/Routes.php b/app/Livewire/Routes.php index 485a06b..64ee812 100644 --- a/app/Livewire/Routes.php +++ b/app/Livewire/Routes.php @@ -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') diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 9e89d9b..693db05 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -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'); } diff --git a/app/Models/Article.php b/app/Models/Article.php index 59477d9..dfd03ea 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -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 + */ + public function articlePublications(): HasMany + { + return $this->hasMany(ArticlePublication::class); + } + /** * @return BelongsTo */ @@ -137,6 +86,14 @@ public function feed(): BelongsTo return $this->belongsTo(Feed::class); } + /** + * @return HasMany + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class); + } + public function dispatchFetchedEvent(): void { event(new NewArticleFetched($this)); diff --git a/app/Models/ArticlePublication.php b/app/Models/ArticlePublication.php index ee29b72..163f2e8 100644 --- a/app/Models/ArticlePublication.php +++ b/app/Models/ArticlePublication.php @@ -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|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 $array) */ diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 6b34237..51537ae 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -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 $query + * @return Builder + */ + 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 */ diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..f6d463e --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,92 @@ +|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 */ + 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 + */ + 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 $query + * @return Builder + */ + public function scopeUnread(Builder $query): Builder + { + return $query->whereNull('read_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeRecent(Builder $query): Builder + { + return $query->latest()->limit(50); + } + + public static function markAllAsRead(): void + { + static::unread()->update(['read_at' => now()]); + } +} diff --git a/app/Models/PlatformChannelPost.php b/app/Models/PlatformChannelPost.php index b2a8652..a411b24 100644 --- a/app/Models/PlatformChannelPost.php +++ b/app/Models/PlatformChannelPost.php @@ -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> */ + /** @use HasFactory> */ use HasFactory; protected $fillable = [ diff --git a/app/Models/Route.php b/app/Models/Route.php index 1125bf5..5c566e1 100644 --- a/app/Models/Route.php +++ b/app/Models/Route.php @@ -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 + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } } diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php new file mode 100644 index 0000000..7e71b52 --- /dev/null +++ b/app/Models/RouteArticle.php @@ -0,0 +1,104 @@ + */ + 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 + */ + public function route(): BelongsTo + { + return $this->belongsTo(Route::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } + + /** + * @return BelongsTo + */ + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } + + /** + * @return BelongsTo + */ + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } + + /** + * @return BelongsTo + */ + 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]); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 9f1cdfb..9206322 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 91135d7..c016ca6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ use HasApiTokens, HasFactory, Notifiable; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2a25ea8..573fa39 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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) diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index 46435a8..e655e8d 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -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, diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index fb76392..431417b 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -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 $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(); } } diff --git a/app/Services/Http/HttpFetcher.php b/app/Services/Http/HttpFetcher.php index 1caa6a6..670b0de 100644 --- a/app/Services/Http/HttpFetcher.php +++ b/app/Services/Http/HttpFetcher.php @@ -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 [ diff --git a/app/Services/Notification/NotificationService.php b/app/Services/Notification/NotificationService.php new file mode 100644 index 0000000..4e0017b --- /dev/null +++ b/app/Services/Notification/NotificationService.php @@ -0,0 +1,69 @@ + $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 $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 $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 $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); + } +} diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index 7634137..73079e1 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -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 $extractedData - * @return Collection * * @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 $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, ]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 5db8c44..0ba51af 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ 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(), diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 4e3b440..2345aa6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,9 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/feed.php b/config/feed.php index 73227fe..4215761 100644 --- a/config/feed.php +++ b/config/feed.php @@ -1,5 +1,13 @@ ['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, ], ], ], diff --git a/config/sanctum.php b/config/sanctum.php index 44527d6..cde73cf 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -1,5 +1,8 @@ [ - '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, ], ]; diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index f35645d..3e15408 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -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
*/ 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', ]; } } diff --git a/database/factories/KeywordFactory.php b/database/factories/KeywordFactory.php index db36fd1..c807a17 100644 --- a/database/factories/KeywordFactory.php +++ b/database/factories/KeywordFactory.php @@ -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, diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php new file mode 100644 index 0000000..a722e1a --- /dev/null +++ b/database/factories/NotificationFactory.php @@ -0,0 +1,51 @@ + + */ +class NotificationFactory extends Factory +{ + protected $model = Notification::class; + + /** + * @return array + */ + 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]); + } +} diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php new file mode 100644 index 0000000..0230d5a --- /dev/null +++ b/database/factories/RouteArticleFactory.php @@ -0,0 +1,83 @@ + 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(), + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..c4ceb07 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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 */ class UserFactory extends Factory { diff --git a/database/migrations/2024_01_01_000006_create_notifications_table.php b/database/migrations/2024_01_01_000006_create_notifications_table.php new file mode 100644 index 0000000..0e14bc7 --- /dev/null +++ b/database/migrations/2024_01_01_000006_create_notifications_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2024_01_01_000007_create_route_articles_table.php b/database/migrations/2024_01_01_000007_create_route_articles_table.php new file mode 100644 index 0000000..d32a0ad --- /dev/null +++ b/database/migrations/2024_01_01_000007_create_route_articles_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php b/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php new file mode 100644 index 0000000..1cb1b9a --- /dev/null +++ b/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php @@ -0,0 +1,22 @@ +boolean('auto_approve')->nullable()->after('priority'); + }); + } + + public function down(): void + { + Schema::table('routes', function (Blueprint $table) { + $table->dropColumn('auto_approve'); + }); + } +}; diff --git a/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php b/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php new file mode 100644 index 0000000..307dc0f --- /dev/null +++ b/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php @@ -0,0 +1,61 @@ +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']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php b/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php new file mode 100644 index 0000000..97c0828 --- /dev/null +++ b/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fb3f0fd..15c4715 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8849a7d..78d305a 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100" +
+ +
@@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring

FFR

+
diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 3acec05..1ad79b9 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -1,19 +1,10 @@
-
+

Articles

- Manage and review articles from your feeds + Review and manage article routing

- @if ($approvalsEnabled) -
- - - - - Approval system enabled -
- @endif
+ + +
+ + {{-- Tab actions --}} +
+ @if ($tab === 'pending' && $pendingCount > 0) + + @elseif ($tab === 'all') +
+ +
+ @else +
+ @endif +
+ + {{-- Route articles list --}} +
+ @forelse ($routeArticles as $routeArticle) +
-

- {{ $article->title ?? 'Untitled Article' }} +

+ {{ $routeArticle->article->title ?? 'Untitled Article' }}

-

- {{ $article->description ?? 'No description available' }} +

+ {{ $routeArticle->article->description ?? 'No description available' }}

-
- Feed: {{ $article->feed?->name ?? 'Unknown' }} - - {{ $article->created_at->format('M d, Y') }} -
-
-
- @if ($article->is_published) - +
+ - Published - - @elseif ($article->publish_status === 'error') - - - - - Publish Error - - @elseif ($article->publish_status === 'publishing') - - - - - Publishing... - - @elseif ($article->approval_status === 'approved') - - - - - Approved - - @elseif ($article->approval_status === 'rejected') - - - - - Rejected - - @else - - - - - Pending + {{ $routeArticle->feed?->name ?? 'Unknown' }} → {{ $routeArticle->platformChannel?->name ?? 'Unknown' }} + {{ $routeArticle->created_at->format('M d, Y H:i') }} +
+
+
+ {{-- Status badge (All tab) --}} + @if ($tab === 'all') + @if ($routeArticle->isApproved()) + + Approved + + @elseif ($routeArticle->isRejected()) + + Rejected + + @else + + Pending + + @endif @endif - @if ($article->url) + {{-- Action buttons --}} + @if ($routeArticle->isPending()) + + + @elseif ($routeArticle->isRejected()) + + @endif + + {{-- Link to original --}} + @if ($routeArticle->article->url) @@ -107,47 +159,34 @@ class="p-2 text-gray-400 hover:text-gray-600 rounded-md" @endif
- - @if ($article->approval_status === 'pending' && $approvalsEnabled) -
- - -
- @endif
@empty
-

No articles

+

+ @if ($tab === 'pending') + No pending articles + @else + No articles found + @endif +

- 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

@endforelse - @if ($articles->hasPages()) + @if ($routeArticles->hasPages())
- {{ $articles->links() }} + {{ $routeArticles->links() }}
@endif
diff --git a/resources/views/livewire/notification-bell.blade.php b/resources/views/livewire/notification-bell.blade.php new file mode 100644 index 0000000..0df0c6f --- /dev/null +++ b/resources/views/livewire/notification-bell.blade.php @@ -0,0 +1,90 @@ +
+ + + +
diff --git a/resources/views/livewire/routes.blade.php b/resources/views/livewire/routes.blade.php index 63ef1ec..5ab5cc2 100644 --- a/resources/views/livewire/routes.blade.php +++ b/resources/views/livewire/routes.blade.php @@ -50,6 +50,13 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font {{ $route->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} Created: {{ $route->created_at->format('M d, Y') }} + @if ($route->auto_approve === true) + + Auto-approve: On + @elseif ($route->auto_approve === false) + + Auto-approve: Off + @endif
@if ($route->platformChannel?->description)

@@ -265,6 +272,26 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc

Higher priority routes are processed first

+ +
+ +
+ + + +
+

Override global approval setting for this route

+
+
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index bd7a258..a2dc75d 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -104,6 +104,51 @@ class="flex-shrink-0"
+ +
+
+

+ + + + Feed Monitoring +

+

+ Configure alerts for feeds that stop returning articles +

+
+
+
+
+

+ Staleness Threshold (hours) +

+

+ Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable. +

+
+
+ + +
+
+ @error('feedStalenessThreshold') +

{{ $message }}

+ @enderror +
+
+ @if ($successMessage)
diff --git a/routes/api.php b/routes/api.php index 7e12a04..38a35e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/routes/console.php b/routes/console.php index 83db0a3..31b4712 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,8 @@ 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(); diff --git a/tests/Feature/DatabaseIntegrationTest.php b/tests/Feature/DatabaseIntegrationTest.php index e7c9a1d..2eebb44 100644 --- a/tests/Feature/DatabaseIntegrationTest.php +++ b/tests/Feature/DatabaseIntegrationTest.php @@ -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); } } } diff --git a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php index 9b16f24..ced053d 100644 --- a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php @@ -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'); diff --git a/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php new file mode 100644 index 0000000..715dc20 --- /dev/null +++ b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php @@ -0,0 +1,253 @@ +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, + ], + ], + ]); + } +} diff --git a/tests/Feature/Jobs/CheckFeedStalenessJobTest.php b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php new file mode 100644 index 0000000..9788fd9 --- /dev/null +++ b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php @@ -0,0 +1,134 @@ +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)); + } +} diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index ed85e4e..b5c793e 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -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); diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php new file mode 100644 index 0000000..fa0bb85 --- /dev/null +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -0,0 +1,149 @@ +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(); + } +} diff --git a/tests/Feature/Livewire/ArticlesTest.php b/tests/Feature/Livewire/ArticlesTest.php new file mode 100644 index 0000000..0b7752a --- /dev/null +++ b/tests/Feature/Livewire/ArticlesTest.php @@ -0,0 +1,184 @@ +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.'); + } +} diff --git a/tests/Feature/Livewire/NotificationBellTest.php b/tests/Feature/Livewire/NotificationBellTest.php new file mode 100644 index 0000000..e8913fd --- /dev/null +++ b/tests/Feature/Livewire/NotificationBellTest.php @@ -0,0 +1,85 @@ +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+'); + } +} diff --git a/tests/Feature/Livewire/RoutesAutoApproveTest.php b/tests/Feature/Livewire/RoutesAutoApproveTest.php new file mode 100644 index 0000000..b23ced8 --- /dev/null +++ b/tests/Feature/Livewire/RoutesAutoApproveTest.php @@ -0,0 +1,130 @@ +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'); + } +} diff --git a/tests/Feature/Livewire/SettingsTest.php b/tests/Feature/Livewire/SettingsTest.php new file mode 100644 index 0000000..101673d --- /dev/null +++ b/tests/Feature/Livewire/SettingsTest.php @@ -0,0 +1,54 @@ +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!'); + } +} diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 0000000..bf14d85 --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,166 @@ +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); + } +} diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index cf7366f..5743555 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -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('Article content', 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('Article content', 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()); } } diff --git a/tests/Traits/CreatesArticleFetcher.php b/tests/Traits/CreatesArticleFetcher.php index ad2f985..adeda40 100644 --- a/tests/Traits/CreatesArticleFetcher.php +++ b/tests/Traits/CreatesArticleFetcher.php @@ -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); diff --git a/tests/Unit/Actions/CreatePlatformAccountActionTest.php b/tests/Unit/Actions/CreatePlatformAccountActionTest.php index 051c522..2c0b817 100644 --- a/tests/Unit/Actions/CreatePlatformAccountActionTest.php +++ b/tests/Unit/Actions/CreatePlatformAccountActionTest.php @@ -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 diff --git a/tests/Unit/Events/ActionPerformedTest.php b/tests/Unit/Events/ActionPerformedTest.php index 5091bbf..7e8fb41 100644 --- a/tests/Unit/Events/ActionPerformedTest.php +++ b/tests/Unit/Events/ActionPerformedTest.php @@ -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), ); } diff --git a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php index b88968d..5ddd4f8 100644 --- a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php +++ b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php @@ -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 diff --git a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php index c1c5579..847c426 100644 --- a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php +++ b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php @@ -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) ); } diff --git a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php index d9546d0..3b27bfd 100644 --- a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php +++ b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php @@ -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 diff --git a/tests/Unit/Jobs/CleanupArticlesJobTest.php b/tests/Unit/Jobs/CleanupArticlesJobTest.php new file mode 100644 index 0000000..8e58f0d --- /dev/null +++ b/tests/Unit/Jobs/CleanupArticlesJobTest.php @@ -0,0 +1,111 @@ +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]); + } +} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 46cdf3c..6614595 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -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 $articleOverrides + * @param array $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(); diff --git a/tests/Unit/Jobs/SyncChannelPostsJobTest.php b/tests/Unit/Jobs/SyncChannelPostsJobTest.php index 7286a55..d439bff 100644 --- a/tests/Unit/Jobs/SyncChannelPostsJobTest.php +++ b/tests/Unit/Jobs/SyncChannelPostsJobTest.php @@ -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) ); } diff --git a/tests/Unit/Models/ArticlePublicationTest.php b/tests/Unit/Models/ArticlePublicationTest.php index 3c7e69c..1949811 100644 --- a/tests/Unit/Models/ArticlePublicationTest.php +++ b/tests/Unit/Models/ArticlePublicationTest.php @@ -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 diff --git a/tests/Unit/Models/ArticleTest.php b/tests/Unit/Models/ArticleTest.php index ed9cd0d..0f2a538 100644 --- a/tests/Unit/Models/ArticleTest.php +++ b/tests/Unit/Models/ArticleTest.php @@ -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); + } } diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php index 6c4029f..274659c 100644 --- a/tests/Unit/Models/FeedTest.php +++ b/tests/Unit/Models/FeedTest.php @@ -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); } } diff --git a/tests/Unit/Models/KeywordTest.php b/tests/Unit/Models/KeywordTest.php index d1fbd13..d729ad2 100644 --- a/tests/Unit/Models/KeywordTest.php +++ b/tests/Unit/Models/KeywordTest.php @@ -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 diff --git a/tests/Unit/Models/LanguageTest.php b/tests/Unit/Models/LanguageTest.php index 3017be0..d18baf0 100644 --- a/tests/Unit/Models/LanguageTest.php +++ b/tests/Unit/Models/LanguageTest.php @@ -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 diff --git a/tests/Unit/Models/PlatformAccountTest.php b/tests/Unit/Models/PlatformAccountTest.php index c3e351e..ff58aca 100644 --- a/tests/Unit/Models/PlatformAccountTest.php +++ b/tests/Unit/Models/PlatformAccountTest.php @@ -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 diff --git a/tests/Unit/Models/PlatformChannelTest.php b/tests/Unit/Models/PlatformChannelTest.php index 9d08a2e..f6e81ba 100644 --- a/tests/Unit/Models/PlatformChannelTest.php +++ b/tests/Unit/Models/PlatformChannelTest.php @@ -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 diff --git a/tests/Unit/Models/PlatformInstanceTest.php b/tests/Unit/Models/PlatformInstanceTest.php index 296515f..7ec68d3 100644 --- a/tests/Unit/Models/PlatformInstanceTest.php +++ b/tests/Unit/Models/PlatformInstanceTest.php @@ -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; @@ -250,8 +251,8 @@ public function test_instance_timestamps(): void $this->assertNotNull($instance->created_at); $this->assertNotNull($instance->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->updated_at); + $this->assertInstanceOf(Carbon::class, $instance->created_at); + $this->assertInstanceOf(Carbon::class, $instance->updated_at); } public function test_instance_can_have_multiple_languages(): void diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php new file mode 100644 index 0000000..97b656e --- /dev/null +++ b/tests/Unit/Models/RouteArticleTest.php @@ -0,0 +1,155 @@ +create(); + + $this->assertInstanceOf(Article::class, $routeArticle->article); + } + + public function test_route_article_belongs_to_feed(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(Feed::class, $routeArticle->feed); + } + + public function test_route_article_belongs_to_platform_channel(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(PlatformChannel::class, $routeArticle->platformChannel); + } + + public function test_route_article_has_default_pending_status(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + $this->assertTrue($routeArticle->isPending()); + $this->assertFalse($routeArticle->isApproved()); + $this->assertFalse($routeArticle->isRejected()); + } + + public function test_route_article_can_be_approved(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->approve(); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); + } + + public function test_route_article_can_be_rejected(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->reject(); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); + } + + public function test_article_has_many_route_articles(): void + { + /** @var Route $route1 */ + $route1 = Route::factory()->active()->create(); + /** @var Route $route2 */ + $route2 = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route1->feed_id]); + + RouteArticle::factory()->forRoute($route1)->create(['article_id' => $article->id]); + RouteArticle::factory()->forRoute($route2)->create(['article_id' => $article->id]); + + $this->assertCount(2, $article->routeArticles); + } + + public function test_route_has_many_route_articles(): void + { + /** @var Route $route */ + $route = Route::factory()->active()->create(); + $article1 = Article::factory()->create(['feed_id' => $route->feed_id]); + $article2 = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article1->id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article2->id]); + + $this->assertCount(2, $route->routeArticles); + } + + public function test_unique_constraint_prevents_duplicate_route_articles(): void + { + /** @var Route $route */ + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $this->expectException(QueryException::class); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + } + + public function test_route_article_cascade_deletes_when_article_deleted(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create(); + $articleId = $routeArticle->article_id; + + Article::destroy($articleId); + + $this->assertDatabaseMissing('route_articles', ['article_id' => $articleId]); + } + + public function test_route_article_cascade_deletes_when_route_deleted(): void + { + /** @var Route $route */ + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->delete(); + + $this->assertDatabaseMissing('route_articles', [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function test_route_article_belongs_to_route(): void + { + /** @var Route $route */ + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $loadedRoute = $routeArticle->route; + + $this->assertInstanceOf(Route::class, $loadedRoute); + $this->assertEquals($route->feed_id, $loadedRoute->feed_id); + $this->assertEquals($route->platform_channel_id, $loadedRoute->platform_channel_id); + } +} diff --git a/tests/Unit/Models/RouteTest.php b/tests/Unit/Models/RouteTest.php index 1903e43..e559410 100644 --- a/tests/Unit/Models/RouteTest.php +++ b/tests/Unit/Models/RouteTest.php @@ -15,7 +15,7 @@ class RouteTest extends TestCase public function test_fillable_fields(): void { - $fillableFields = ['feed_id', 'platform_channel_id', 'is_active', 'priority']; + $fillableFields = ['feed_id', 'platform_channel_id', 'is_active', 'priority', 'auto_approve']; $route = new Route; $this->assertEquals($fillableFields, $route->getFillable()); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php index 1165b81..19fc5a6 100644 --- a/tests/Unit/Models/SettingTest.php +++ b/tests/Unit/Models/SettingTest.php @@ -39,4 +39,34 @@ public function test_set_article_publishing_interval_zero(): void $this->assertSame(0, Setting::getArticlePublishingInterval()); } + + public function test_get_feed_staleness_threshold_returns_default_when_not_set(): void + { + $this->assertSame(48, Setting::getFeedStalenessThreshold()); + } + + public function test_get_feed_staleness_threshold_returns_stored_value(): void + { + Setting::set('feed_staleness_threshold', '72'); + + $this->assertSame(72, Setting::getFeedStalenessThreshold()); + } + + public function test_set_feed_staleness_threshold_persists_value(): void + { + Setting::setFeedStalenessThreshold(24); + + $this->assertSame(24, Setting::getFeedStalenessThreshold()); + $this->assertDatabaseHas('settings', [ + 'key' => 'feed_staleness_threshold', + 'value' => '24', + ]); + } + + public function test_set_feed_staleness_threshold_zero(): void + { + Setting::setFeedStalenessThreshold(0); + + $this->assertSame(0, Setting::getFeedStalenessThreshold()); + } } diff --git a/tests/Unit/Services/ArticleFetcherRssTest.php b/tests/Unit/Services/ArticleFetcherRssTest.php index 425ae23..3f15dd6 100644 --- a/tests/Unit/Services/ArticleFetcherRssTest.php +++ b/tests/Unit/Services/ArticleFetcherRssTest.php @@ -5,6 +5,7 @@ use App\Models\Article; use App\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Mockery; use Tests\TestCase; @@ -56,7 +57,7 @@ public function test_get_articles_from_rss_feed_returns_collection(): void $fetcher = $this->createArticleFetcher(); $result = $fetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); } public function test_get_articles_from_rss_feed_creates_articles(): void @@ -118,7 +119,7 @@ public function test_get_articles_from_rss_feed_handles_invalid_xml(): void $fetcher = $this->createArticleFetcher(); $result = $fetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } diff --git a/tests/Unit/Services/ArticleFetcherTest.php b/tests/Unit/Services/ArticleFetcherTest.php index 180b099..f281bea 100644 --- a/tests/Unit/Services/ArticleFetcherTest.php +++ b/tests/Unit/Services/ArticleFetcherTest.php @@ -6,6 +6,7 @@ use App\Models\Feed; use App\Services\Article\ArticleFetcher; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Mockery; use Tests\TestCase; @@ -38,7 +39,7 @@ public function test_get_articles_from_feed_returns_collection(): void $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); } public function test_get_articles_from_rss_feed_returns_empty_collection(): void @@ -66,7 +67,7 @@ public function test_get_articles_from_website_feed_handles_no_parser(): void $result = $articleFetcher->getArticlesFromFeed($feed); // Should return empty collection when no parser is available - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -80,7 +81,7 @@ public function test_get_articles_from_unsupported_feed_type(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -130,7 +131,7 @@ public function test_get_articles_from_feed_with_null_feed_type(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -150,7 +151,7 @@ public function test_get_articles_from_website_feed_with_supported_parser(): voi $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); // VRT parser will process the mocked HTML response } @@ -166,7 +167,7 @@ public function test_get_articles_from_website_feed_handles_invalid_url(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } diff --git a/tests/Unit/Services/Http/HttpFetcherTest.php b/tests/Unit/Services/Http/HttpFetcherTest.php index 7f6c8c9..ae2266b 100644 --- a/tests/Unit/Services/Http/HttpFetcherTest.php +++ b/tests/Unit/Services/Http/HttpFetcherTest.php @@ -4,6 +4,8 @@ use App\Services\Http\HttpFetcher; use Exception; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Psr7\Request; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -154,7 +156,7 @@ public function test_fetch_multiple_urls_returns_empty_array_on_exception(): voi $urls = ['https://example.com']; Http::fake(function () { - throw new \GuzzleHttp\Exception\ConnectException('Pool request failed', new \GuzzleHttp\Psr7\Request('GET', 'https://example.com')); + throw new ConnectException('Pool request failed', new Request('GET', 'https://example.com')); }); $results = HttpFetcher::fetchMultipleUrls($urls); diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 4302a83..61a8d17 100644 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit\Services\Publishing; use App\Enums\PlatformEnum; -use App\Exceptions\PublishException; use App\Models\Article; use App\Models\Feed; use App\Models\PlatformAccount; @@ -11,11 +10,11 @@ use App\Models\PlatformChannelPost; use App\Models\PlatformInstance; use App\Models\Route; +use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use App\Services\Publishing\ArticlePublishingService; use Exception; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -45,102 +44,77 @@ protected function tearDown(): void parent::tearDown(); } - public function test_publish_to_routed_channels_throws_exception_for_invalid_article(): void - { - $article = Article::factory()->create(['approval_status' => 'rejected']); - $extractedData = ['title' => 'Test Title']; - - $this->expectException(PublishException::class); - $this->expectExceptionMessage('CANNOT_PUBLISH_INVALID_ARTICLE'); - - $this->service->publishToRoutedChannels($article, $extractedData); - } - - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void + /** + * @return array{RouteArticle, PlatformChannel, PlatformAccount, Article} + */ + private function createRouteArticleWithAccount(): array { $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'validated_at' => now(), - ]); - $extractedData = ['title' => 'Test Title']; - - $result = $this->service->publishToRoutedChannels($article, $extractedData); - - $this->assertInstanceOf(EloquentCollection::class, $result); - $this->assertTrue($result->isEmpty()); - } - - public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void - { - // Arrange: valid article - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'validated_at' => now(), - ]); - - // Create a route with a channel but no active accounts - $channel = PlatformChannel::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Don't create any platform accounts for the channel - - // Act - $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_successfully_publishes_to_channel(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); - $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); - // Create route - Route::create([ + /** @var Route $route */ + $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, ]); - // Attach account to channel as active $channel->platformAccounts()->attach($account->id, [ 'is_active' => true, 'priority' => 50, ]); - // Mock publisher via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + return [$routeArticle, $channel, $account, $article]; + } + + public function test_publish_route_article_returns_null_when_no_active_account(): void + { + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + /** @var Route $route */ + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + $result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']); + + $this->assertNull($result); + $this->assertDatabaseCount('article_publications', 0); + } + + public function test_publish_route_article_successfully_publishes(): void + { + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); + + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'platform_channel_id' => $channel->id, @@ -149,239 +123,55 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe ]); } - public function test_publish_to_routed_channels_handles_publishing_failure_gracefully(): void + public function test_publish_route_article_handles_publishing_failure_gracefully(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); + [$routeArticle] = $this->createRouteArticleWithAccount(); - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Publisher throws an exception via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } - public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(2, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 100]); - $this->assertDatabaseHas('article_publications', ['post_id' => 200]); - } - - public function test_publish_to_routed_channels_filters_out_failed_publications(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 300]); - $this->assertDatabaseCount('article_publications', 1); - } - public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'validated_at' => now(), - 'url' => 'https://example.com/article-1', - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Simulate the URL already being posted to this channel (synced from Lemmy) + // Simulate the URL already being posted to this channel PlatformChannelPost::storePost( PlatformEnum::LEMMY, (string) $channel->channel_id, $channel->name, '999', - 'https://example.com/article-1', + $article->url, 'Different Title', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'validated_at' => now(), - 'url' => 'https://example.com/article-new-url', - 'title' => 'Breaking News: Something Happened', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Simulate the same title already posted with a different URL PlatformChannelPost::storePost( @@ -390,50 +180,25 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe $channel->name, '888', 'https://example.com/different-url', - 'Breaking News: Something Happened', + 'Breaking News', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_proceeds_when_no_duplicate_exists(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'validated_at' => now(), - 'url' => 'https://example.com/unique-article', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Existing post in the channel has a completely different URL and title PlatformChannelPost::storePost( @@ -445,19 +210,18 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void 'Totally Different Title', ); - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 456]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'post_id' => 456, diff --git a/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/tests/Unit/Services/Publishing/KeywordFilteringTest.php deleted file mode 100644 index 7a0b80c..0000000 --- a/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ /dev/null @@ -1,281 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($logSaver); - $this->feed = Feed::factory()->create(); - $this->channel1 = PlatformChannel::factory()->create(); - $this->channel2 = PlatformChannel::factory()->create(); - - // Create routes - $this->route1 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - $this->route2 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_route_with_no_keywords_matches_all_articles(): void - { - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Some random article', - 'description' => 'This is about something', - 'full_article' => 'The content talks about various topics', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route with no keywords should match any article'); - } - - public function test_route_with_keywords_matches_article_containing_keyword(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); - } - - public function test_route_with_keywords_does_not_match_article_without_keywords(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'sports', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'football', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Economic news update', - 'description' => 'Markets are doing well', - 'full_article' => 'The economy is showing strong growth this quarter...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertFalse($result, 'Route should not match article without any keywords'); - } - - public function test_inactive_keywords_are_ignored(): void - { - // Add active and inactive keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => false, // Inactive - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, // Active - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedDataWithInactiveKeyword = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - $extractedDataWithActiveKeyword = [ - 'title' => 'Political changes ahead', - 'description' => 'Politics is changing', - 'full_article' => 'The political landscape is shifting...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); - $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); - - $this->assertFalse($result1, 'Route should not match article with inactive keyword'); - $this->assertTrue($result2, 'Route should match article with active keyword'); - } - - public function test_keyword_matching_is_case_insensitive(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'BELGIUM', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'belgium news', - 'description' => 'About Belgium', - 'full_article' => 'News from belgium today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Keyword matching should be case insensitive'); - } - - public function test_keywords_match_in_title_description_and_content(): void - { - $keywordInTitle = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'title-word', - 'is_active' => true, - ]); - - $keywordInDescription = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'keyword' => 'desc-word', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'This contains title-word', - 'description' => 'This has desc-word in it', - 'full_article' => 'The content has no special words', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); - - $this->assertTrue($result1, 'Should match keyword in title'); - $this->assertTrue($result2, 'Should match keyword in description'); - } -} diff --git a/tests/Unit/Services/ValidationServiceKeywordTest.php b/tests/Unit/Services/ValidationServiceKeywordTest.php deleted file mode 100644 index 711088f..0000000 --- a/tests/Unit/Services/ValidationServiceKeywordTest.php +++ /dev/null @@ -1,211 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * Helper method to access private validateByKeywords method - */ - private function getValidateByKeywordsMethod(): ReflectionMethod - { - $reflection = new ReflectionClass($this->validationService); - $method = $reflection->getMethod('validateByKeywords'); - $method->setAccessible(true); - - return $method; - } - - public function test_validates_belgian_political_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.')); - $this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.')); - $this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.')); - $this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.')); - $this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.')); - $this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.')); - } - - public function test_validates_belgian_location_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.')); - $this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.')); - $this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.')); - $this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.')); - $this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.')); - $this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.')); - $this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.')); - $this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.')); - $this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.')); - $this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.')); - } - - public function test_validates_government_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.')); - $this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.')); - $this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.')); - $this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.')); - $this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.')); - } - - public function test_validates_news_topic_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.')); - $this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.')); - $this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.')); - $this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.')); - $this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.')); - $this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.')); - $this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.')); - $this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.')); - $this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.')); - $this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.')); - $this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.')); - } - - public function test_case_insensitive_keyword_matching(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.')); - $this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.')); - $this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.')); - $this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.')); - $this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.')); - } - - public function test_rejects_content_without_belgian_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.')); - $this->assertFalse($method->invoke($this->validationService, 'International news from other countries.')); - $this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.')); - $this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.')); - $this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.')); - $this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.')); - $this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.')); - } - - public function test_keyword_matching_in_longer_text(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $longText = ' - This is a comprehensive article about various topics. - It covers international relations, global economics, and regional policies. - However, it specifically mentions that Antwerp hosted a major conference - last week with participants from around the world. The event was - considered highly successful and will likely be repeated next year. - '; - - $this->assertTrue($method->invoke($this->validationService, $longText)); - - $longTextWithoutKeywords = ' - This is a comprehensive article about various topics. - It covers international relations, global finance, and commercial matters. - The conference was held in a major international city and attracted - participants from around the world. The event was considered highly - successful and will likely be repeated next year. - '; - - $this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords)); - } - - public function test_empty_content_returns_false(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, '')); - $this->assertFalse($method->invoke($this->validationService, ' ')); - $this->assertFalse($method->invoke($this->validationService, "\n\n\t")); - } - - /** - * Test comprehensive keyword coverage to ensure all expected keywords work - */ - public function test_all_keywords_are_functional(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $expectedKeywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // 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', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police', - ]; - - foreach ($expectedKeywords as $keyword) { - $testContent = "This article contains the keyword: {$keyword}."; - $result = $method->invoke($this->validationService, $testContent); - - $this->assertTrue($result, "Keyword '{$keyword}' should match but didn't"); - } - } - - public function test_partial_keyword_matches_work(): void - { - $method = $this->getValidateByKeywordsMethod(); - - // Keywords should match when they appear as part of larger words or phrases - $this->assertTrue($method->invoke($this->validationService, 'Anti-government protesters gathered.')); - $this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.')); - $this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.')); - } -} diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index cd9a83a..5133193 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -2,26 +2,34 @@ namespace Tests\Unit\Services; +use App\Enums\ApprovalStatusEnum; use App\Models\Article; use App\Models\Feed; +use App\Models\Keyword; +use App\Models\PlatformChannel; +use App\Models\Route; +use App\Models\RouteArticle; +use App\Models\Setting; +use App\Services\Article\ArticleFetcher; use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; use Mockery; +use Mockery\MockInterface; use Tests\TestCase; -use Tests\Traits\CreatesArticleFetcher; class ValidationServiceTest extends TestCase { - use CreatesArticleFetcher, RefreshDatabase; + use RefreshDatabase; private ValidationService $validationService; + private MockInterface $articleFetcher; + protected function setUp(): void { parent::setUp(); - $articleFetcher = $this->createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); + $this->articleFetcher = Mockery::mock(ArticleFetcher::class); + $this->validationService = new ValidationService($this->articleFetcher); } protected function tearDown(): void @@ -30,133 +38,345 @@ protected function tearDown(): void parent::tearDown(); } - public function test_validate_returns_article_with_validation_status(): void + private function mockFetchReturning(Article $article, ?string $content, ?string $title = 'Test Title', ?string $description = 'Test description'): void { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Test content with Belgium news', 200), - ]); + $data = []; + if ($title) { + $data['title'] = $title; + } + if ($description) { + $data['description'] = $description; + } + if ($content) { + $data['full_article'] = $content; + } - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $result = $this->validationService->validate($article); - - $this->assertInstanceOf(Article::class, $result); - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); + $this->articleFetcher + ->shouldReceive('fetchArticleData') + ->with($article) + ->once() + ->andReturn($data); } - public function test_validate_marks_article_invalid_when_missing_data(): void + public function test_validate_sets_validated_at_on_article(): void { - // Mock HTTP requests to return HTML without article content - Http::fake([ - 'https://invalid-url-without-parser.com/article' => Http::response('Empty', 200), - ]); - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, - 'url' => 'https://invalid-url-without-parser.com/article', - 'approval_status' => 'pending', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); - $result = $this->validationService->validate($article); - - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_with_supported_article_content(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $result = $this->validationService->validate($article); - - // Since we can't fetch real content in tests, it should be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_updates_article_in_database(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $originalId = $article->id; + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); $this->validationService->validate($article); - // Check that the article was updated in the database - $updatedArticle = Article::find($originalId); - $this->assertContains($updatedArticle->approval_status, ['pending', 'approved', 'rejected']); + $this->assertNotNull($article->fresh()->validated_at); } - public function test_validate_handles_article_with_existing_validation(): void + public function test_validate_creates_route_articles_for_active_routes(): void { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Some article content'); + + $this->validationService->validate($article); + + $this->assertCount(2, RouteArticle::where('article_id', $article->id)->get()); + } + + public function test_validate_skips_inactive_routes(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + Route::factory()->inactive()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Some article content'); + + $this->validationService->validate($article); + + $this->assertCount(1, RouteArticle::where('article_id', $article->id)->get()); + } + + public function test_validate_sets_pending_when_keywords_match(): 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', ]); + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium politics'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_sets_rejected_when_no_keywords_match(): 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', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about random topics and weather'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status); + } + + public function test_validate_sets_pending_when_route_has_no_keywords(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about random topics'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_different_routes_get_different_statuses(): void + { + $feed = Feed::factory()->create(); + $channel1 = PlatformChannel::factory()->create(); + $channel2 = PlatformChannel::factory()->create(); + + Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + ]); + Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + ]); + + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + 'keyword' => 'Belgium', + ]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + 'keyword' => 'Technology', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $ra1 = RouteArticle::where('article_id', $article->id) + ->where('platform_channel_id', $channel1->id)->first(); + $ra2 = RouteArticle::where('article_id', $article->id) + ->where('platform_channel_id', $channel2->id)->first(); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $ra1->approval_status); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $ra2->approval_status); + } + + public function test_validate_auto_approves_when_global_setting_off_and_keywords_match(): void + { + Setting::setBool('enable_publishing_approvals', false); + $feed = Feed::factory()->create(); - $article = Article::factory()->create([ + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); - $originalApprovalStatus = $article->approval_status; + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + } + + public function test_validate_route_auto_approve_overrides_global_setting(): void + { + Setting::setBool('enable_publishing_approvals', true); + + $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'auto_approve' => true, + ]); + 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]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + } + + public function test_validate_route_auto_approve_false_overrides_global_off(): void + { + Setting::setBool('enable_publishing_approvals', false); + + $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'auto_approve' => false, + ]); + 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]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_does_not_auto_approve_rejected_articles(): void + { + 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]); + $this->mockFetchReturning($article, 'Random content no match'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status); + } + + public function test_validate_creates_no_route_articles_when_content_fetch_fails(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, null); + + $this->validationService->validate($article); + + $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get()); + $this->assertNotNull($article->fresh()->validated_at); + } + + public function test_validate_updates_article_metadata(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'title' => 'Old Title', + ]); + $this->mockFetchReturning($article, 'Content about Belgium', 'New Title', 'New description'); $result = $this->validationService->validate($article); - // Should re-validate - status may change based on content validation - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); + $this->assertEquals('New Title', $result->title); + $this->assertEquals('New description', $result->description); + $this->assertEquals('Content about Belgium', $result->content); } - public function test_validate_keyword_checking_logic(): void + public function test_validate_sets_validated_at_on_route_articles(): void { - // Mock HTTP requests with content that contains Belgian keywords - Http::fake([ - 'https://example.com/article-about-bart-de-wever' => Http::response( - '
Article about Bart De Wever and Belgian politics
', - 200 - ), - ]); - $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); - // Create an article that would match the validation keywords if content was available - $article = Article::factory()->create([ + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Content about something'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertNotNull($routeArticle->validated_at); + } + + public function test_validate_keyword_matching_is_case_insensitive(): 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, - 'url' => 'https://example.com/article-about-bart-de-wever', - 'approval_status' => 'pending', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'belgium', ]); - $result = $this->validationService->validate($article); + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about BELGIUM politics'); - // The service looks for keywords in the full_article content - // Since we can't fetch real content, it will be marked rejected - $this->assertEquals('rejected', $result->approval_status); + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_only_uses_active_keywords(): void + { + $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->inactive()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + // No active keywords = matches everything = pending + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); } }