Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
6 changed files with 184 additions and 5 deletions
Showing only changes of commit 548a55af05 - Show all commits

View file

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

View file

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

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

View file

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

View file

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

View file

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