diff --git a/app/Jobs/CheckFeedStalenessJob.php b/app/Jobs/CheckFeedStalenessJob.php new file mode 100644 index 0000000..69cbc3f --- /dev/null +++ b/app/Jobs/CheckFeedStalenessJob.php @@ -0,0 +1,51 @@ +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, + ); + } + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 9e89d9b..9c97752 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -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!'; diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 6b34237..51537ae 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -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 $query + * @return Builder + */ + 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 */ diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 9f1cdfb..9206322 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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); + } } diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index bd7a258..a2dc75d 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -104,6 +104,51 @@ class="flex-shrink-0" + +
+
+

+ + + + Feed Monitoring +

+

+ Configure alerts for feeds that stop returning articles +

+
+
+
+
+

+ Staleness Threshold (hours) +

+

+ Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable. +

+
+
+ + +
+
+ @error('feedStalenessThreshold') +

{{ $message }}

+ @enderror +
+
+ @if ($successMessage)
diff --git a/routes/console.php b/routes/console.php index 83db0a3..98aac72 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ name('refresh-articles') ->withoutOverlapping() ->onOneServer(); + +Schedule::job(new CheckFeedStalenessJob) + ->hourly() + ->name('check-feed-staleness') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/Jobs/CheckFeedStalenessJobTest.php b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php new file mode 100644 index 0000000..4e5c0f2 --- /dev/null +++ b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php @@ -0,0 +1,133 @@ +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)); + } +} diff --git a/tests/Feature/Livewire/SettingsTest.php b/tests/Feature/Livewire/SettingsTest.php new file mode 100644 index 0000000..101673d --- /dev/null +++ b/tests/Feature/Livewire/SettingsTest.php @@ -0,0 +1,54 @@ +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!'); + } +} diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php index 6c4029f..05864cb 100644 --- a/tests/Unit/Models/FeedTest.php +++ b/tests/Unit/Models/FeedTest.php @@ -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]); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php index 1165b81..19fc5a6 100644 --- a/tests/Unit/Models/SettingTest.php +++ b/tests/Unit/Models/SettingTest.php @@ -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()); + } }