diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php index 8dd4987e..eeeb5cb9 100644 --- a/app/Http/Controllers/Api/V1/SettingsController.php +++ b/app/Http/Controllers/Api/V1/SettingsController.php @@ -19,6 +19,7 @@ public function index(): JsonResponse 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), 'article_publishing_interval' => Setting::getArticlePublishingInterval(), + 'daily_publish_cap' => Setting::getDailyPublishCap(), ]; return $this->sendResponse($settings, 'Settings retrieved successfully.'); @@ -37,6 +38,7 @@ public function update(Request $request): JsonResponse 'article_processing_enabled' => 'boolean', 'publishing_approvals_enabled' => 'boolean', 'article_publishing_interval' => 'integer|min:0', + 'daily_publish_cap' => 'integer|min:0', ]); if (isset($validated['article_processing_enabled'])) { @@ -51,10 +53,15 @@ public function update(Request $request): JsonResponse Setting::setArticlePublishingInterval($validated['article_publishing_interval']); } + if (isset($validated['daily_publish_cap'])) { + Setting::setDailyPublishCap($validated['daily_publish_cap']); + } + $updatedSettings = [ 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), 'article_publishing_interval' => Setting::getArticlePublishingInterval(), + 'daily_publish_cap' => Setting::getDailyPublishCap(), ]; return $this->sendResponse( diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 868aca09..f2e22022 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -35,6 +35,10 @@ public function __construct() */ public function handle(PublishRouteArticleAction $publishRouteArticle): void { + if ($this->dailyCapReached()) { + return; + } + $interval = Setting::getArticlePublishingInterval(); if ($interval > 0) { @@ -70,4 +74,15 @@ public function handle(PublishRouteArticleAction $publishRouteArticle): void $publishRouteArticle->execute($routeArticle); } + + private function dailyCapReached(): bool + { + $cap = Setting::getDailyPublishCap(); + + if ($cap <= 0) { + return false; + } + + return ArticlePublication::where('published_at', '>=', now()->startOfDay())->count() >= $cap; + } } diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 693db055..d1fbba25 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -16,6 +16,8 @@ class Settings extends Component public int $feedStalenessThreshold = 48; + public int $dailyPublishCap = 0; + public ?string $successMessage = null; public ?string $errorMessage = null; @@ -26,6 +28,7 @@ public function mount(): void $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); $this->articlePublishingInterval = Setting::getArticlePublishingInterval(); $this->feedStalenessThreshold = Setting::getFeedStalenessThreshold(); + $this->dailyPublishCap = Setting::getDailyPublishCap(); } public function toggleArticleProcessing(): void @@ -62,6 +65,16 @@ public function updateFeedStalenessThreshold(): void $this->showSuccess(); } + public function updateDailyPublishCap(): void + { + $this->validate([ + 'dailyPublishCap' => 'required|integer|min:0', + ]); + + Setting::setDailyPublishCap($this->dailyPublishCap); + $this->showSuccess(); + } + protected function showSuccess(): void { $this->successMessage = 'Settings updated successfully!'; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 92063227..9d155472 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -81,4 +81,14 @@ public static function setFeedStalenessThreshold(int $hours): void { static::set('feed_staleness_threshold', (string) $hours); } + + public static function getDailyPublishCap(): int + { + return (int) static::get('daily_publish_cap', 0); + } + + public static function setDailyPublishCap(int $articles): void + { + static::set('daily_publish_cap', (string) $articles); + } } diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 87ca51de..bbd9cde1 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -72,6 +72,36 @@ class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-

{{ $message }}

@enderror +
+
+

+ Daily Publish Cap +

+

+ Maximum posts per UTC day, counted across all channels. An article sent to + three channels counts as three. Set to 0 for no limit. +

+
+
+ + +
+
+ @error('dailyPublishCap') +

{{ $message }}

+ @enderror +

diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php index d2a65827..6e662be1 100644 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php @@ -114,6 +114,41 @@ public function test_index_returns_article_publishing_interval(): void ->assertJsonPath('data.article_publishing_interval', 5); } + public function test_index_returns_daily_publish_cap(): void + { + $response = $this->getJson('/api/v1/settings'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'daily_publish_cap', + ], + ]) + ->assertJsonPath('data.daily_publish_cap', 0); + } + + public function test_update_accepts_valid_daily_publish_cap(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'daily_publish_cap' => 30, + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.daily_publish_cap', 30); + + $this->assertSame(30, Setting::getDailyPublishCap()); + } + + public function test_update_rejects_negative_daily_publish_cap(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'daily_publish_cap' => -1, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['daily_publish_cap']); + } + public function test_update_accepts_valid_article_publishing_interval(): void { $response = $this->putJson('/api/v1/settings', [ diff --git a/tests/Feature/Livewire/SettingsTest.php b/tests/Feature/Livewire/SettingsTest.php index 101673df..edcd70c3 100644 --- a/tests/Feature/Livewire/SettingsTest.php +++ b/tests/Feature/Livewire/SettingsTest.php @@ -51,4 +51,36 @@ public function test_update_feed_staleness_threshold_shows_success_message(): vo ->call('updateFeedStalenessThreshold') ->assertSet('successMessage', 'Settings updated successfully!'); } + + public function test_mount_loads_daily_publish_cap(): void + { + Setting::setDailyPublishCap(25); + + Livewire::test(Settings::class) + ->assertSet('dailyPublishCap', 25); + } + + public function test_mount_defaults_daily_publish_cap_to_unlimited(): void + { + Livewire::test(Settings::class) + ->assertSet('dailyPublishCap', 0); + } + + public function test_update_daily_publish_cap_saves_value(): void + { + Livewire::test(Settings::class) + ->set('dailyPublishCap', 40) + ->call('updateDailyPublishCap') + ->assertHasNoErrors(); + + $this->assertSame(40, Setting::getDailyPublishCap()); + } + + public function test_update_daily_publish_cap_validates_minimum(): void + { + Livewire::test(Settings::class) + ->set('dailyPublishCap', -1) + ->call('updateDailyPublishCap') + ->assertHasErrors(['dailyPublishCap' => 'min']); + } } diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 3823c7a3..b9e9979c 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -227,6 +227,122 @@ public function test_handle_skips_publishing_when_last_publication_within_interv $this->assertTrue(true); } + public function test_handle_skips_publishing_when_daily_cap_reached(): void + { + $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->count(3)->create(['published_at' => now()->subHours(2)]); + Setting::setArticlePublishingInterval(0); + Setting::setDailyPublishCap(3); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); + + $job = new PublishNextArticleJob; + $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_below_daily_cap(): void + { + $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->count(2)->create(['published_at' => now()->subHours(2)]); + Setting::setArticlePublishingInterval(0); + Setting::setDailyPublishCap(3); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn(['title' => 'Test Article', 'description' => 'Test description']); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishRouteArticle') + ->once() + ->andReturn(PublishOutcome::published($this->makePublication())); + + $job = new PublishNextArticleJob; + $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_daily_cap_is_zero(): void + { + $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->count(50)->create(['published_at' => now()->subHours(2)]); + Setting::setArticlePublishingInterval(0); + Setting::setDailyPublishCap(0); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn(['title' => 'Test Article', 'description' => 'Test description']); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishRouteArticle') + ->once() + ->andReturn(PublishOutcome::published($this->makePublication())); + + $job = new PublishNextArticleJob; + $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); + + $this->assertTrue(true); + } + + public function test_daily_cap_counts_each_channel_publication_separately(): void + { + $this->createApprovedRouteArticle(); + + $article = Article::factory()->create(); + ArticlePublication::factory()->count(3)->create([ + 'article_id' => $article->id, + 'published_at' => now()->subHours(2), + ]); + Setting::setArticlePublishingInterval(0); + Setting::setDailyPublishCap(3); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); + + $job = new PublishNextArticleJob; + $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); + + $this->assertTrue(true); + } + + public function test_handle_ignores_publications_from_previous_days(): void + { + $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->count(5)->create(['published_at' => now()->subDay()]); + Setting::setArticlePublishingInterval(0); + Setting::setDailyPublishCap(3); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn(['title' => 'Test Article', 'description' => 'Test description']); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishRouteArticle') + ->once() + ->andReturn(PublishOutcome::published($this->makePublication())); + + $job = new PublishNextArticleJob; + $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); + + $this->assertTrue(true); + } + public function test_handle_publishes_when_last_publication_beyond_interval(): void { $this->createApprovedRouteArticle();