|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_empty_fetch_records_error_activity_not_fetch(): void { $feed = Feed::factory()->create(['is_active' => true, 'name' => 'Belga']); $this->runJobForFeed($feed); $entry = ActivityLog::first(); $this->assertSame(ActivityTypeEnum::ERROR, $entry->type); $this->assertStringContainsString('Belga', $entry->message); $this->assertSame(0, ActivityLog::ofType(ActivityTypeEnum::FETCH)->count()); } public function test_fetch_with_articles_records_fetch_activity(): void { $feed = Feed::factory()->create(['is_active' => true, 'name' => 'VRT']); $articles = Article::factory()->count(2)->create(['feed_id' => $feed->id]); $this->runJobForFeed($feed, $articles); $entry = ActivityLog::first(); $this->assertSame(ActivityTypeEnum::FETCH, $entry->type); $this->assertStringContainsString('2 articles', $entry->message); $this->assertSame($feed->getMorphClass(), $entry->subject_type); } 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); } }