Compare commits

...

3 commits

25 changed files with 1358 additions and 29 deletions

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NotificationSeverityEnum: string
{
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
}

View 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',
};
}
}

View 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,
);
}
}
}

View file

@ -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;
}
}

View file

@ -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,
);
}
}
}

View 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');
}
}

View file

@ -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!';

View file

@ -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>
*/

View 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()]);
}
}

View file

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

View 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);
}
}

View 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]);
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::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');
}
};

View file

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

View 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>

View file

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

View file

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

View 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));
}
}

View 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();
}
}

View 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+');
}
}

View 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!');
}
}

View 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);
}
}

View file

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

View file

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

View file

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