Release v1.3.0 #100
111 changed files with 4002 additions and 2030 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';
|
||||
}
|
||||
10
app/Enums/NotificationSeverityEnum.php
Normal file
10
app/Enums/NotificationSeverityEnum.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationSeverityEnum: string
|
||||
{
|
||||
case INFO = 'info';
|
||||
case WARNING = 'warning';
|
||||
case ERROR = 'error';
|
||||
}
|
||||
21
app/Enums/NotificationTypeEnum.php
Normal file
21
app/Enums/NotificationTypeEnum.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationTypeEnum: string
|
||||
{
|
||||
case GENERAL = 'general';
|
||||
case FEED_STALE = 'feed_stale';
|
||||
case PUBLISH_FAILED = 'publish_failed';
|
||||
case CREDENTIAL_EXPIRED = 'credential_expired';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::GENERAL => 'General',
|
||||
self::FEED_STALE => 'Feed Stale',
|
||||
self::PUBLISH_FAILED => 'Publish Failed',
|
||||
self::CREDENTIAL_EXPIRED => 'Credential Expired',
|
||||
};
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
|
|
@ -26,7 +27,7 @@ public function create(Request $request): View
|
|||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
|
|
@ -21,7 +22,7 @@ public function create(): View
|
|||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
|
|
@ -25,7 +26,7 @@ public function create(): View
|
|||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class HandleAppearance
|
|||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
|
@ -22,7 +23,7 @@ public function authorize(): bool
|
|||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
|
@ -35,7 +36,7 @@ public function rules(): array
|
|||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
|
|
@ -55,7 +56,7 @@ public function authenticate(): void
|
|||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest
|
|||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\ArticlePublication;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\ArticlePublication
|
||||
* @mixin ArticlePublication
|
||||
*/
|
||||
class ArticlePublicationResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Article;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\Article
|
||||
* @mixin Article
|
||||
*/
|
||||
class ArticleResource extends JsonResource
|
||||
{
|
||||
|
|
@ -21,9 +22,6 @@ public function toArray(Request $request): array
|
|||
'url' => $this->url,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'is_valid' => $this->is_valid,
|
||||
'approval_status' => $this->approval_status,
|
||||
'publish_status' => $this->publish_status,
|
||||
'validated_at' => $this->validated_at?->toISOString(),
|
||||
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\Feed
|
||||
* @mixin Feed
|
||||
*/
|
||||
class FeedResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\PlatformAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformAccount
|
||||
* @mixin PlatformAccount
|
||||
*/
|
||||
/**
|
||||
* @mixin \App\Models\PlatformAccount
|
||||
* @mixin PlatformAccount
|
||||
*/
|
||||
class PlatformAccountResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformChannel
|
||||
* @mixin PlatformChannel
|
||||
*/
|
||||
class PlatformChannelResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformInstance
|
||||
* @mixin PlatformInstance
|
||||
*/
|
||||
class PlatformInstanceResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
39
app/Http/Resources/RouteArticleResource.php
Normal file
39
app/Http/Resources/RouteArticleResource.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin RouteArticle
|
||||
*/
|
||||
class RouteArticleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'feed_id' => $this->feed_id,
|
||||
'platform_channel_id' => $this->platform_channel_id,
|
||||
'article_id' => $this->article_id,
|
||||
'approval_status' => $this->approval_status->value,
|
||||
'publish_status' => $this->publish_status->value,
|
||||
'validated_at' => $this->validated_at?->toISOString(),
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'article' => [
|
||||
'id' => $this->article->id,
|
||||
'title' => $this->article->title,
|
||||
'url' => $this->article->url,
|
||||
'description' => $this->article->description,
|
||||
'feed_name' => $this->article->feed->name,
|
||||
],
|
||||
'route_name' => $this->feed->name.' → '.$this->platformChannel->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Route;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\Route
|
||||
* @mixin Route
|
||||
*/
|
||||
class RouteResource extends JsonResource
|
||||
{
|
||||
|
|
|
|||
51
app/Jobs/CheckFeedStalenessJob.php
Normal file
51
app/Jobs/CheckFeedStalenessJob.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class CheckFeedStalenessJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function handle(NotificationService $notificationService): void
|
||||
{
|
||||
$thresholdHours = Setting::getFeedStalenessThreshold();
|
||||
|
||||
if ($thresholdHours === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$staleFeeds = Feed::stale($thresholdHours)->get();
|
||||
|
||||
foreach ($staleFeeds as $feed) {
|
||||
$alreadyNotified = Notification::query()
|
||||
->where('type', NotificationTypeEnum::FEED_STALE)
|
||||
->where('notifiable_type', $feed->getMorphClass())
|
||||
->where('notifiable_id', $feed->getKey())
|
||||
->unread()
|
||||
->exists();
|
||||
|
||||
if ($alreadyNotified) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notificationService->send(
|
||||
type: NotificationTypeEnum::FEED_STALE,
|
||||
severity: NotificationSeverityEnum::WARNING,
|
||||
title: "Feed \"{$feed->name}\" is stale",
|
||||
message: $feed->last_fetched_at
|
||||
? "Last fetched {$feed->last_fetched_at->diffForHumans()}. Threshold is {$thresholdHours} hours."
|
||||
: "This feed has never been fetched. Threshold is {$thresholdHours} hours.",
|
||||
notifiable: $feed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,18 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -33,7 +38,7 @@ public function __construct()
|
|||
*
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
|
||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void
|
||||
{
|
||||
$interval = Setting::getArticlePublishingInterval();
|
||||
|
||||
|
|
@ -45,39 +50,73 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
|
|||
}
|
||||
}
|
||||
|
||||
// Get the oldest approved article that hasn't been published yet
|
||||
$article = Article::where('approval_status', 'approved')
|
||||
->whereDoesntHave('articlePublication')
|
||||
->oldest('created_at')
|
||||
// Get the oldest approved route_article that hasn't been published to its channel yet
|
||||
$routeArticle = RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)
|
||||
->whereDoesntHave('article.articlePublications', function ($query) {
|
||||
$query->whereColumn('article_publications.platform_channel_id', 'route_articles.platform_channel_id');
|
||||
})
|
||||
->oldest('route_articles.created_at')
|
||||
->with(['article', 'platformChannel.platformInstance', 'platformChannel.activePlatformAccounts'])
|
||||
->first();
|
||||
|
||||
if (! $article) {
|
||||
if (! $routeArticle) {
|
||||
return;
|
||||
}
|
||||
|
||||
$article = $routeArticle->article;
|
||||
|
||||
ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'url' => $article->url,
|
||||
'created_at' => $article->created_at,
|
||||
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
|
||||
]);
|
||||
|
||||
// Fetch article data
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
if ($publication) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publication was created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (PublishException $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@
|
|||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -16,51 +20,67 @@ class PublishApprovedArticleListener implements ShouldQueue
|
|||
|
||||
public function __construct(
|
||||
private ArticleFetcher $articleFetcher,
|
||||
private ArticlePublishingService $publishingService
|
||||
private ArticlePublishingService $publishingService,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
public function handle(ArticleApproved $event): void
|
||||
public function handle(RouteArticleApproved $event): void
|
||||
{
|
||||
$article = $event->article->fresh();
|
||||
$routeArticle = $event->routeArticle;
|
||||
$article = $routeArticle->article;
|
||||
|
||||
// Skip if already published
|
||||
if ($article->articlePublication()->exists()) {
|
||||
// Skip if already published to this channel
|
||||
if ($article->articlePublications()
|
||||
->where('platform_channel_id', $routeArticle->platform_channel_id)
|
||||
->exists()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not approved (safety check)
|
||||
if (! $article->isApproved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$article->update(['publish_status' => 'publishing']);
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$extractedData = $this->articleFetcher->fetchArticleData($article);
|
||||
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
|
||||
if ($publications->isNotEmpty()) {
|
||||
$article->update(['publish_status' => 'published']);
|
||||
if ($publication) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
||||
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
$article->update(['publish_status' => 'error']);
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
|
||||
ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publication was created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$article->update(['publish_status' => 'error']);
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Article;
|
||||
use App\Models\Setting;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -12,22 +13,44 @@ class Articles extends Component
|
|||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $tab = 'pending';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public bool $isRefreshing = false;
|
||||
|
||||
public function approve(int $articleId): void
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$article = Article::findOrFail($articleId);
|
||||
$article->approve();
|
||||
|
||||
$this->dispatch('article-updated');
|
||||
$this->tab = $tab;
|
||||
$this->search = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function reject(int $articleId): void
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$article = Article::findOrFail($articleId);
|
||||
$article->reject();
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
$this->dispatch('article-updated');
|
||||
public function approve(int $routeArticleId): void
|
||||
{
|
||||
RouteArticle::findOrFail($routeArticleId)->approve();
|
||||
}
|
||||
|
||||
public function reject(int $routeArticleId): void
|
||||
{
|
||||
RouteArticle::findOrFail($routeArticleId)->reject();
|
||||
}
|
||||
|
||||
public function restore(int $routeArticleId): void
|
||||
{
|
||||
$routeArticle = RouteArticle::findOrFail($routeArticleId);
|
||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
||||
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
|
|
@ -39,17 +62,28 @@ public function refresh(): void
|
|||
$this->dispatch('refresh-started');
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
$articles = Article::with(['feed', 'articlePublication'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(15);
|
||||
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$approvalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||
if ($this->tab === 'pending') {
|
||||
$query->where('approval_status', ApprovalStatusEnum::PENDING);
|
||||
} elseif ($this->search !== '') {
|
||||
$search = $this->search;
|
||||
$query->whereHas('article', function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$routeArticles = $query->paginate(15);
|
||||
|
||||
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||
|
||||
return view('livewire.articles', [
|
||||
'articles' => $articles,
|
||||
'approvalsEnabled' => $approvalsEnabled,
|
||||
'routeArticles' => $routeArticles,
|
||||
'pendingCount' => $pendingCount,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Channels extends Component
|
||||
|
|
@ -51,7 +52,7 @@ public function detachAccount(int $channelId, int $accountId): void
|
|||
$channel->platformAccounts()->detach($accountId);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Services\DashboardStatsService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
|
|
@ -19,7 +20,7 @@ public function setPeriod(string $period): void
|
|||
$this->period = $period;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
$service = app(DashboardStatsService::class);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Feeds extends Component
|
||||
|
|
@ -14,7 +15,7 @@ public function toggle(int $feedId): void
|
|||
$feed->save();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
$feeds = Feed::orderBy('name')->get();
|
||||
|
||||
|
|
|
|||
42
app/Livewire/NotificationBell.php
Normal file
42
app/Livewire/NotificationBell.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
public function markAsRead(int $id): void
|
||||
{
|
||||
Notification::findOrFail($id)->markAsRead();
|
||||
}
|
||||
|
||||
public function markAllAsRead(): void
|
||||
{
|
||||
Notification::markAllAsRead();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function unreadCount(): int
|
||||
{
|
||||
return Notification::unread()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Notification>
|
||||
*/
|
||||
#[Computed]
|
||||
public function notifications(): Collection
|
||||
{
|
||||
return Notification::recent()->get();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.notification-bell');
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@
|
|||
use App\Models\Setting;
|
||||
use App\Services\OnboardingService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use RuntimeException;
|
||||
|
||||
|
|
@ -68,7 +70,7 @@ class Onboarding extends Component
|
|||
|
||||
public bool $isLoading = false;
|
||||
|
||||
#[\Livewire\Attributes\Locked]
|
||||
#[Locked]
|
||||
public ?int $previousChannelLanguageId = null;
|
||||
|
||||
protected CreatePlatformAccountAction $createPlatformAccountAction;
|
||||
|
|
@ -406,7 +408,7 @@ public function getChannelLanguage(): ?Language
|
|||
return Language::find($this->channelLanguageId);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
// For channel step: only show languages that have providers
|
||||
$availableCodes = $this->getAvailableLanguageCodes();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Routes extends Component
|
||||
|
|
@ -26,6 +27,8 @@ class Routes extends Component
|
|||
// Edit form
|
||||
public int $editPriority = 50;
|
||||
|
||||
public string $editAutoApprove = '';
|
||||
|
||||
// Keyword management
|
||||
public string $newKeyword = '';
|
||||
|
||||
|
|
@ -81,6 +84,7 @@ public function openEditModal(int $feedId, int $channelId): void
|
|||
$this->editingFeedId = $feedId;
|
||||
$this->editingChannelId = $channelId;
|
||||
$this->editPriority = $route->priority;
|
||||
$this->editAutoApprove = $route->auto_approve === null ? '' : ($route->auto_approve ? '1' : '0');
|
||||
$this->newKeyword = '';
|
||||
$this->showKeywordInput = false;
|
||||
}
|
||||
|
|
@ -101,9 +105,18 @@ public function updateRoute(): void
|
|||
'editPriority' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$autoApprove = match ($this->editAutoApprove) {
|
||||
'1' => true,
|
||||
'0' => false,
|
||||
default => null,
|
||||
};
|
||||
|
||||
Route::where('feed_id', $this->editingFeedId)
|
||||
->where('platform_channel_id', $this->editingChannelId)
|
||||
->update(['priority' => $this->editPriority]);
|
||||
->update([
|
||||
'priority' => $this->editPriority,
|
||||
'auto_approve' => $autoApprove,
|
||||
]);
|
||||
|
||||
$this->closeEditModal();
|
||||
}
|
||||
|
|
@ -159,7 +172,7 @@ public function deleteKeyword(int $keywordId): void
|
|||
Keyword::destroy($keywordId);
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
$routes = Route::with(['feed', 'platformChannel.platformInstance'])
|
||||
->orderBy('priority', 'desc')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Settings extends Component
|
||||
|
|
@ -13,6 +14,8 @@ class Settings extends Component
|
|||
|
||||
public int $articlePublishingInterval = 5;
|
||||
|
||||
public int $feedStalenessThreshold = 48;
|
||||
|
||||
public ?string $successMessage = null;
|
||||
|
||||
public ?string $errorMessage = null;
|
||||
|
|
@ -22,6 +25,7 @@ public function mount(): void
|
|||
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||
$this->articlePublishingInterval = Setting::getArticlePublishingInterval();
|
||||
$this->feedStalenessThreshold = Setting::getFeedStalenessThreshold();
|
||||
}
|
||||
|
||||
public function toggleArticleProcessing(): void
|
||||
|
|
@ -48,6 +52,16 @@ public function updateArticlePublishingInterval(): void
|
|||
$this->showSuccess();
|
||||
}
|
||||
|
||||
public function updateFeedStalenessThreshold(): void
|
||||
{
|
||||
$this->validate([
|
||||
'feedStalenessThreshold' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
Setting::setFeedStalenessThreshold($this->feedStalenessThreshold);
|
||||
$this->showSuccess();
|
||||
}
|
||||
|
||||
protected function showSuccess(): void
|
||||
{
|
||||
$this->successMessage = 'Settings updated successfully!';
|
||||
|
|
@ -63,7 +77,7 @@ public function clearMessages(): void
|
|||
$this->errorMessage = null;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.settings')->layout('layouts.app');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
|
|
@ -15,9 +16,9 @@
|
|||
* @property string $platform
|
||||
* @property string $published_by
|
||||
* @property array<string, mixed>|null $publication_data
|
||||
* @property \Illuminate\Support\Carbon $published_at
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
* @property \Illuminate\Support\Carbon $updated_at
|
||||
* @property Carbon $published_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*
|
||||
* @method static create(array<string, mixed> $array)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FeedFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -86,6 +87,19 @@ public function getStatusAttribute(): string
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Feed> $query
|
||||
* @return Builder<Feed>
|
||||
*/
|
||||
public function scopeStale(Builder $query, int $thresholdHours): Builder
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function (Builder $query) use ($thresholdHours) {
|
||||
$query->whereNull('last_fetched_at')
|
||||
->orWhere('last_fetched_at', '<', now()->subHours($thresholdHours));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<PlatformChannel, $this>
|
||||
*/
|
||||
|
|
|
|||
92
app/Models/Notification.php
Normal file
92
app/Models/Notification.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use Database\Factories\NotificationFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property NotificationTypeEnum $type
|
||||
* @property NotificationSeverityEnum $severity
|
||||
* @property string $title
|
||||
* @property string $message
|
||||
* @property array<string, mixed>|null $data
|
||||
* @property string|null $notifiable_type
|
||||
* @property int|null $notifiable_id
|
||||
* @property Carbon|null $read_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Notification extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'severity',
|
||||
'title',
|
||||
'message',
|
||||
'data',
|
||||
'notifiable_type',
|
||||
'notifiable_id',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'type' => NotificationTypeEnum::class,
|
||||
'severity' => NotificationSeverityEnum::class,
|
||||
'data' => 'array',
|
||||
'read_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function notifiable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->read_at !== null;
|
||||
}
|
||||
|
||||
public function markAsRead(): void
|
||||
{
|
||||
$this->update(['read_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Notification> $query
|
||||
* @return Builder<Notification>
|
||||
*/
|
||||
public function scopeUnread(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('read_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Notification> $query
|
||||
* @return Builder<Notification>
|
||||
*/
|
||||
public function scopeRecent(Builder $query): Builder
|
||||
{
|
||||
return $query->latest()->limit(50);
|
||||
}
|
||||
|
||||
public static function markAllAsRead(): void
|
||||
{
|
||||
static::unread()->update(['read_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
*/
|
||||
class PlatformChannelPost extends Model
|
||||
{
|
||||
/** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<PlatformChannelPost>> */
|
||||
/** @use HasFactory<Factory<PlatformChannelPost>> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,4 +71,14 @@ public static function setArticlePublishingInterval(int $minutes): void
|
|||
{
|
||||
static::set('article_publishing_interval', (string) $minutes);
|
||||
}
|
||||
|
||||
public static function getFeedStalenessThreshold(): int
|
||||
{
|
||||
return (int) static::get('feed_staleness_threshold', 48);
|
||||
}
|
||||
|
||||
public static function setFeedStalenessThreshold(int $hours): void
|
||||
{
|
||||
static::set('feed_staleness_threshold', (string) $hours);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
|
@ -10,7 +11,7 @@
|
|||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@
|
|||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Listeners\LogActionListener;
|
||||
use App\Listeners\LogExceptionToDatabase;
|
||||
use App\Listeners\PublishApprovedArticleListener;
|
||||
use App\Listeners\ValidateArticleListener;
|
||||
use Error;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
|
@ -31,13 +35,13 @@ public function boot(): void
|
|||
);
|
||||
|
||||
Event::listen(
|
||||
\App\Events\NewArticleFetched::class,
|
||||
\App\Listeners\ValidateArticleListener::class,
|
||||
NewArticleFetched::class,
|
||||
ValidateArticleListener::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
\App\Events\ArticleApproved::class,
|
||||
\App\Listeners\PublishApprovedArticleListener::class,
|
||||
RouteArticleApproved::class,
|
||||
PublishApprovedArticleListener::class,
|
||||
);
|
||||
|
||||
app()->make(ExceptionHandler::class)
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article
|
|||
}
|
||||
|
||||
return $article;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->error('Failed to create article', null, [
|
||||
'url' => $url,
|
||||
'feed_id' => $feedId,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Http;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class HttpFetcher
|
||||
|
|
@ -48,7 +49,7 @@ public static function fetchMultipleUrls(array $urls): array
|
|||
->reject(fn ($response, $index) => $response instanceof Exception)
|
||||
->map(function ($response, $index) use ($urls) {
|
||||
$url = $urls[$index];
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
/** @var Response $response */
|
||||
try {
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
|
|
|
|||
69
app/Services/Notification/NotificationService.php
Normal file
69
app/Services/Notification/NotificationService.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function send(
|
||||
NotificationTypeEnum $type,
|
||||
NotificationSeverityEnum $severity,
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return Notification::create([
|
||||
'type' => $type,
|
||||
'severity' => $severity,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'data' => $data ?: null,
|
||||
'notifiable_type' => $notifiable?->getMorphClass(),
|
||||
'notifiable_id' => $notifiable?->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function info(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function warning(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function error(
|
||||
string $title,
|
||||
string $message,
|
||||
?Model $notifiable = null,
|
||||
array $data = [],
|
||||
): Notification {
|
||||
return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Http\Middleware\EnsureOnboardingComplete;
|
||||
use App\Http\Middleware\RedirectIfOnboardingComplete;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
|
@ -22,13 +24,13 @@
|
|||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->reportable(function (Throwable $e) {
|
||||
$level = match (true) {
|
||||
$e instanceof Error => \App\Enums\LogLevelEnum::CRITICAL,
|
||||
$e instanceof RuntimeException => \App\Enums\LogLevelEnum::ERROR,
|
||||
$e instanceof InvalidArgumentException => \App\Enums\LogLevelEnum::WARNING,
|
||||
default => \App\Enums\LogLevelEnum::ERROR,
|
||||
$e instanceof Error => LogLevelEnum::CRITICAL,
|
||||
$e instanceof RuntimeException => LogLevelEnum::ERROR,
|
||||
$e instanceof InvalidArgumentException => LogLevelEnum::WARNING,
|
||||
default => LogLevelEnum::ERROR,
|
||||
};
|
||||
|
||||
App\Events\ExceptionOccurred::dispatch(
|
||||
ExceptionOccurred::dispatch(
|
||||
$e,
|
||||
$level,
|
||||
$e->getMessage(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\HorizonServiceProvider;
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
HorizonServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -62,7 +64,7 @@
|
|||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Parsers\BelgaArticlePageParser;
|
||||
use App\Services\Parsers\BelgaArticleParser;
|
||||
use App\Services\Parsers\GuardianArticlePageParser;
|
||||
use App\Services\Parsers\GuardianArticleParser;
|
||||
use App\Services\Parsers\VrtArticlePageParser;
|
||||
use App\Services\Parsers\VrtArticleParser;
|
||||
use App\Services\Parsers\VrtHomepageParserAdapter;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -24,9 +32,9 @@
|
|||
'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'],
|
||||
],
|
||||
'parsers' => [
|
||||
'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class,
|
||||
'article' => \App\Services\Parsers\VrtArticleParser::class,
|
||||
'article_page' => \App\Services\Parsers\VrtArticlePageParser::class,
|
||||
'homepage' => VrtHomepageParserAdapter::class,
|
||||
'article' => VrtArticleParser::class,
|
||||
'article_page' => VrtArticlePageParser::class,
|
||||
],
|
||||
],
|
||||
'belga' => [
|
||||
|
|
@ -39,8 +47,8 @@
|
|||
'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
|
||||
],
|
||||
'parsers' => [
|
||||
'article' => \App\Services\Parsers\BelgaArticleParser::class,
|
||||
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
|
||||
'article' => BelgaArticleParser::class,
|
||||
'article_page' => BelgaArticlePageParser::class,
|
||||
],
|
||||
],
|
||||
'guardian' => [
|
||||
|
|
@ -53,8 +61,8 @@
|
|||
'en' => ['url' => 'https://www.theguardian.com/international/rss'],
|
||||
],
|
||||
'parsers' => [
|
||||
'article' => \App\Services\Parsers\GuardianArticleParser::class,
|
||||
'article_page' => \App\Services\Parsers\GuardianArticlePageParser::class,
|
||||
'article' => GuardianArticleParser::class,
|
||||
'article_page' => GuardianArticlePageParser::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
|
@ -76,9 +79,9 @@
|
|||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article>
|
||||
* @extends Factory<Article>
|
||||
*/
|
||||
class ArticleFactory extends Factory
|
||||
{
|
||||
|
|
@ -17,7 +19,7 @@ class ArticleFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'feed_id' => \App\Models\Feed::factory(),
|
||||
'feed_id' => Feed::factory(),
|
||||
'url' => $this->faker->url(),
|
||||
'title' => $this->faker->sentence(),
|
||||
'description' => $this->faker->paragraph(),
|
||||
|
|
@ -25,8 +27,6 @@ public function definition(): array
|
|||
'image_url' => $this->faker->optional()->imageUrl(),
|
||||
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
|
||||
'author' => $this->faker->optional()->name(),
|
||||
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
|
||||
'publish_status' => 'unpublished',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class KeywordFactory extends Factory
|
||||
|
|
@ -12,8 +14,8 @@ class KeywordFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'feed_id' => \App\Models\Feed::factory(),
|
||||
'platform_channel_id' => \App\Models\PlatformChannel::factory(),
|
||||
'feed_id' => Feed::factory(),
|
||||
'platform_channel_id' => PlatformChannel::factory(),
|
||||
'keyword' => $this->faker->word(),
|
||||
'is_active' => $this->faker->boolean(70), // 70% chance of being active
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
|
|
@ -21,14 +23,14 @@ public function definition(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function forFeed(\App\Models\Feed $feed): static
|
||||
public function forFeed(Feed $feed): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'feed_id' => $feed->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function forChannel(\App\Models\PlatformChannel $channel): static
|
||||
public function forChannel(PlatformChannel $channel): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform_channel_id' => $channel->id,
|
||||
|
|
|
|||
51
database/factories/NotificationFactory.php
Normal file
51
database/factories/NotificationFactory.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Notification>
|
||||
*/
|
||||
class NotificationFactory extends Factory
|
||||
{
|
||||
protected $model = Notification::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'type' => fake()->randomElement(NotificationTypeEnum::cases()),
|
||||
'severity' => fake()->randomElement(NotificationSeverityEnum::cases()),
|
||||
'title' => fake()->sentence(3),
|
||||
'message' => fake()->sentence(),
|
||||
'data' => null,
|
||||
'read_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function read(): static
|
||||
{
|
||||
return $this->state(['read_at' => now()]);
|
||||
}
|
||||
|
||||
public function unread(): static
|
||||
{
|
||||
return $this->state(['read_at' => null]);
|
||||
}
|
||||
|
||||
public function severity(NotificationSeverityEnum $severity): static
|
||||
{
|
||||
return $this->state(['severity' => $severity]);
|
||||
}
|
||||
|
||||
public function type(NotificationTypeEnum $type): static
|
||||
{
|
||||
return $this->state(['type' => $type]);
|
||||
}
|
||||
}
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('type');
|
||||
$table->string('severity');
|
||||
$table->string('title');
|
||||
$table->text('message');
|
||||
$table->json('data')->nullable();
|
||||
$table->string('notifiable_type')->nullable();
|
||||
$table->unsignedBigInteger('notifiable_id')->nullable();
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('read_at');
|
||||
$table->index('created_at');
|
||||
$table->index(['notifiable_type', 'notifiable_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -54,6 +54,30 @@ parameters:
|
|||
count: 1
|
||||
path: tests/Unit/Enums/PlatformEnumTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$platform_channel_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 4
|
||||
path: tests/Unit/Jobs/CleanupArticlesJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
|||
<nav class="mt-5 flex-1 px-2 bg-white">
|
||||
@include('layouts.navigation-items')
|
||||
</nav>
|
||||
<div class="flex-shrink-0 px-4 py-3 border-t border-gray-200">
|
||||
<livewire:notification-bell />
|
||||
</div>
|
||||
<div class="flex-shrink-0 p-4 border-t border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring
|
|||
</button>
|
||||
<div class="flex-1 px-4 flex justify-between items-center">
|
||||
<h1 class="text-lg font-medium text-gray-900">FFR</h1>
|
||||
<livewire:notification-bell />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
90
resources/views/livewire/notification-bell.blade.php
Normal file
90
resources/views/livewire/notification-bell.blade.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<div class="relative" x-data="{ open: false }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="relative p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
@if ($this->unreadCount > 0)
|
||||
<span class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-500 rounded-full">
|
||||
{{ $this->unreadCount > 99 ? '99+' : $this->unreadCount }}
|
||||
</span>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
@click.away="open = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-80 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
|
||||
@if ($this->unreadCount > 0)
|
||||
<button
|
||||
wire:click="markAllAsRead"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
@forelse ($this->notifications as $notification)
|
||||
<div
|
||||
class="px-4 py-3 border-b border-gray-50 last:border-b-0 {{ $notification->isRead() ? 'bg-white' : 'bg-blue-50' }}"
|
||||
wire:key="notification-{{ $notification->id }}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
@if ($notification->severity === \App\Enums\NotificationSeverityEnum::ERROR)
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
@elseif ($notification->severity === \App\Enums\NotificationSeverityEnum::WARNING)
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">{{ $notification->title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ $notification->message }}</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
|
||||
@unless ($notification->isRead())
|
||||
<button
|
||||
wire:click="markAsRead({{ $notification->id }})"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-4 py-8 text-center">
|
||||
<svg class="mx-auto h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500">No notifications</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -104,6 +104,51 @@ class="flex-shrink-0"
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Monitoring Settings -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900 flex items-center">
|
||||
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
Feed Monitoring
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Configure alerts for feeds that stop returning articles
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900">
|
||||
Staleness Threshold (hours)
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
wire:model="feedStalenessThreshold"
|
||||
min="0"
|
||||
step="1"
|
||||
class="w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
wire:click="updateFeedStalenessThreshold"
|
||||
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@error('feedStalenessThreshold')
|
||||
<p class="text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
@if ($successMessage)
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Jobs\CheckFeedStalenessJob;
|
||||
use App\Jobs\CleanupArticlesJob;
|
||||
use App\Jobs\PublishNextArticleJob;
|
||||
use App\Jobs\SyncChannelPostsJob;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
|
@ -20,3 +22,15 @@
|
|||
->name('refresh-articles')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
Schedule::job(new CheckFeedStalenessJob)
|
||||
->hourly()
|
||||
->name('check-feed-staleness')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
Schedule::job(new CleanupArticlesJob)
|
||||
->daily()
|
||||
->name('cleanup-old-articles')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -134,14 +136,12 @@ public function test_article_model_creates_successfully(): void
|
|||
'feed_id' => $feed->id,
|
||||
'title' => 'Test Article',
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('articles', [
|
||||
'feed_id' => $feed->id,
|
||||
'title' => 'Test Article',
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertEquals($feed->id, $article->feed->id);
|
||||
|
|
@ -334,7 +334,7 @@ public function test_model_soft_deletes_work_correctly(): void
|
|||
public function test_database_constraints_are_enforced(): void
|
||||
{
|
||||
// Test foreign key constraints
|
||||
$this->expectException(\Illuminate\Database\QueryException::class);
|
||||
$this->expectException(QueryException::class);
|
||||
|
||||
// Try to create article with non-existent feed_id
|
||||
Article::factory()->create(['feed_id' => 99999]);
|
||||
|
|
@ -357,7 +357,7 @@ public function test_all_factories_work_correctly(): void
|
|||
|
||||
foreach ($models as $model) {
|
||||
$this->assertNotNull($model);
|
||||
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Model::class, $model);
|
||||
$this->assertInstanceOf(Model::class, $model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
134
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal file
134
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Jobs;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Jobs\CheckFeedStalenessJob;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CheckFeedStalenessJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_stale_feed_creates_notification(): void
|
||||
{
|
||||
$feed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(50),
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::FEED_STALE->value,
|
||||
'severity' => NotificationSeverityEnum::WARNING->value,
|
||||
'notifiable_type' => $feed->getMorphClass(),
|
||||
'notifiable_id' => $feed->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_fresh_feed_does_not_create_notification(): void
|
||||
{
|
||||
Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(10),
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
public function test_inactive_feed_does_not_create_notification(): void
|
||||
{
|
||||
Feed::factory()->create([
|
||||
'is_active' => false,
|
||||
'last_fetched_at' => now()->subHours(100),
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
public function test_does_not_create_duplicate_notification_when_unread_exists(): void
|
||||
{
|
||||
$feed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(50),
|
||||
]);
|
||||
|
||||
Notification::factory()
|
||||
->type(NotificationTypeEnum::FEED_STALE)
|
||||
->unread()
|
||||
->create([
|
||||
'notifiable_type' => $feed->getMorphClass(),
|
||||
'notifiable_id' => $feed->id,
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
}
|
||||
|
||||
public function test_creates_new_notification_when_previous_is_read(): void
|
||||
{
|
||||
$feed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(50),
|
||||
]);
|
||||
|
||||
Notification::factory()
|
||||
->type(NotificationTypeEnum::FEED_STALE)
|
||||
->read()
|
||||
->create([
|
||||
'notifiable_type' => $feed->getMorphClass(),
|
||||
'notifiable_id' => $feed->id,
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseCount('notifications', 2);
|
||||
}
|
||||
|
||||
public function test_threshold_zero_disables_check(): void
|
||||
{
|
||||
Setting::setFeedStalenessThreshold(0);
|
||||
|
||||
Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(100),
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
public function test_never_fetched_feed_creates_notification(): void
|
||||
{
|
||||
$feed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => null,
|
||||
]);
|
||||
|
||||
$this->dispatch();
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::FEED_STALE->value,
|
||||
'notifiable_type' => $feed->getMorphClass(),
|
||||
'notifiable_id' => $feed->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function dispatch(): void
|
||||
{
|
||||
(new CheckFeedStalenessJob)->handle(app(NotificationService::class));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,24 +2,26 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ArticleApproved;
|
||||
// use App\Events\ArticleReadyToPublish; // Class no longer exists
|
||||
use App\Events\ExceptionLogged;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Jobs\ArticleDiscoveryForFeedJob;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Jobs\PublishNextArticleJob;
|
||||
use App\Jobs\SyncChannelPostsJob;
|
||||
use App\Listeners\LogExceptionToDatabase;
|
||||
// use App\Listeners\PublishApprovedArticle; // Class no longer exists
|
||||
// use App\Listeners\PublishArticle; // Class no longer exists
|
||||
use App\Listeners\ValidateArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\Log;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
|
|
@ -61,14 +63,14 @@ public function test_article_discovery_for_feed_job_processes_feed(): void
|
|||
]);
|
||||
|
||||
// Mock the ArticleFetcher service in the container
|
||||
$mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class);
|
||||
$mockFetcher = \Mockery::mock(ArticleFetcher::class);
|
||||
$article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]);
|
||||
$article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]);
|
||||
$mockFetcher->shouldReceive('getArticlesFromFeed')
|
||||
->with($feed)
|
||||
->andReturn(collect([$article1, $article2]));
|
||||
|
||||
$this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
|
||||
$this->app->instance(ArticleFetcher::class, $mockFetcher);
|
||||
|
||||
$logSaver = app(LogSaver::class);
|
||||
$articleFetcher = app(ArticleFetcher::class);
|
||||
|
|
@ -116,40 +118,13 @@ public function test_new_article_fetched_event_is_dispatched(): void
|
|||
});
|
||||
}
|
||||
|
||||
public function test_article_approved_event_is_dispatched(): void
|
||||
{
|
||||
Event::fake();
|
||||
|
||||
$article = Article::factory()->create();
|
||||
|
||||
event(new ArticleApproved($article));
|
||||
|
||||
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) {
|
||||
return $event->article->id === $article->id;
|
||||
});
|
||||
}
|
||||
|
||||
// Test removed - ArticleReadyToPublish class no longer exists
|
||||
// public function test_article_ready_to_publish_event_is_dispatched(): void
|
||||
// {
|
||||
// Event::fake();
|
||||
|
||||
// $article = Article::factory()->create();
|
||||
|
||||
// event(new ArticleReadyToPublish($article));
|
||||
|
||||
// Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) {
|
||||
// return $event->article->id === $article->id;
|
||||
// });
|
||||
// }
|
||||
|
||||
public function test_exception_occurred_event_is_dispatched(): void
|
||||
{
|
||||
Event::fake();
|
||||
|
||||
$exception = new \Exception('Test exception');
|
||||
|
||||
event(new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception', ['context' => 'test']));
|
||||
event(new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception', ['context' => 'test']));
|
||||
|
||||
Event::assertDispatched(ExceptionOccurred::class, function (ExceptionOccurred $event) {
|
||||
return $event->exception->getMessage() === 'Test exception';
|
||||
|
|
@ -175,20 +150,25 @@ public function test_exception_logged_event_is_dispatched(): void
|
|||
|
||||
public function test_validate_article_listener_processes_new_article(): void
|
||||
{
|
||||
Event::fake([ArticleApproved::class]);
|
||||
|
||||
// Disable approvals so listener auto-approves valid articles
|
||||
Setting::setBool('enable_publishing_approvals', false);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
// Mock ArticleFetcher to return valid article data
|
||||
$mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class);
|
||||
$this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
|
||||
$mockFetcher = \Mockery::mock(ArticleFetcher::class);
|
||||
$this->app->instance(ArticleFetcher::class, $mockFetcher);
|
||||
$mockFetcher->shouldReceive('fetchArticleData')
|
||||
->with($article)
|
||||
->andReturn([
|
||||
|
|
@ -203,45 +183,13 @@ public function test_validate_article_listener_processes_new_article(): void
|
|||
$listener->handle($event);
|
||||
|
||||
$article->refresh();
|
||||
$this->assertEquals('approved', $article->approval_status);
|
||||
Event::assertDispatched(ArticleApproved::class);
|
||||
$this->assertNotNull($article->validated_at);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertNotNull($routeArticle);
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
// Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist
|
||||
// public function test_publish_approved_article_listener_queues_job(): void
|
||||
// {
|
||||
// Event::fake();
|
||||
|
||||
// $article = Article::factory()->create([
|
||||
// 'approval_status' => 'approved',
|
||||
// 'approval_status' => 'approved',
|
||||
// ]);
|
||||
|
||||
// $listener = new PublishApprovedArticle();
|
||||
// $event = new ArticleApproved($article);
|
||||
|
||||
// $listener->handle($event);
|
||||
|
||||
// Event::assertDispatched(ArticleReadyToPublish::class);
|
||||
// }
|
||||
|
||||
// Test removed - PublishArticle and ArticleReadyToPublish classes no longer exist
|
||||
// public function test_publish_article_listener_queues_publish_job(): void
|
||||
// {
|
||||
// Queue::fake();
|
||||
|
||||
// $article = Article::factory()->create([
|
||||
// 'approval_status' => 'approved',
|
||||
// ]);
|
||||
|
||||
// $listener = new PublishArticle();
|
||||
// $event = new ArticleReadyToPublish($article);
|
||||
|
||||
// $listener->handle($event);
|
||||
|
||||
// Queue::assertPushed(PublishNextArticleJob::class);
|
||||
// }
|
||||
|
||||
public function test_log_exception_to_database_listener_creates_log(): void
|
||||
{
|
||||
$log = Log::factory()->create([
|
||||
|
|
@ -252,7 +200,7 @@ public function test_log_exception_to_database_listener_creates_log(): void
|
|||
|
||||
$listener = new LogExceptionToDatabase;
|
||||
$exception = new \Exception('Test exception message');
|
||||
$event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message');
|
||||
$event = new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception message');
|
||||
|
||||
$listener->handle($event);
|
||||
|
||||
|
|
@ -263,7 +211,7 @@ public function test_log_exception_to_database_listener_creates_log(): void
|
|||
|
||||
$savedLog = Log::where('message', 'Test exception message')->first();
|
||||
$this->assertNotNull($savedLog);
|
||||
$this->assertEquals(\App\Enums\LogLevelEnum::ERROR, $savedLog->level);
|
||||
$this->assertEquals(LogLevelEnum::ERROR, $savedLog->level);
|
||||
}
|
||||
|
||||
public function test_event_listener_registration_works(): void
|
||||
|
|
@ -275,13 +223,8 @@ public function test_event_listener_registration_works(): void
|
|||
$listeners = Event::getListeners(NewArticleFetched::class);
|
||||
$this->assertNotEmpty($listeners);
|
||||
|
||||
// ArticleApproved event exists but has no listeners after publishing redesign
|
||||
// $listeners = Event::getListeners(ArticleApproved::class);
|
||||
// $this->assertNotEmpty($listeners);
|
||||
|
||||
// ArticleReadyToPublish no longer exists - removed this check
|
||||
// $listeners = Event::getListeners(ArticleReadyToPublish::class);
|
||||
// $this->assertNotEmpty($listeners);
|
||||
$listeners = Event::getListeners(RouteArticleApproved::class);
|
||||
$this->assertNotEmpty($listeners);
|
||||
|
||||
$listeners = Event::getListeners(ExceptionOccurred::class);
|
||||
$this->assertNotEmpty($listeners);
|
||||
|
|
|
|||
149
tests/Feature/Listeners/PublishApprovedArticleListenerTest.php
Normal file
149
tests/Feature/Listeners/PublishApprovedArticleListenerTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Listeners;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Listeners\PublishApprovedArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublishApprovedArticleListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'title' => $title,
|
||||
]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return $routeArticle;
|
||||
}
|
||||
|
||||
public function test_exception_during_publishing_creates_error_notification(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andThrow(new Exception('Connection refused'));
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::ERROR->value,
|
||||
'notifiable_type' => $routeArticle->article->getMorphClass(),
|
||||
'notifiable_id' => $routeArticle->article_id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('Test Article', $notification->title);
|
||||
$this->assertStringContainsString('Connection refused', $notification->message);
|
||||
}
|
||||
|
||||
public function test_no_publication_created_creates_warning_notification(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::WARNING->value,
|
||||
'notifiable_type' => $routeArticle->article->getMorphClass(),
|
||||
'notifiable_id' => $routeArticle->article_id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('Test Article', $notification->title);
|
||||
}
|
||||
|
||||
public function test_successful_publish_does_not_create_notification(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
public function test_skips_already_published_to_channel(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
|
||||
ArticlePublication::factory()->create([
|
||||
'article_id' => $routeArticle->article_id,
|
||||
'platform_channel_id' => $routeArticle->platform_channel_id,
|
||||
]);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
}
|
||||
85
tests/Feature/Livewire/NotificationBellTest.php
Normal file
85
tests/Feature/Livewire/NotificationBellTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\NotificationBell;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationBellTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_renders_successfully(): void
|
||||
{
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Notifications');
|
||||
}
|
||||
|
||||
public function test_it_shows_unread_count_badge(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('3');
|
||||
}
|
||||
|
||||
public function test_it_hides_badge_when_no_unread(): void
|
||||
{
|
||||
Notification::factory()->count(2)->read()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertDontSee('Mark all as read');
|
||||
}
|
||||
|
||||
public function test_it_shows_empty_state_when_no_notifications(): void
|
||||
{
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('No notifications');
|
||||
}
|
||||
|
||||
public function test_mark_as_read(): void
|
||||
{
|
||||
$notification = Notification::factory()->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->call('markAsRead', $notification->id);
|
||||
|
||||
$this->assertTrue($notification->fresh()->isRead());
|
||||
}
|
||||
|
||||
public function test_mark_all_as_read(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
|
||||
$this->assertEquals(3, Notification::unread()->count());
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->call('markAllAsRead');
|
||||
|
||||
$this->assertEquals(0, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_it_displays_notification_title_and_message(): void
|
||||
{
|
||||
Notification::factory()->create([
|
||||
'title' => 'Test Notification Title',
|
||||
'message' => 'Test notification message body',
|
||||
]);
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('Test Notification Title')
|
||||
->assertSee('Test notification message body');
|
||||
}
|
||||
|
||||
public function test_it_caps_badge_at_99_plus(): void
|
||||
{
|
||||
Notification::factory()->count(100)->unread()->create();
|
||||
|
||||
Livewire::test(NotificationBell::class)
|
||||
->assertSee('99+');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
54
tests/Feature/Livewire/SettingsTest.php
Normal file
54
tests/Feature/Livewire/SettingsTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\Settings;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_mount_loads_feed_staleness_threshold(): void
|
||||
{
|
||||
Setting::setFeedStalenessThreshold(72);
|
||||
|
||||
Livewire::test(Settings::class)
|
||||
->assertSet('feedStalenessThreshold', 72);
|
||||
}
|
||||
|
||||
public function test_mount_loads_default_feed_staleness_threshold(): void
|
||||
{
|
||||
Livewire::test(Settings::class)
|
||||
->assertSet('feedStalenessThreshold', 48);
|
||||
}
|
||||
|
||||
public function test_update_feed_staleness_threshold_saves_value(): void
|
||||
{
|
||||
Livewire::test(Settings::class)
|
||||
->set('feedStalenessThreshold', 24)
|
||||
->call('updateFeedStalenessThreshold')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame(24, Setting::getFeedStalenessThreshold());
|
||||
}
|
||||
|
||||
public function test_update_feed_staleness_threshold_validates_minimum(): void
|
||||
{
|
||||
Livewire::test(Settings::class)
|
||||
->set('feedStalenessThreshold', -1)
|
||||
->call('updateFeedStalenessThreshold')
|
||||
->assertHasErrors(['feedStalenessThreshold' => 'min']);
|
||||
}
|
||||
|
||||
public function test_update_feed_staleness_threshold_shows_success_message(): void
|
||||
{
|
||||
Livewire::test(Settings::class)
|
||||
->set('feedStalenessThreshold', 24)
|
||||
->call('updateFeedStalenessThreshold')
|
||||
->assertSet('successMessage', 'Settings updated successfully!');
|
||||
}
|
||||
}
|
||||
166
tests/Feature/NotificationTest.php
Normal file
166
tests/Feature/NotificationTest.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new NotificationService;
|
||||
}
|
||||
|
||||
public function test_send_creates_notification_with_all_fields(): void
|
||||
{
|
||||
$notification = $this->service->send(
|
||||
NotificationTypeEnum::FEED_STALE,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
'Feed is stale',
|
||||
'Feed "VRT" has not returned new articles in 24 hours.',
|
||||
data: ['hours_stale' => 24],
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => 'feed_stale',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Feed is stale',
|
||||
]);
|
||||
$this->assertEquals(['hours_stale' => 24], $notification->data);
|
||||
$this->assertNull($notification->read_at);
|
||||
}
|
||||
|
||||
public function test_info_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->info('Test title', 'Test message');
|
||||
|
||||
$this->assertEquals(NotificationTypeEnum::GENERAL, $notification->type);
|
||||
$this->assertEquals(NotificationSeverityEnum::INFO, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_warning_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->warning('Warning title', 'Warning message');
|
||||
|
||||
$this->assertEquals(NotificationSeverityEnum::WARNING, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_error_convenience_method(): void
|
||||
{
|
||||
$notification = $this->service->error('Error title', 'Error message');
|
||||
|
||||
$this->assertEquals(NotificationSeverityEnum::ERROR, $notification->severity);
|
||||
}
|
||||
|
||||
public function test_mark_as_read(): void
|
||||
{
|
||||
$notification = Notification::factory()->unread()->create();
|
||||
|
||||
$this->assertFalse($notification->isRead());
|
||||
|
||||
$notification->markAsRead();
|
||||
|
||||
$this->assertTrue($notification->fresh()->isRead());
|
||||
$this->assertNotNull($notification->fresh()->read_at);
|
||||
}
|
||||
|
||||
public function test_mark_all_as_read(): void
|
||||
{
|
||||
Notification::factory()->count(3)->unread()->create();
|
||||
Notification::factory()->count(2)->read()->create();
|
||||
|
||||
$this->assertEquals(3, Notification::unread()->count());
|
||||
|
||||
Notification::markAllAsRead();
|
||||
|
||||
$this->assertEquals(0, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_unread_scope(): void
|
||||
{
|
||||
Notification::factory()->count(2)->unread()->create();
|
||||
Notification::factory()->count(3)->read()->create();
|
||||
|
||||
$this->assertEquals(2, Notification::unread()->count());
|
||||
}
|
||||
|
||||
public function test_recent_scope_limits_to_50(): void
|
||||
{
|
||||
Notification::factory()->count(60)->create();
|
||||
|
||||
$this->assertCount(50, Notification::recent()->get());
|
||||
}
|
||||
|
||||
public function test_enums_cast_correctly(): void
|
||||
{
|
||||
$notification = Notification::factory()->create([
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED,
|
||||
'severity' => NotificationSeverityEnum::ERROR,
|
||||
]);
|
||||
|
||||
$fresh = Notification::find($notification->id);
|
||||
$this->assertInstanceOf(NotificationTypeEnum::class, $fresh->type);
|
||||
$this->assertInstanceOf(NotificationSeverityEnum::class, $fresh->severity);
|
||||
$this->assertEquals(NotificationTypeEnum::PUBLISH_FAILED, $fresh->type);
|
||||
$this->assertEquals(NotificationSeverityEnum::ERROR, $fresh->severity);
|
||||
}
|
||||
|
||||
public function test_notifiable_morph_to_relationship(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
|
||||
$notification = $this->service->send(
|
||||
NotificationTypeEnum::FEED_STALE,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
'Feed stale',
|
||||
'No new articles',
|
||||
notifiable: $feed,
|
||||
);
|
||||
|
||||
$fresh = Notification::find($notification->id);
|
||||
$this->assertInstanceOf(Feed::class, $fresh->notifiable);
|
||||
$this->assertEquals($feed->id, $fresh->notifiable->id);
|
||||
}
|
||||
|
||||
public function test_notification_without_notifiable(): void
|
||||
{
|
||||
$notification = $this->service->info('General notice', 'Something happened');
|
||||
|
||||
$this->assertNull($notification->notifiable_type);
|
||||
$this->assertNull($notification->notifiable_id);
|
||||
$this->assertNull($notification->notifiable);
|
||||
}
|
||||
|
||||
public function test_notification_type_enum_labels(): void
|
||||
{
|
||||
$this->assertEquals('General', NotificationTypeEnum::GENERAL->label());
|
||||
$this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label());
|
||||
$this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label());
|
||||
$this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label());
|
||||
}
|
||||
|
||||
public function test_data_stores_null_when_empty(): void
|
||||
{
|
||||
$notification = $this->service->info('Title', 'Message');
|
||||
|
||||
$this->assertNull($notification->data);
|
||||
}
|
||||
|
||||
public function test_data_stores_array_when_provided(): void
|
||||
{
|
||||
$notification = $this->service->info('Title', 'Message', data: ['key' => 'value']);
|
||||
|
||||
$this->assertEquals(['key' => 'value'], $notification->data);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,82 +2,96 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Listeners\ValidateArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Article\ValidationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ValidateArticleListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_listener_validates_article_and_dispatches_ready_to_publish_event(): void
|
||||
private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener
|
||||
{
|
||||
Event::fake([ArticleApproved::class]);
|
||||
$articleFetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcher->shouldReceive('fetchArticleData')->andReturn(
|
||||
$content ? [
|
||||
'title' => 'Test Title',
|
||||
'description' => 'Test description',
|
||||
'full_article' => $content,
|
||||
] : []
|
||||
);
|
||||
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
|
||||
return new ValidateArticleListener(
|
||||
new ValidationService($articleFetcher)
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_listener_validates_article_and_creates_route_articles(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$listener = app(ValidateArticleListener::class);
|
||||
$event = new NewArticleFetched($article);
|
||||
|
||||
$listener->handle($event);
|
||||
$listener = $this->createListenerWithMockedFetcher('Article about Belgium');
|
||||
$listener->handle(new NewArticleFetched($article));
|
||||
|
||||
$article->refresh();
|
||||
$this->assertNotNull($article->validated_at);
|
||||
|
||||
if ($article->isValid()) {
|
||||
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) {
|
||||
return $event->article->id === $article->id;
|
||||
});
|
||||
} else {
|
||||
Event::assertNotDispatched(ArticleApproved::class);
|
||||
}
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertNotNull($routeArticle);
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_listener_skips_already_validated_articles(): void
|
||||
{
|
||||
Event::fake([ArticleApproved::class]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'approved',
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$listener = app(ValidateArticleListener::class);
|
||||
$event = new NewArticleFetched($article);
|
||||
$listener = $this->createListenerWithMockedFetcher();
|
||||
$listener->handle(new NewArticleFetched($article));
|
||||
|
||||
$listener->handle($event);
|
||||
|
||||
Event::assertNotDispatched(ArticleApproved::class);
|
||||
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
|
||||
}
|
||||
|
||||
public function test_listener_skips_articles_with_existing_publication(): void
|
||||
{
|
||||
Event::fake([ArticleApproved::class]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
ArticlePublication::create([
|
||||
|
|
@ -88,38 +102,29 @@ public function test_listener_skips_articles_with_existing_publication(): void
|
|||
'published_by' => 'test-user',
|
||||
]);
|
||||
|
||||
$listener = app(ValidateArticleListener::class);
|
||||
$event = new NewArticleFetched($article);
|
||||
$listener = $this->createListenerWithMockedFetcher();
|
||||
$listener->handle(new NewArticleFetched($article));
|
||||
|
||||
$listener->handle($event);
|
||||
|
||||
Event::assertNotDispatched(ArticleApproved::class);
|
||||
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
|
||||
}
|
||||
|
||||
public function test_listener_calls_validation_service(): void
|
||||
public function test_listener_handles_validation_errors_gracefully(): void
|
||||
{
|
||||
Event::fake([ArticleApproved::class]);
|
||||
$articleFetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed'));
|
||||
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
|
||||
]);
|
||||
$listener = new ValidateArticleListener(
|
||||
new ValidationService($articleFetcher)
|
||||
);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$listener = app(ValidateArticleListener::class);
|
||||
$event = new NewArticleFetched($article);
|
||||
$listener->handle(new NewArticleFetched($article));
|
||||
|
||||
$listener->handle($event);
|
||||
|
||||
// Verify that the article was processed by ValidationService
|
||||
$article->refresh();
|
||||
$this->assertNotEquals('pending', $article->approval_status, 'Article should have been validated');
|
||||
$this->assertContains($article->approval_status, ['approved', 'rejected'], 'Article should have validation result');
|
||||
$this->assertNull($article->fresh()->validated_at);
|
||||
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
trait CreatesArticleFetcher
|
||||
{
|
||||
|
|
@ -21,7 +22,7 @@ protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetc
|
|||
return new ArticleFetcher($logSaver);
|
||||
}
|
||||
|
||||
/** @return array{ArticleFetcher, \Mockery\MockInterface} */
|
||||
/** @return array{ArticleFetcher, MockInterface} */
|
||||
protected function createArticleFetcherWithMockedLogSaver(): array
|
||||
{
|
||||
$logSaver = Mockery::mock(LogSaver::class);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Services\Auth\LemmyAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreatePlatformAccountActionTest extends TestCase
|
||||
|
|
@ -18,7 +19,7 @@ class CreatePlatformAccountActionTest extends TestCase
|
|||
|
||||
private CreatePlatformAccountAction $action;
|
||||
|
||||
/** @var LemmyAuthService&\Mockery\MockInterface */
|
||||
/** @var LemmyAuthService&MockInterface */
|
||||
private LemmyAuthService $lemmyAuthService;
|
||||
|
||||
protected function setUp(): void
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ActionPerformedTest extends TestCase
|
||||
|
|
@ -41,7 +42,7 @@ public function test_event_uses_dispatchable_trait(): void
|
|||
public function test_event_does_not_use_serializes_models_trait(): void
|
||||
{
|
||||
$this->assertNotContains(
|
||||
\Illuminate\Queue\SerializesModels::class,
|
||||
SerializesModels::class,
|
||||
class_uses(ActionPerformed::class),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Unit\Exceptions;
|
||||
|
||||
use App\Exceptions\RoutingException;
|
||||
use App\Exceptions\RoutingMismatchException;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Language;
|
||||
|
|
@ -52,7 +53,7 @@ public function test_exception_extends_routing_exception(): void
|
|||
$exception = new RoutingMismatchException($feed, $channel);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(\App\Exceptions\RoutingException::class, $exception);
|
||||
$this->assertInstanceOf(RoutingException::class, $exception);
|
||||
}
|
||||
|
||||
public function test_exception_with_different_languages(): void
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
use App\Models\Feed;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
|
|
@ -34,7 +36,7 @@ public function test_job_implements_should_queue(): void
|
|||
$feed = Feed::factory()->make();
|
||||
$job = new ArticleDiscoveryForFeedJob($feed);
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
|
||||
$this->assertInstanceOf(ShouldQueue::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_uses_queueable_trait(): void
|
||||
|
|
@ -43,7 +45,7 @@ public function test_job_uses_queueable_trait(): void
|
|||
$job = new ArticleDiscoveryForFeedJob($feed);
|
||||
|
||||
$this->assertContains(
|
||||
\Illuminate\Foundation\Queue\Queueable::class,
|
||||
Queueable::class,
|
||||
class_uses($job)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
|
|
@ -100,7 +101,7 @@ public function test_job_implements_should_queue(): void
|
|||
$job = new ArticleDiscoveryJob;
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
|
||||
$this->assertInstanceOf(ShouldQueue::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_uses_queueable_trait(): void
|
||||
|
|
|
|||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,23 @@
|
|||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Jobs\PublishNextArticleJob;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -18,9 +27,31 @@ class PublishNextArticleJobTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationService $notificationService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->notificationService = new NotificationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $articleOverrides
|
||||
* @param array<string, mixed> $routeOverrides
|
||||
*/
|
||||
private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
|
||||
$article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return $routeArticle;
|
||||
}
|
||||
|
||||
public function test_constructor_sets_correct_queue(): void
|
||||
|
|
@ -34,14 +65,14 @@ public function test_job_implements_should_queue(): void
|
|||
{
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
|
||||
$this->assertInstanceOf(ShouldQueue::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_implements_should_be_unique(): void
|
||||
{
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
|
||||
$this->assertInstanceOf(ShouldBeUnique::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_has_unique_for_property(): void
|
||||
|
|
@ -55,193 +86,314 @@ public function test_job_uses_queueable_trait(): void
|
|||
{
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
$this->assertContains(
|
||||
\Illuminate\Foundation\Queue\Queueable::class,
|
||||
class_uses($job)
|
||||
);
|
||||
$this->assertContains(Queueable::class, class_uses($job));
|
||||
}
|
||||
|
||||
public function test_handle_returns_early_when_no_approved_articles(): void
|
||||
public function test_handle_returns_early_when_no_approved_route_articles(): void
|
||||
{
|
||||
// Arrange - No articles exist
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
// No expectations as handle should return early
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
// Assert - Should complete without error
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_returns_early_when_no_unpublished_approved_articles(): void
|
||||
public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
|
||||
// Create a publication record to mark it as already published
|
||||
ArticlePublication::factory()->create(['article_id' => $article->id]);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
// No expectations as handle should return early
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
// Assert - Should complete without error
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_skips_non_approved_articles(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'rejected',
|
||||
// Mark the article as already published to this channel
|
||||
ArticlePublication::factory()->create([
|
||||
'article_id' => $routeArticle->article_id,
|
||||
'platform_channel_id' => $routeArticle->platform_channel_id,
|
||||
]);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
// No expectations as handle should return early
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
// Assert - Should complete without error (no approved articles to process)
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_oldest_approved_article(): void
|
||||
public function test_handle_skips_non_approved_route_articles(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
// Create older article first
|
||||
$olderArticle = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_oldest_approved_route_article(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$olderArticle = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$newerArticle = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $olderArticle->id,
|
||||
'created_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
// Create newer article
|
||||
$newerArticle = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $newerArticle->id,
|
||||
'created_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$extractedData = ['title' => 'Test Article', 'content' => 'Test content'];
|
||||
|
||||
// Mock ArticleFetcher
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->with(Mockery::on(function ($article) use ($olderArticle) {
|
||||
return $article->id === $olderArticle->id;
|
||||
}))
|
||||
->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id))
|
||||
->andReturn($extractedData);
|
||||
|
||||
// Mock ArticlePublishingService
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->with(
|
||||
Mockery::on(function ($article) use ($olderArticle) {
|
||||
return $article->id === $olderArticle->id;
|
||||
}),
|
||||
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
|
||||
$extractedData
|
||||
);
|
||||
)
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
// Assert - Mockery expectations are verified in tearDown
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_throws_exception_on_publishing_failure(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
$routeArticle = $this->createApprovedRouteArticle();
|
||||
$article = $routeArticle->article;
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
$publishException = new PublishException($article, null);
|
||||
|
||||
// Mock ArticleFetcher
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->with(Mockery::type(Article::class))
|
||||
->andReturn($extractedData);
|
||||
|
||||
// Mock ArticlePublishingService to throw exception
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andThrow($publishException);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Assert
|
||||
$this->expectException(PublishException::class);
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
}
|
||||
|
||||
public function test_handle_logs_publishing_start(): void
|
||||
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'title' => 'Test Article Title',
|
||||
'url' => 'https://example.com/article',
|
||||
$this->createApprovedRouteArticle();
|
||||
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(3),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_last_publication_beyond_interval(): void
|
||||
{
|
||||
$this->createApprovedRouteArticle();
|
||||
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(15),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
// Mock ArticleFetcher
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
// Mock ArticlePublishingService
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')->once();
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_interval_is_zero(): void
|
||||
{
|
||||
$this->createApprovedRouteArticle();
|
||||
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now(),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(0);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
|
||||
{
|
||||
$this->createApprovedRouteArticle();
|
||||
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(10),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_no_previous_publications_exist(): void
|
||||
{
|
||||
$this->createApprovedRouteArticle();
|
||||
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(ArticlePublication::factory()->make());
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_creates_warning_notification_when_no_publication_created(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']);
|
||||
|
||||
$extractedData = ['title' => 'No Route Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturn(null);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::WARNING->value,
|
||||
'notifiable_type' => $routeArticle->article->getMorphClass(),
|
||||
'notifiable_id' => $routeArticle->article_id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('No Route Article', $notification->title);
|
||||
}
|
||||
|
||||
public function test_handle_creates_notification_on_publish_exception(): void
|
||||
{
|
||||
$routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']);
|
||||
$article = $routeArticle->article;
|
||||
|
||||
$extractedData = ['title' => 'Failing Article'];
|
||||
$publishException = new PublishException($article, null);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andThrow($publishException);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
try {
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
} catch (PublishException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert - Verify the job completes (logging is verified by observing no exceptions)
|
||||
$this->assertTrue(true);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::ERROR->value,
|
||||
'notifiable_type' => $article->getMorphClass(),
|
||||
'notifiable_id' => $article->id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('Failing Article', $notification->title);
|
||||
}
|
||||
|
||||
public function test_job_can_be_serialized(): void
|
||||
|
|
@ -256,186 +408,6 @@ public function test_job_can_be_serialized(): void
|
|||
$this->assertEquals($job->uniqueFor, $unserialized->uniqueFor);
|
||||
}
|
||||
|
||||
public function test_handle_fetches_article_data_before_publishing(): void
|
||||
{
|
||||
// Arrange
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
$extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content'];
|
||||
|
||||
// Mock ArticleFetcher with specific expectations
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->with(Mockery::type(Article::class))
|
||||
->andReturn($extractedData);
|
||||
|
||||
// Mock publishing service to receive the extracted data
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once()
|
||||
->with(Mockery::type(Article::class), $extractedData);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
// Assert - Mockery expectations verified in tearDown
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
// Last publication was 3 minutes ago, interval is 10 minutes
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(3),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
|
||||
// Neither should be called
|
||||
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
||||
$publishingServiceMock->shouldNotReceive('publishToRoutedChannels');
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_last_publication_beyond_interval(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
// Last publication was 15 minutes ago, interval is 10 minutes
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(15),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_interval_is_zero(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
// Last publication was just now, but interval is 0
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now(),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(0);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
// Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish
|
||||
ArticlePublication::factory()->create([
|
||||
'published_at' => now()->subMinutes(10),
|
||||
]);
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_publishes_when_no_previous_publications_exist(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
]);
|
||||
|
||||
Setting::setArticlePublishingInterval(10);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
use App\Models\PlatformInstance;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
|
@ -39,7 +42,7 @@ public function test_job_implements_should_queue(): void
|
|||
$channel = PlatformChannel::factory()->make();
|
||||
$job = new SyncChannelPostsJob($channel);
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
|
||||
$this->assertInstanceOf(ShouldQueue::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_implements_should_be_unique(): void
|
||||
|
|
@ -47,7 +50,7 @@ public function test_job_implements_should_be_unique(): void
|
|||
$channel = PlatformChannel::factory()->make();
|
||||
$job = new SyncChannelPostsJob($channel);
|
||||
|
||||
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
|
||||
$this->assertInstanceOf(ShouldBeUnique::class, $job);
|
||||
}
|
||||
|
||||
public function test_job_uses_queueable_trait(): void
|
||||
|
|
@ -56,7 +59,7 @@ public function test_job_uses_queueable_trait(): void
|
|||
$job = new SyncChannelPostsJob($channel);
|
||||
|
||||
$this->assertContains(
|
||||
\Illuminate\Foundation\Queue\Queueable::class,
|
||||
Queueable::class,
|
||||
class_uses($job)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\PlatformChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ public function test_casts_published_at_to_datetime(): void
|
|||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->create(['published_at' => $timestamp]);
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->published_at);
|
||||
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ public function test_publication_creation_with_factory(): void
|
|||
$this->assertNotNull($publication->article_id);
|
||||
$this->assertNotNull($publication->platform_channel_id);
|
||||
$this->assertIsString($publication->post_id);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->published_at);
|
||||
$this->assertIsString($publication->published_by);
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ public function test_publication_factory_recently_published_state(): void
|
|||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->recentlyPublished()->create();
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->published_at);
|
||||
$this->assertTrue($publication->published_at->isAfter(now()->subDay()));
|
||||
$this->assertTrue($publication->published_at->isBefore(now()->addMinute()));
|
||||
}
|
||||
|
|
@ -203,7 +204,7 @@ public function test_publication_with_specific_published_at(): void
|
|||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->create(['published_at' => $timestamp]);
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->published_at);
|
||||
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
|
|
@ -230,8 +231,8 @@ public function test_publication_timestamps(): void
|
|||
|
||||
$this->assertNotNull($publication->created_at);
|
||||
$this->assertNotNull($publication->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $publication->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $publication->updated_at);
|
||||
}
|
||||
|
||||
public function test_multiple_publications_for_same_article(): void
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -20,169 +19,9 @@ protected function setUp(): void
|
|||
{
|
||||
parent::setUp();
|
||||
|
||||
// Mock HTTP requests to prevent external calls
|
||||
Http::fake([
|
||||
'*' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
// Don't fake events globally - let individual tests control this
|
||||
}
|
||||
|
||||
public function test_is_valid_returns_false_when_approval_status_is_pending(): void
|
||||
{
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertFalse($article->isValid());
|
||||
}
|
||||
|
||||
public function test_is_valid_returns_false_when_approval_status_is_rejected(): void
|
||||
{
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'rejected',
|
||||
]);
|
||||
|
||||
$this->assertFalse($article->isValid());
|
||||
}
|
||||
|
||||
public function test_is_valid_returns_true_when_validated_and_not_rejected(): void
|
||||
{
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'approved',
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($article->isValid());
|
||||
}
|
||||
|
||||
public function test_is_valid_returns_false_when_not_validated(): void
|
||||
{
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'approved',
|
||||
'validated_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($article->isValid());
|
||||
}
|
||||
|
||||
public function test_is_approved_returns_true_for_approved_status(): void
|
||||
{
|
||||
$article = Article::factory()->make(['approval_status' => 'approved']);
|
||||
|
||||
$this->assertTrue($article->isApproved());
|
||||
}
|
||||
|
||||
public function test_is_approved_returns_false_for_non_approved_status(): void
|
||||
{
|
||||
$article = Article::factory()->make(['approval_status' => 'pending']);
|
||||
|
||||
$this->assertFalse($article->isApproved());
|
||||
}
|
||||
|
||||
public function test_is_pending_returns_true_for_pending_status(): void
|
||||
{
|
||||
$article = Article::factory()->make(['approval_status' => 'pending']);
|
||||
|
||||
$this->assertTrue($article->isPending());
|
||||
}
|
||||
|
||||
public function test_is_rejected_returns_true_for_rejected_status(): void
|
||||
{
|
||||
$article = Article::factory()->make(['approval_status' => 'rejected']);
|
||||
|
||||
$this->assertTrue($article->isRejected());
|
||||
}
|
||||
|
||||
public function test_approve_updates_status_and_triggers_event(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
Event::fake();
|
||||
|
||||
$article->approve('test_user');
|
||||
|
||||
$article->refresh();
|
||||
$this->assertEquals('approved', $article->approval_status);
|
||||
|
||||
Event::assertDispatched(ArticleApproved::class, function ($event) use ($article) {
|
||||
return $event->article->id === $article->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_approve_without_approved_by_parameter(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
Event::fake();
|
||||
|
||||
$article->approve();
|
||||
|
||||
$article->refresh();
|
||||
$this->assertEquals('approved', $article->approval_status);
|
||||
}
|
||||
|
||||
public function test_reject_updates_status(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$article->reject('test_user');
|
||||
|
||||
$article->refresh();
|
||||
$this->assertEquals('rejected', $article->approval_status);
|
||||
}
|
||||
|
||||
public function test_can_be_published_returns_false_for_invalid_article(): void
|
||||
{
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'rejected', // rejected = not valid
|
||||
]);
|
||||
|
||||
$this->assertFalse($article->canBePublished());
|
||||
}
|
||||
|
||||
public function test_can_be_published_requires_approval_when_approvals_enabled(): void
|
||||
{
|
||||
// Create a setting that enables approvals
|
||||
Setting::create(['key' => 'enable_publishing_approvals', 'value' => '1']);
|
||||
|
||||
$pendingArticle = Article::factory()->make([
|
||||
'approval_status' => 'pending',
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$approvedArticle = Article::factory()->make([
|
||||
'approval_status' => 'approved',
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($pendingArticle->canBePublished());
|
||||
$this->assertTrue($approvedArticle->canBePublished());
|
||||
}
|
||||
|
||||
public function test_can_be_published_returns_true_when_approvals_disabled(): void
|
||||
{
|
||||
// Make sure approvals are disabled (default behavior)
|
||||
Setting::where('key', 'enable_publishing_approvals')->delete();
|
||||
|
||||
$article = Article::factory()->make([
|
||||
'approval_status' => 'approved',
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($article->canBePublished());
|
||||
}
|
||||
|
||||
public function test_feed_relationship(): void
|
||||
|
|
@ -194,6 +33,14 @@ public function test_feed_relationship(): void
|
|||
$this->assertEquals($feed->id, $article->feed->id);
|
||||
}
|
||||
|
||||
public function test_route_articles_relationship(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$this->assertCount(0, $article->routeArticles);
|
||||
}
|
||||
|
||||
public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void
|
||||
{
|
||||
Event::fake([NewArticleFetched::class]);
|
||||
|
|
@ -207,4 +54,23 @@ public function test_dispatch_fetched_event_fires_new_article_fetched_event(): v
|
|||
return $event->article->id === $article->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_is_published_attribute(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$this->assertFalse($article->is_published);
|
||||
}
|
||||
|
||||
public function test_validated_at_is_cast_to_datetime(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(Carbon::class, $article->validated_at);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\Language;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -51,7 +52,7 @@ public function test_casts_last_fetched_at_to_datetime(): void
|
|||
$timestamp = now()->subHours(2);
|
||||
$feed = Feed::factory()->create(['last_fetched_at' => $timestamp]);
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->last_fetched_at);
|
||||
$this->assertInstanceOf(Carbon::class, $feed->last_fetched_at);
|
||||
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $feed->last_fetched_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
|
|
@ -313,6 +314,56 @@ public function test_feed_settings_can_be_complex_structure(): void
|
|||
$this->assertTrue($feed->settings['schedule']['enabled']);
|
||||
}
|
||||
|
||||
public function test_scope_stale_includes_active_feeds_with_old_last_fetched_at(): void
|
||||
{
|
||||
$staleFeed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(50),
|
||||
]);
|
||||
|
||||
$staleFeeds = Feed::stale(48)->get();
|
||||
|
||||
$this->assertCount(1, $staleFeeds);
|
||||
$this->assertTrue($staleFeeds->contains('id', $staleFeed->id));
|
||||
}
|
||||
|
||||
public function test_scope_stale_includes_active_feeds_never_fetched(): void
|
||||
{
|
||||
$neverFetchedFeed = Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => null,
|
||||
]);
|
||||
|
||||
$staleFeeds = Feed::stale(48)->get();
|
||||
|
||||
$this->assertCount(1, $staleFeeds);
|
||||
$this->assertTrue($staleFeeds->contains('id', $neverFetchedFeed->id));
|
||||
}
|
||||
|
||||
public function test_scope_stale_excludes_fresh_feeds(): void
|
||||
{
|
||||
Feed::factory()->create([
|
||||
'is_active' => true,
|
||||
'last_fetched_at' => now()->subHours(10),
|
||||
]);
|
||||
|
||||
$staleFeeds = Feed::stale(48)->get();
|
||||
|
||||
$this->assertCount(0, $staleFeeds);
|
||||
}
|
||||
|
||||
public function test_scope_stale_excludes_inactive_feeds(): void
|
||||
{
|
||||
Feed::factory()->create([
|
||||
'is_active' => false,
|
||||
'last_fetched_at' => now()->subHours(100),
|
||||
]);
|
||||
|
||||
$staleFeeds = Feed::stale(48)->get();
|
||||
|
||||
$this->assertCount(0, $staleFeeds);
|
||||
}
|
||||
|
||||
public function test_feed_can_have_null_last_fetched_at(): void
|
||||
{
|
||||
$feed = Feed::factory()->create(['last_fetched_at' => null]);
|
||||
|
|
@ -326,7 +377,7 @@ public function test_feed_timestamps(): void
|
|||
|
||||
$this->assertNotNull($feed->created_at);
|
||||
$this->assertNotNull($feed->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $feed->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $feed->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $feed->updated_at);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
use App\Models\Feed;
|
||||
use App\Models\Keyword;
|
||||
use App\Models\PlatformChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -213,7 +215,7 @@ public function test_keyword_uniqueness_constraint(): void
|
|||
]);
|
||||
|
||||
// Attempt to create duplicate should fail
|
||||
$this->expectException(\Illuminate\Database\QueryException::class);
|
||||
$this->expectException(QueryException::class);
|
||||
|
||||
Keyword::create([
|
||||
'feed_id' => $feed->id,
|
||||
|
|
@ -257,8 +259,8 @@ public function test_keyword_timestamps(): void
|
|||
|
||||
$this->assertNotNull($keyword->created_at);
|
||||
$this->assertNotNull($keyword->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $keyword->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $keyword->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $keyword->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $keyword->updated_at);
|
||||
}
|
||||
|
||||
public function test_keyword_default_active_state(): void
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\Language;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -193,8 +194,8 @@ public function test_language_timestamps(): void
|
|||
|
||||
$this->assertNotNull($language->created_at);
|
||||
$this->assertNotNull($language->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $language->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $language->updated_at);
|
||||
}
|
||||
|
||||
public function test_language_can_have_multiple_platform_instances(): void
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Enums\PlatformEnum;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ public function test_casts_last_tested_at_to_datetime(): void
|
|||
$timestamp = now()->subHours(2);
|
||||
$account = PlatformAccount::factory()->create(['last_tested_at' => $timestamp]);
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $account->last_tested_at);
|
||||
$this->assertInstanceOf(Carbon::class, $account->last_tested_at);
|
||||
$this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
|
|
@ -366,8 +367,8 @@ public function test_account_timestamps(): void
|
|||
|
||||
$this->assertNotNull($account->created_at);
|
||||
$this->assertNotNull($account->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $account->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $account->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $account->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $account->updated_at);
|
||||
}
|
||||
|
||||
public function test_account_can_have_multiple_channels_with_different_priorities(): void
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use App\Models\Route;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -305,8 +306,8 @@ public function test_channel_timestamps(): void
|
|||
|
||||
$this->assertNotNull($channel->created_at);
|
||||
$this->assertNotNull($channel->updated_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $channel->created_at);
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $channel->updated_at);
|
||||
$this->assertInstanceOf(Carbon::class, $channel->created_at);
|
||||
$this->assertInstanceOf(Carbon::class, $channel->updated_at);
|
||||
}
|
||||
|
||||
public function test_channel_can_have_multiple_accounts_with_different_priorities(): void
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue