Release v1.4.0 #146
6 changed files with 184 additions and 5 deletions
|
|
@ -6,6 +6,7 @@ enum NotificationTypeEnum: string
|
||||||
{
|
{
|
||||||
case GENERAL = 'general';
|
case GENERAL = 'general';
|
||||||
case FEED_STALE = 'feed_stale';
|
case FEED_STALE = 'feed_stale';
|
||||||
|
case FEED_EMPTY = 'feed_empty';
|
||||||
case PUBLISH_FAILED = 'publish_failed';
|
case PUBLISH_FAILED = 'publish_failed';
|
||||||
case CREDENTIAL_EXPIRED = 'credential_expired';
|
case CREDENTIAL_EXPIRED = 'credential_expired';
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ public function label(): string
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::GENERAL => 'General',
|
self::GENERAL => 'General',
|
||||||
self::FEED_STALE => 'Feed Stale',
|
self::FEED_STALE => 'Feed Stale',
|
||||||
|
self::FEED_EMPTY => 'Feed Empty',
|
||||||
self::PUBLISH_FAILED => 'Publish Failed',
|
self::PUBLISH_FAILED => 'Publish Failed',
|
||||||
self::CREDENTIAL_EXPIRED => 'Credential Expired',
|
self::CREDENTIAL_EXPIRED => 'Credential Expired',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\NotificationSeverityEnum;
|
||||||
|
use App\Enums\NotificationTypeEnum;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
|
use App\Models\Notification;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
|
use App\Services\Notification\NotificationService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
|
@ -20,7 +24,7 @@ public function __construct(
|
||||||
$this->onQueue('feed-discovery');
|
$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, [
|
$logSaver->info('Starting feed article fetch', null, [
|
||||||
'feed_id' => $this->feed->id,
|
'feed_id' => $this->feed->id,
|
||||||
|
|
@ -37,6 +41,32 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->feed->update(['last_fetched_at' => now()]);
|
$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
|
public static function dispatchForAllActiveFeeds(): void
|
||||||
|
|
|
||||||
143
tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php
Normal file
143
tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\NotificationSeverityEnum;
|
||||||
|
use App\Enums\NotificationTypeEnum;
|
||||||
|
use App\Jobs\ArticleDiscoveryForFeedJob;
|
||||||
|
use App\Models\Article;
|
||||||
|
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\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ArticleDiscoveryForFeedJobEmptyFetchTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Article>|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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
|
use App\Services\Notification\NotificationService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
@ -75,7 +76,7 @@ public function test_article_discovery_for_feed_job_processes_feed(): void
|
||||||
$logSaver = app(LogSaver::class);
|
$logSaver = app(LogSaver::class);
|
||||||
$articleFetcher = app(ArticleFetcher::class);
|
$articleFetcher = app(ArticleFetcher::class);
|
||||||
$job = new ArticleDiscoveryForFeedJob($feed);
|
$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)
|
// Should have articles in database (existing articles created by factory)
|
||||||
$this->assertCount(2, Article::all());
|
$this->assertCount(2, Article::all());
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ public function test_notification_type_enum_labels(): void
|
||||||
{
|
{
|
||||||
$this->assertEquals('General', NotificationTypeEnum::GENERAL->label());
|
$this->assertEquals('General', NotificationTypeEnum::GENERAL->label());
|
||||||
$this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->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('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label());
|
||||||
$this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label());
|
$this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace Tests\Unit\Jobs;
|
namespace Tests\Unit\Jobs;
|
||||||
|
|
||||||
use App\Jobs\ArticleDiscoveryForFeedJob;
|
use App\Jobs\ArticleDiscoveryForFeedJob;
|
||||||
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
|
use App\Services\Notification\NotificationService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -89,7 +91,7 @@ public function test_handle_fetches_articles_and_updates_feed(): void
|
||||||
$job = new ArticleDiscoveryForFeedJob($feed);
|
$job = new ArticleDiscoveryForFeedJob($feed);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$job->handle($logSaverMock, $articleFetcherMock);
|
$job->handle($logSaverMock, $articleFetcherMock, app(NotificationService::class));
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$feed->refresh();
|
$feed->refresh();
|
||||||
|
|
@ -185,7 +187,7 @@ public function test_handle_logs_start_message_with_correct_context(): void
|
||||||
'url' => 'https://example.com/feed',
|
'url' => 'https://example.com/feed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mockArticles = collect([]);
|
$mockArticles = collect([new Article]);
|
||||||
|
|
||||||
// Mock ArticleFetcher
|
// Mock ArticleFetcher
|
||||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||||
|
|
@ -210,7 +212,7 @@ public function test_handle_logs_start_message_with_correct_context(): void
|
||||||
$job = new ArticleDiscoveryForFeedJob($feed);
|
$job = new ArticleDiscoveryForFeedJob($feed);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$job->handle($logSaverMock, $articleFetcherMock);
|
$job->handle($logSaverMock, $articleFetcherMock, app(NotificationService::class));
|
||||||
|
|
||||||
// Assert - Mockery expectations are verified in tearDown
|
// Assert - Mockery expectations are verified in tearDown
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue