Compare commits

...

14 commits

Author SHA1 Message Date
cc94ba8e55 89 - Add article cleanup job with 30-day retention policy
Some checks failed
CI / ci (push) Failing after 4m35s
CI / ci (pull_request) Failing after 4m23s
2026-03-18 18:09:54 +01:00
bab2557e85 97 - Add auto_approve toggle to Route edit modal 2026-03-18 17:51:35 +01:00
9430158051 96 - Rework Articles page into route_article triage UI with tabs and actions 2026-03-18 17:39:38 +01:00
e7acbb6882 99 - Move publish_status from Article to RouteArticle with PublishStatusEnum 2026-03-18 17:31:47 +01:00
9fb373d139 98 - Add RouteArticle API endpoints for approve, reject, restore, and clear 2026-03-18 17:20:15 +01:00
2b74f24356 85 - Replace ArticleApproved with RouteArticleApproved event and update publishing listener 2026-03-18 17:00:56 +01:00
5e571babda 85 - Fix keyword matching to include title and description, add PHPStan type annotations 2026-03-18 16:35:12 +01:00
f3406b1713 85 - Remove approval_status from Article, migrate to route_articles 2026-03-18 16:23:46 +01:00
d0985fc57d 85 - Simplify Articles page to read-only feed log 2026-03-18 16:09:48 +01:00
0c35af4403 85 - Update publishing pipeline to use route_articles for per-route publishing 2026-03-18 16:05:31 +01:00
f449548123 85 - Update ValidateArticleListener for per-route validation flow 2026-03-18 15:51:33 +01:00
e3ea02ae1c 85 - Refactor ValidationService to per-route keyword evaluation with ApprovalStatusEnum 2026-03-18 15:46:15 +01:00
2a5a8c788b 85 - Add auto_approve column to routes table 2026-03-18 15:28:01 +01:00
b832d6d850 85 - Add route_articles table, model, and factory for per-route approval 2026-03-18 15:24:03 +01:00
46 changed files with 2416 additions and 1886 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,9 +21,6 @@ public function toArray(Request $request): array
'url' => $this->url,
'title' => $this->title,
'description' => $this->description,
'is_valid' => $this->is_valid,
'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'validated_at' => $this->validated_at?->toISOString(),
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
'created_at' => $this->created_at->toISOString(),

View file

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

View file

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

View file

@ -2,13 +2,15 @@
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;
@ -48,34 +50,45 @@ 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,
]);
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try {
$extractedData = $articleFetcher->fetchArticleData($article);
$publications = $publishingService->publishToRoutedChannels($article, $extractedData);
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
if ($publications->isNotEmpty()) {
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
@ -84,11 +97,13 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publications were created for this article. Check channel routing configuration.',
'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(),

View file

@ -5,8 +5,9 @@
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;
@ -23,37 +24,36 @@ public function __construct(
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,
]);
@ -62,12 +62,12 @@ public function handle(ArticleApproved $event): void
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publications were created for this article. Check channel routing configuration.',
'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,

View file

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

View file

@ -2,9 +2,9 @@
namespace App\Livewire;
use App\Enums\ApprovalStatusEnum;
use App\Jobs\ArticleDiscoveryJob;
use App\Models\Article;
use App\Models\Setting;
use App\Models\RouteArticle;
use Livewire\Component;
use Livewire\WithPagination;
@ -12,22 +12,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
@ -41,15 +63,26 @@ public function refresh(): void
public function render(): \Illuminate\Contracts\View\View
{
$articles = Article::with(['feed', 'articlePublication'])
->orderBy('created_at', 'desc')
->paginate(15);
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc');
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
if ($this->tab === 'pending') {
$query->where('approval_status', ApprovalStatusEnum::PENDING);
} elseif ($this->search !== '') {
$search = $this->search;
$query->whereHas('article', function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
$routeArticles = $query->paginate(15);
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
return view('livewire.articles', [
'articles' => $articles,
'approvalsEnabled' => $approvalsEnabled,
'routeArticles' => $routeArticles,
'pendingCount' => $pendingCount,
])->layout('layouts.app');
}
}

View file

@ -26,6 +26,8 @@ class Routes extends Component
// Edit form
public int $editPriority = 50;
public string $editAutoApprove = '';
// Keyword management
public string $newKeyword = '';
@ -81,6 +83,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 +104,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();
}

View file

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

View file

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

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

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

View file

@ -36,7 +36,7 @@ public function boot(): void
);
Event::listen(
\App\Events\ArticleApproved::class,
\App\Events\RouteArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);

View file

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

View file

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

View file

@ -25,8 +25,6 @@ public function definition(): array
'image_url' => $this->faker->optional()->imageUrl(),
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\CheckFeedStalenessJob;
use App\Jobs\CleanupArticlesJob;
use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob;
use Illuminate\Support\Facades\Schedule;
@ -27,3 +28,9 @@
->name('check-feed-staleness')
->withoutOverlapping()
->onOneServer();
Schedule::job(new CleanupArticlesJob)
->daily()
->name('cleanup-old-articles')
->withoutOverlapping()
->onOneServer();

View file

@ -134,14 +134,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);

View file

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

View file

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

View file

@ -3,8 +3,6 @@
namespace Tests\Feature;
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;
@ -13,8 +11,6 @@
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;
@ -116,33 +112,6 @@ 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();
@ -175,15 +144,20 @@ 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 \App\Models\Route $route */
$route = \App\Models\Route::factory()->active()->create(['feed_id' => $feed->id]);
\App\Models\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
@ -203,45 +177,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 = \App\Models\RouteArticle::where('article_id', $article->id)->first();
$this->assertNotNull($routeArticle);
$this->assertEquals(\App\Enums\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([
@ -275,13 +217,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(\App\Events\RouteArticleApproved::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ExceptionOccurred::class);
$this->assertNotEmpty($listeners);

View file

@ -4,17 +4,19 @@
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Events\ArticleApproved;
use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
@ -22,15 +24,28 @@ class PublishApprovedArticleListenerTest extends TestCase
{
use RefreshDatabase;
public function test_exception_during_publishing_creates_error_notification(): void
private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article',
'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()
@ -39,13 +54,13 @@ public function test_exception_during_publishing_creates_error_notification(): v
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article));
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::ERROR->value,
'notifiable_type' => $article->getMorphClass(),
'notifiable_id' => $article->id,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
@ -53,14 +68,9 @@ public function test_exception_during_publishing_creates_error_notification(): v
$this->assertStringContainsString('Connection refused', $notification->message);
}
public function test_no_publications_created_creates_warning_notification(): void
public function test_no_publication_created_creates_warning_notification(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article',
]);
$routeArticle = $this->createApprovedRouteArticle();
$extractedData = ['title' => 'Test Article'];
@ -70,18 +80,18 @@ public function test_no_publications_created_creates_warning_notification(): voi
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection);
->andReturn(null);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article));
$listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $article->getMorphClass(),
'notifiable_id' => $article->id,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
@ -90,12 +100,7 @@ public function test_no_publications_created_creates_warning_notification(): voi
public function test_successful_publish_does_not_create_notification(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Test Article',
]);
$routeArticle = $this->createApprovedRouteArticle();
$extractedData = ['title' => 'Test Article'];
@ -105,16 +110,37 @@ public function test_successful_publish_does_not_create_notification(): void
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection(['publication']));
->andReturn(ArticlePublication::factory()->make());
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article));
$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();

View file

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

View file

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

View file

@ -2,82 +2,95 @@
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\Services\Article\ValidationService;
use App\Models\Keyword;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Mockery;
use Tests\TestCase;
class ValidateArticleListenerTest extends TestCase
{
use RefreshDatabase;
public function test_listener_validates_article_and_dispatches_ready_to_publish_event(): void
private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener
{
Event::fake([ArticleApproved::class]);
$articleFetcher = Mockery::mock(ArticleFetcher::class);
$articleFetcher->shouldReceive('fetchArticleData')->andReturn(
$content ? [
'title' => 'Test Title',
'description' => 'Test description',
'full_article' => $content,
] : []
);
// Mock HTTP requests
Http::fake([
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
return new ValidateArticleListener(
new \App\Services\Article\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 +101,29 @@ public function test_listener_skips_articles_with_existing_publication(): void
'published_by' => 'test-user',
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener = $this->createListenerWithMockedFetcher();
$listener->handle(new NewArticleFetched($article));
$listener->handle($event);
Event::assertNotDispatched(ArticleApproved::class);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
}
public function test_listener_calls_validation_service(): void
public function test_listener_handles_validation_errors_gracefully(): void
{
Event::fake([ArticleApproved::class]);
$articleFetcher = Mockery::mock(ArticleFetcher::class);
$articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed'));
// Mock HTTP requests
Http::fake([
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
]);
$listener = new ValidateArticleListener(
new \App\Services\Article\ValidationService($articleFetcher)
);
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending',
]);
$listener = app(ValidateArticleListener::class);
$event = new NewArticleFetched($article);
$listener->handle(new NewArticleFetched($article));
$listener->handle($event);
// Verify that the article was processed by ValidationService
$article->refresh();
$this->assertNotEquals('pending', $article->approval_status, 'Article should have been validated');
$this->assertContains($article->approval_status, ['approved', 'rejected'], 'Article should have validation result');
$this->assertNull($article->fresh()->validated_at);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
}
}

View file

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

View file

@ -10,12 +10,14 @@
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\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
@ -31,6 +33,25 @@ protected function setUp(): void
$this->notificationService = new NotificationService;
}
/**
* @param array<string, mixed> $articleOverrides
* @param array<string, mixed> $routeOverrides
*/
private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
$article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $article->id,
]);
return $routeArticle;
}
public function test_constructor_sets_correct_queue(): void
{
$job = new PublishNextArticleJob;
@ -63,253 +84,127 @@ 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;
// Act
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// 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, $this->notificationService);
// 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;
// Act
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// 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(new Collection(['publication']));
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
// Act
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// 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, $this->notificationService);
}
public function test_handle_logs_publishing_start(): 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',
]);
$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()
->andReturn(new Collection(['publication']));
$job = new PublishNextArticleJob;
// Act
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// Assert - Verify the job completes (logging is verified by observing no exceptions)
$this->assertTrue(true);
}
public function test_job_can_be_serialized(): void
{
$job = new PublishNextArticleJob;
$serialized = serialize($job);
$unserialized = unserialize($serialized);
$this->assertInstanceOf(PublishNextArticleJob::class, $unserialized);
$this->assertEquals($job->queue, $unserialized->queue);
$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)
->andReturn(new Collection(['publication']));
$job = new PublishNextArticleJob;
// Act
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
// 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',
]);
$this->createApprovedRouteArticle();
// Last publication was 3 minutes ago, interval is 10 minutes
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(3),
]);
@ -318,9 +213,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
// Neither should be called
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock->shouldNotReceive('publishToRoutedChannels');
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -330,13 +224,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
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',
]);
$this->createApprovedRouteArticle();
// Last publication was 15 minutes ago, interval is 10 minutes
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(15),
]);
@ -350,9 +239,9 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection(['publication']));
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -362,13 +251,8 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
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',
]);
$this->createApprovedRouteArticle();
// Last publication was just now, but interval is 0
ArticlePublication::factory()->create([
'published_at' => now(),
]);
@ -382,9 +266,9 @@ public function test_handle_publishes_when_interval_is_zero(): void
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
->once()
->andReturn(new Collection(['publication']));
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -394,13 +278,8 @@ public function test_handle_publishes_when_interval_is_zero(): void
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',
]);
$this->createApprovedRouteArticle();
// Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(10),
]);
@ -414,9 +293,9 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection(['publication']));
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -426,11 +305,7 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
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',
]);
$this->createApprovedRouteArticle();
Setting::setArticlePublishingInterval(10);
@ -442,9 +317,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection(['publication']));
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -452,14 +327,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
$this->assertTrue(true);
}
public function test_handle_creates_warning_notification_when_no_publications_created(): void
public function test_handle_creates_warning_notification_when_no_publication_created(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'No Route Article',
]);
$routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']);
$extractedData = ['title' => 'No Route Article'];
@ -469,9 +339,9 @@ public function test_handle_creates_warning_notification_when_no_publications_cr
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection);
->andReturn(null);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
@ -479,8 +349,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $article->getMorphClass(),
'notifiable_id' => $article->id,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
@ -489,12 +359,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr
public function test_handle_creates_notification_on_publish_exception(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'approved',
'title' => 'Failing Article',
]);
$routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']);
$article = $routeArticle->article;
$extractedData = ['title' => 'Failing Article'];
$publishException = new PublishException($article, null);
@ -505,7 +371,7 @@ public function test_handle_creates_notification_on_publish_exception(): void
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andThrow($publishException);
@ -528,6 +394,18 @@ public function test_handle_creates_notification_on_publish_exception(): void
$this->assertStringContainsString('Failing Article', $notification->title);
}
public function test_job_can_be_serialized(): void
{
$job = new PublishNextArticleJob;
$serialized = serialize($job);
$unserialized = unserialize($serialized);
$this->assertInstanceOf(PublishNextArticleJob::class, $unserialized);
$this->assertEquals($job->queue, $unserialized->queue);
$this->assertEquals($job->uniqueFor, $unserialized->uniqueFor);
}
protected function tearDown(): void
{
Mockery::close();

View file

@ -2,11 +2,9 @@
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\Facades\Event;
use Illuminate\Support\Facades\Http;
@ -20,169 +18,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 +32,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 +53,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(\Illuminate\Support\Carbon::class, $article->validated_at);
}
}

View file

@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\Models;
use App\Enums\ApprovalStatusEnum;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RouteArticleTest extends TestCase
{
use RefreshDatabase;
public function test_route_article_belongs_to_article(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->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(\Illuminate\Database\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);
}
}

View file

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

View file

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

View file

@ -1,281 +0,0 @@
<?php
namespace Tests\Unit\Services\Publishing;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Services\Log\LogSaver;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class KeywordFilteringTest extends TestCase
{
use RefreshDatabase;
private ArticlePublishingService $service;
private Feed $feed;
private PlatformChannel $channel1;
private PlatformChannel $channel2;
private Route $route1;
private Route $route2;
protected function setUp(): void
{
parent::setUp();
$logSaver = Mockery::mock(LogSaver::class);
$logSaver->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');
}
}

View file

@ -1,211 +0,0 @@
<?php
namespace Tests\Unit\Services;
use App\Services\Article\ValidationService;
use Mockery;
use ReflectionClass;
use ReflectionMethod;
use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher;
class ValidationServiceKeywordTest extends TestCase
{
use CreatesArticleFetcher;
private ValidationService $validationService;
protected function setUp(): void
{
parent::setUp();
$articleFetcher = $this->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.'));
}
}

View file

@ -2,26 +2,33 @@
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 Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher;
class ValidationServiceTest extends TestCase
{
use CreatesArticleFetcher, RefreshDatabase;
use RefreshDatabase;
private ValidationService $validationService;
private \Mockery\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 +37,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('<html><body>Test content with Belgium news</body></html>', 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('<html><body>Empty</body></html>', 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('<html><body>Article content</body></html>', 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('<html><body>Article content</body></html>', 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('<html><body>Article content</body></html>', 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(
'<html><body><article>Article about Bart De Wever and Belgian politics</article></body></html>',
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);
}
}