Compare commits
3 commits
ec09711a6f
...
d21c054250
| Author | SHA1 | Date | |
|---|---|---|---|
| d21c054250 | |||
| 062b00d01c | |||
| 4feab96765 |
25 changed files with 1358 additions and 29 deletions
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,12 +3,15 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
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 +36,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();
|
||||
|
||||
|
|
@ -62,22 +65,43 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
|
|||
'created_at' => $article->created_at,
|
||||
]);
|
||||
|
||||
// Fetch article data
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
|
||||
try {
|
||||
$publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
$extractedData = $articleFetcher->fetchArticleData($article);
|
||||
$publications = $publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
if ($publications->isNotEmpty()) {
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publications were created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (PublishException $e) {
|
||||
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,12 @@
|
|||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -16,7 +19,8 @@ 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
|
||||
|
|
@ -53,6 +57,14 @@ public function handle(ArticleApproved $event): void
|
|||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::WARNING,
|
||||
"Publish failed: {$article->title}",
|
||||
'No publications were created for this article. Check channel routing configuration.',
|
||||
$article,
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$article->update(['publish_status' => 'error']);
|
||||
|
|
@ -61,6 +73,14 @@ public function handle(ArticleApproved $event): void
|
|||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->notificationService->send(
|
||||
NotificationTypeEnum::PUBLISH_FAILED,
|
||||
NotificationSeverityEnum::ERROR,
|
||||
"Publish failed: {$article->title}",
|
||||
$e->getMessage(),
|
||||
$article,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ class Settings extends Component
|
|||
|
||||
public int $articlePublishingInterval = 5;
|
||||
|
||||
public int $feedStalenessThreshold = 48;
|
||||
|
||||
public ?string $successMessage = null;
|
||||
|
||||
public ?string $errorMessage = null;
|
||||
|
|
@ -22,6 +24,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 +51,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!';
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Jobs\CheckFeedStalenessJob;
|
||||
use App\Jobs\PublishNextArticleJob;
|
||||
use App\Jobs\SyncChannelPostsJob;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
|
@ -20,3 +21,9 @@
|
|||
->name('refresh-articles')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
||||
Schedule::job(new CheckFeedStalenessJob)
|
||||
->hourly()
|
||||
->name('check-feed-staleness')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
|
|
|
|||
133
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal file
133
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?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 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(\App\Services\Notification\NotificationService::class));
|
||||
}
|
||||
}
|
||||
123
tests/Feature/Listeners/PublishApprovedArticleListenerTest.php
Normal file
123
tests/Feature/Listeners/PublishApprovedArticleListenerTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Listeners;
|
||||
|
||||
use App\Enums\NotificationSeverityEnum;
|
||||
use App\Enums\NotificationTypeEnum;
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Listeners\PublishApprovedArticleListener;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Notification;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublishApprovedArticleListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_exception_during_publishing_creates_error_notification(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'title' => 'Test Article',
|
||||
]);
|
||||
|
||||
$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 ArticleApproved($article));
|
||||
|
||||
$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('Test Article', $notification->title);
|
||||
$this->assertStringContainsString('Connection refused', $notification->message);
|
||||
}
|
||||
|
||||
public function test_no_publications_created_creates_warning_notification(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'title' => 'Test Article',
|
||||
]);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once()
|
||||
->andReturn(new Collection);
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new ArticleApproved($article));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::WARNING->value,
|
||||
'notifiable_type' => $article->getMorphClass(),
|
||||
'notifiable_id' => $article->id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('Test Article', $notification->title);
|
||||
}
|
||||
|
||||
public function test_successful_publish_does_not_create_notification(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'title' => 'Test Article',
|
||||
]);
|
||||
|
||||
$extractedData = ['title' => 'Test Article'];
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$articleFetcherMock->shouldReceive('fetchArticleData')
|
||||
->once()
|
||||
->andReturn($extractedData);
|
||||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||
$listener->handle(new ArticleApproved($article));
|
||||
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
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+');
|
||||
}
|
||||
}
|
||||
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,15 +2,20 @@
|
|||
|
||||
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\Setting;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -18,9 +23,12 @@ class PublishNextArticleJobTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationService $notificationService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->notificationService = new NotificationService;
|
||||
}
|
||||
|
||||
public function test_constructor_sets_correct_queue(): void
|
||||
|
|
@ -71,7 +79,7 @@ public function test_handle_returns_early_when_no_approved_articles(): void
|
|||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Should complete without error
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -96,7 +104,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_articles(
|
|||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Should complete without error
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -122,7 +130,7 @@ public function test_handle_skips_non_approved_articles(): void
|
|||
|
||||
// Act
|
||||
$publishingServiceMock = \Mockery::mock(ArticlePublishingService::class);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Should complete without error (no approved articles to process)
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -167,12 +175,13 @@ public function test_handle_publishes_oldest_approved_article(): void
|
|||
return $article->id === $olderArticle->id;
|
||||
}),
|
||||
$extractedData
|
||||
);
|
||||
)
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Mockery expectations are verified in tearDown
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -209,7 +218,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void
|
|||
$this->expectException(PublishException::class);
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
}
|
||||
|
||||
public function test_handle_logs_publishing_start(): void
|
||||
|
|
@ -233,12 +242,14 @@ public function test_handle_logs_publishing_start(): void
|
|||
|
||||
// Mock ArticlePublishingService
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')->once();
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Verify the job completes (logging is verified by observing no exceptions)
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -278,12 +289,13 @@ public function test_handle_fetches_article_data_before_publishing(): void
|
|||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once()
|
||||
->with(Mockery::type(Article::class), $extractedData);
|
||||
->with(Mockery::type(Article::class), $extractedData)
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
// Act
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
// Assert - Mockery expectations verified in tearDown
|
||||
$this->assertTrue(true);
|
||||
|
|
@ -311,7 +323,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
|
|||
$publishingServiceMock->shouldNotReceive('publishToRoutedChannels');
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -339,10 +351,11 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
|
|||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -370,10 +383,11 @@ public function test_handle_publishes_when_interval_is_zero(): void
|
|||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -401,10 +415,11 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
|
|||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
|
@ -428,14 +443,91 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
|
|||
|
||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingServiceMock->shouldReceive('publishToRoutedChannels')
|
||||
->once();
|
||||
->once()
|
||||
->andReturn(new Collection(['publication']));
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock);
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_handle_creates_warning_notification_when_no_publications_created(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'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('publishToRoutedChannels')
|
||||
->once()
|
||||
->andReturn(new Collection);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||
'severity' => NotificationSeverityEnum::WARNING->value,
|
||||
'notifiable_type' => $article->getMorphClass(),
|
||||
'notifiable_id' => $article->id,
|
||||
]);
|
||||
|
||||
$notification = Notification::first();
|
||||
$this->assertStringContainsString('No Route Article', $notification->title);
|
||||
}
|
||||
|
||||
public function test_handle_creates_notification_on_publish_exception(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'approval_status' => 'approved',
|
||||
'title' => 'Failing Article',
|
||||
]);
|
||||
|
||||
$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('publishToRoutedChannels')
|
||||
->once()
|
||||
->andThrow($publishException);
|
||||
|
||||
$job = new PublishNextArticleJob;
|
||||
|
||||
try {
|
||||
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||
} catch (PublishException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
|
|
|
|||
|
|
@ -313,6 +313,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]);
|
||||
|
|
|
|||
|
|
@ -39,4 +39,34 @@ public function test_set_article_publishing_interval_zero(): void
|
|||
|
||||
$this->assertSame(0, Setting::getArticlePublishingInterval());
|
||||
}
|
||||
|
||||
public function test_get_feed_staleness_threshold_returns_default_when_not_set(): void
|
||||
{
|
||||
$this->assertSame(48, Setting::getFeedStalenessThreshold());
|
||||
}
|
||||
|
||||
public function test_get_feed_staleness_threshold_returns_stored_value(): void
|
||||
{
|
||||
Setting::set('feed_staleness_threshold', '72');
|
||||
|
||||
$this->assertSame(72, Setting::getFeedStalenessThreshold());
|
||||
}
|
||||
|
||||
public function test_set_feed_staleness_threshold_persists_value(): void
|
||||
{
|
||||
Setting::setFeedStalenessThreshold(24);
|
||||
|
||||
$this->assertSame(24, Setting::getFeedStalenessThreshold());
|
||||
$this->assertDatabaseHas('settings', [
|
||||
'key' => 'feed_staleness_threshold',
|
||||
'value' => '24',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_set_feed_staleness_threshold_zero(): void
|
||||
{
|
||||
Setting::setFeedStalenessThreshold(0);
|
||||
|
||||
$this->assertSame(0, Setting::getFeedStalenessThreshold());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue