Compare commits
14 commits
d21c054250
...
cc94ba8e55
| Author | SHA1 | Date | |
|---|---|---|---|
| cc94ba8e55 | |||
| bab2557e85 | |||
| 9430158051 | |||
| e7acbb6882 | |||
| 9fb373d139 | |||
| 2b74f24356 | |||
| 5e571babda | |||
| f3406b1713 | |||
| d0985fc57d | |||
| 0c35af4403 | |||
| f449548123 | |||
| e3ea02ae1c | |||
| 2a5a8c788b | |||
| b832d6d850 |
46 changed files with 2416 additions and 1886 deletions
10
app/Enums/ApprovalStatusEnum.php
Normal file
10
app/Enums/ApprovalStatusEnum.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ApprovalStatusEnum: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
}
|
||||
11
app/Enums/PublishStatusEnum.php
Normal file
11
app/Enums/PublishStatusEnum.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PublishStatusEnum: string
|
||||
{
|
||||
case UNPUBLISHED = 'unpublished';
|
||||
case PUBLISHING = 'publishing';
|
||||
case PUBLISHED = 'published';
|
||||
case ERROR = 'error';
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
101
app/Http/Controllers/Api/V1/RouteArticlesController.php
Normal file
101
app/Http/Controllers/Api/V1/RouteArticlesController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
38
app/Http/Resources/RouteArticleResource.php
Normal file
38
app/Http/Resources/RouteArticleResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Jobs/CleanupArticlesJob.php
Normal file
25
app/Jobs/CleanupArticlesJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
104
app/Models/RouteArticle.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ public function boot(): void
|
|||
);
|
||||
|
||||
Event::listen(
|
||||
\App\Events\ArticleApproved::class,
|
||||
\App\Events\RouteArticleApproved::class,
|
||||
\App\Listeners\PublishApprovedArticleListener::class,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
database/factories/RouteArticleFactory.php
Normal file
83
database/factories/RouteArticleFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>•</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' }} → {{ $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>
|
||||
|
|
|
|||
|
|
@ -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>•</span>
|
||||
<span>Created: {{ $route->created_at->format('M d, Y') }}</span>
|
||||
@if ($route->auto_approve === true)
|
||||
<span>•</span>
|
||||
<span class="text-green-600">Auto-approve: On</span>
|
||||
@elseif ($route->auto_approve === false)
|
||||
<span>•</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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
184
tests/Feature/Livewire/ArticlesTest.php
Normal file
184
tests/Feature/Livewire/ArticlesTest.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
130
tests/Feature/Livewire/RoutesAutoApproveTest.php
Normal file
130
tests/Feature/Livewire/RoutesAutoApproveTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
tests/Unit/Jobs/CleanupArticlesJobTest.php
Normal file
111
tests/Unit/Jobs/CleanupArticlesJobTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
tests/Unit/Models/RouteArticleTest.php
Normal file
154
tests/Unit/Models/RouteArticleTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue