diff --git a/app/Enums/NotificationTypeEnum.php b/app/Enums/NotificationTypeEnum.php index 46ad3453..daee8247 100644 --- a/app/Enums/NotificationTypeEnum.php +++ b/app/Enums/NotificationTypeEnum.php @@ -6,6 +6,7 @@ enum NotificationTypeEnum: string { case GENERAL = 'general'; case FEED_STALE = 'feed_stale'; + case FEED_EMPTY = 'feed_empty'; case PUBLISH_FAILED = 'publish_failed'; case CREDENTIAL_EXPIRED = 'credential_expired'; @@ -14,6 +15,7 @@ public function label(): string return match ($this) { self::GENERAL => 'General', self::FEED_STALE => 'Feed Stale', + self::FEED_EMPTY => 'Feed Empty', self::PUBLISH_FAILED => 'Publish Failed', self::CREDENTIAL_EXPIRED => 'Credential Expired', }; diff --git a/app/Jobs/ArticleDiscoveryForFeedJob.php b/app/Jobs/ArticleDiscoveryForFeedJob.php index 2c94b7cc..e8bce63e 100644 --- a/app/Jobs/ArticleDiscoveryForFeedJob.php +++ b/app/Jobs/ArticleDiscoveryForFeedJob.php @@ -2,9 +2,13 @@ namespace App\Jobs; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Models\Feed; +use App\Models\Notification; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; +use App\Services\Notification\NotificationService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -20,7 +24,7 @@ public function __construct( $this->onQueue('feed-discovery'); } - public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void + public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher, NotificationService $notificationService): void { $logSaver->info('Starting feed article fetch', null, [ 'feed_id' => $this->feed->id, @@ -37,6 +41,32 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void ]); $this->feed->update(['last_fetched_at' => now()]); + + if ($articles->isEmpty()) { + $this->warnFeedReturnedNothing($notificationService); + } + } + + private function warnFeedReturnedNothing(NotificationService $notificationService): void + { + $alreadyNotified = Notification::query() + ->where('type', NotificationTypeEnum::FEED_EMPTY) + ->where('notifiable_type', $this->feed->getMorphClass()) + ->where('notifiable_id', $this->feed->getKey()) + ->unread() + ->exists(); + + if ($alreadyNotified) { + return; + } + + $notificationService->send( + type: NotificationTypeEnum::FEED_EMPTY, + severity: NotificationSeverityEnum::WARNING, + title: "Feed \"{$this->feed->name}\" returned no articles", + message: "The fetch completed but produced nothing. Check that {$this->feed->url} is still a valid feed.", + notifiable: $this->feed, + ); } public static function dispatchForAllActiveFeeds(): void diff --git a/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php b/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php new file mode 100644 index 00000000..516c8ab6 --- /dev/null +++ b/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php @@ -0,0 +1,143 @@ +|null $articles + */ + private function runJobForFeed(Feed $feed, ?Collection $articles = null): void + { + $fetcher = Mockery::mock(ArticleFetcher::class); + $fetcher->shouldReceive('getArticlesFromFeed') + ->andReturn($articles ?? collect()); + + (new ArticleDiscoveryForFeedJob($feed))->handle( + app(LogSaver::class), + $fetcher, + app(NotificationService::class) + ); + } + + public function test_empty_fetch_creates_notification(): void + { + $feed = Feed::factory()->create(['is_active' => true]); + + $this->runJobForFeed($feed); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::FEED_EMPTY->value, + 'severity' => NotificationSeverityEnum::WARNING->value, + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + } + + public function test_fetch_with_articles_does_not_create_notification(): void + { + $feed = Feed::factory()->create(['is_active' => true]); + $articles = Article::factory()->count(2)->create(['feed_id' => $feed->id]); + + $this->runJobForFeed($feed, $articles); + + $this->assertDatabaseCount('notifications', 0); + } + + public function test_notification_message_names_the_feed_url(): void + { + $feed = Feed::factory()->create([ + 'is_active' => true, + 'name' => 'Belga', + 'url' => 'https://example.test/retired-feed.xml', + ]); + + $this->runJobForFeed($feed); + + $notification = Notification::first(); + + $this->assertStringContainsString('Belga', $notification->title); + $this->assertStringContainsString('https://example.test/retired-feed.xml', $notification->message); + } + + public function test_empty_fetch_still_updates_last_fetched_at(): void + { + $feed = Feed::factory()->create(['is_active' => true, 'last_fetched_at' => null]); + + $this->runJobForFeed($feed); + + $this->assertNotNull($feed->fresh()->last_fetched_at); + } + + public function test_does_not_create_duplicate_notification_when_unread_exists(): void + { + $feed = Feed::factory()->create(['is_active' => true]); + + Notification::factory() + ->type(NotificationTypeEnum::FEED_EMPTY) + ->unread() + ->create([ + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + + $this->runJobForFeed($feed); + + $this->assertDatabaseCount('notifications', 1); + } + + public function test_creates_new_notification_when_previous_is_read(): void + { + $feed = Feed::factory()->create(['is_active' => true]); + + Notification::factory() + ->type(NotificationTypeEnum::FEED_EMPTY) + ->read() + ->create([ + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + + $this->runJobForFeed($feed); + + $this->assertDatabaseCount('notifications', 2); + } + + public function test_notification_for_one_feed_does_not_suppress_another(): void + { + $notified = Feed::factory()->create(['is_active' => true]); + $other = Feed::factory()->create(['is_active' => true]); + + Notification::factory() + ->type(NotificationTypeEnum::FEED_EMPTY) + ->unread() + ->create([ + 'notifiable_type' => $notified->getMorphClass(), + 'notifiable_id' => $notified->id, + ]); + + $this->runJobForFeed($other); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::FEED_EMPTY->value, + 'notifiable_id' => $other->id, + ]); + $this->assertDatabaseCount('notifications', 2); + } +} diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index b5c793ea..d6b9c794 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -25,6 +25,7 @@ use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; +use App\Services\Notification\NotificationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; @@ -75,7 +76,7 @@ public function test_article_discovery_for_feed_job_processes_feed(): void $logSaver = app(LogSaver::class); $articleFetcher = app(ArticleFetcher::class); $job = new ArticleDiscoveryForFeedJob($feed); - $job->handle($logSaver, $articleFetcher); + $job->handle($logSaver, $articleFetcher, app(NotificationService::class)); // Should have articles in database (existing articles created by factory) $this->assertCount(2, Article::all()); diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php index bf14d85d..1e01c069 100644 --- a/tests/Feature/NotificationTest.php +++ b/tests/Feature/NotificationTest.php @@ -146,6 +146,7 @@ public function test_notification_type_enum_labels(): void { $this->assertEquals('General', NotificationTypeEnum::GENERAL->label()); $this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label()); + $this->assertEquals('Feed Empty', NotificationTypeEnum::FEED_EMPTY->label()); $this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label()); $this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label()); } diff --git a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php index 847c4261..242f1ee5 100644 --- a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php +++ b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit\Jobs; use App\Jobs\ArticleDiscoveryForFeedJob; +use App\Models\Article; use App\Models\Feed; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; +use App\Services\Notification\NotificationService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -89,7 +91,7 @@ public function test_handle_fetches_articles_and_updates_feed(): void $job = new ArticleDiscoveryForFeedJob($feed); // Act - $job->handle($logSaverMock, $articleFetcherMock); + $job->handle($logSaverMock, $articleFetcherMock, app(NotificationService::class)); // Assert $feed->refresh(); @@ -185,7 +187,7 @@ public function test_handle_logs_start_message_with_correct_context(): void 'url' => 'https://example.com/feed', ]); - $mockArticles = collect([]); + $mockArticles = collect([new Article]); // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); @@ -210,7 +212,7 @@ public function test_handle_logs_start_message_with_correct_context(): void $job = new ArticleDiscoveryForFeedJob($feed); // Act - $job->handle($logSaverMock, $articleFetcherMock); + $job->handle($logSaverMock, $articleFetcherMock, app(NotificationService::class)); // Assert - Mockery expectations are verified in tearDown $this->assertTrue(true);