From 677d1cab6eebb41d9d0d4e8a2e5d14f1d92a62f9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 8 Mar 2026 11:25:50 +0100 Subject: [PATCH] 62 - Add article publishing interval setting --- .../Controllers/Api/V1/SettingsController.php | 7 + app/Jobs/PublishNextArticleJob.php | 12 ++ app/Livewire/Settings.php | 12 ++ app/Models/Setting.php | 10 ++ resources/views/livewire/settings.blade.php | 30 ++++ .../Api/V1/SettingsControllerTest.php | 49 +++++- tests/Unit/Jobs/PublishNextArticleJobTest.php | 148 ++++++++++++++++++ tests/Unit/Models/SettingTest.php | 42 +++++ 8 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Models/SettingTest.php diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php index 25ba8a7..80edb29 100644 --- a/app/Http/Controllers/Api/V1/SettingsController.php +++ b/app/Http/Controllers/Api/V1/SettingsController.php @@ -18,6 +18,7 @@ public function index(): JsonResponse $settings = [ 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), + 'article_publishing_interval' => Setting::getArticlePublishingInterval(), ]; return $this->sendResponse($settings, 'Settings retrieved successfully.'); @@ -35,6 +36,7 @@ public function update(Request $request): JsonResponse $validated = $request->validate([ 'article_processing_enabled' => 'boolean', 'publishing_approvals_enabled' => 'boolean', + 'article_publishing_interval' => 'integer|min:0', ]); if (isset($validated['article_processing_enabled'])) { @@ -45,9 +47,14 @@ public function update(Request $request): JsonResponse Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']); } + if (isset($validated['article_publishing_interval'])) { + Setting::setArticlePublishingInterval($validated['article_publishing_interval']); + } + $updatedSettings = [ 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), + 'article_publishing_interval' => Setting::getArticlePublishingInterval(), ]; return $this->sendResponse( diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 4e5fc0d..f62c857 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -4,6 +4,8 @@ use App\Exceptions\PublishException; use App\Models\Article; +use App\Models\ArticlePublication; +use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -30,6 +32,16 @@ public function __construct() */ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void { + $interval = Setting::getArticlePublishingInterval(); + + if ($interval > 0) { + $lastPublishedAt = ArticlePublication::max('published_at'); + + if ($lastPublishedAt && now()->diffInMinutes($lastPublishedAt, absolute: true) < $interval) { + return; + } + } + // Get the oldest approved article that hasn't been published yet $article = Article::where('approval_status', 'approved') ->whereDoesntHave('articlePublication') diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index f63de38..9502fbd 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -9,6 +9,7 @@ class Settings extends Component { public bool $articleProcessingEnabled = true; public bool $publishingApprovalsEnabled = false; + public int $articlePublishingInterval = 5; public ?string $successMessage = null; public ?string $errorMessage = null; @@ -17,6 +18,7 @@ public function mount(): void { $this->articleProcessingEnabled = Setting::isArticleProcessingEnabled(); $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); + $this->articlePublishingInterval = Setting::getArticlePublishingInterval(); } public function toggleArticleProcessing(): void @@ -33,6 +35,16 @@ public function togglePublishingApprovals(): void $this->showSuccess(); } + public function updateArticlePublishingInterval(): void + { + $this->validate([ + 'articlePublishingInterval' => 'required|integer|min:0', + ]); + + Setting::setArticlePublishingInterval($this->articlePublishingInterval); + $this->showSuccess(); + } + protected function showSuccess(): void { $this->successMessage = 'Settings updated successfully!'; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 9f5a132..09b0381 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -59,4 +59,14 @@ public static function setPublishingApprovalsEnabled(bool $enabled): void { static::setBool('enable_publishing_approvals', $enabled); } + + public static function getArticlePublishingInterval(): int + { + return (int) static::get('article_publishing_interval', 5); + } + + public static function setArticlePublishingInterval(int $minutes): void + { + static::set('article_publishing_interval', (string) $minutes); + } } diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 96b050c..bd7a258 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -47,6 +47,36 @@ class="flex-shrink-0" +
+
+

+ Publishing Interval (minutes) +

+

+ Minimum time between publishing articles. Set to 0 for no delay. +

+
+
+ + +
+
+ @error('articlePublishingInterval') +

{{ $message }}

+ @enderror +

diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php index 95e8ead..e41b8c3 100644 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php @@ -20,6 +20,7 @@ public function test_index_returns_current_settings(): void 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ], 'message' ]) @@ -90,12 +91,58 @@ public function test_update_accepts_partial_updates(): void ] ]); - // Should still have structure for both settings + // Should still have structure for all settings $response->assertJsonStructure([ 'data' => [ 'article_processing_enabled', 'publishing_approvals_enabled', + 'article_publishing_interval', ] ]); } + + public function test_index_returns_article_publishing_interval(): void + { + $response = $this->getJson('/api/v1/settings'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'article_publishing_interval', + ], + ]) + ->assertJsonPath('data.article_publishing_interval', 5); + } + + public function test_update_accepts_valid_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 15, + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.article_publishing_interval', 15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + } + + public function test_update_rejects_negative_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => -5, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } + + public function test_update_rejects_non_integer_article_publishing_interval(): void + { + $response = $this->putJson('/api/v1/settings', [ + 'article_publishing_interval' => 'abc', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['article_publishing_interval']); + } } diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 9e8f6cf..af12d93 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -7,6 +7,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; +use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -288,6 +289,153 @@ public function test_handle_fetches_article_data_before_publishing(): void $this->assertTrue(true); } + public function test_handle_skips_publishing_when_last_publication_within_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 3 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(3), + ]); + Setting::setArticlePublishingInterval(10); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + // Neither should be called + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_beyond_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was 15 minutes ago, interval is 10 minutes + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(15), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_interval_is_zero(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was just now, but interval is 0 + ArticlePublication::factory()->create([ + 'published_at' => now(), + ]); + Setting::setArticlePublishingInterval(0); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_last_publication_exactly_at_interval(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish + ArticlePublication::factory()->create([ + 'published_at' => now()->subMinutes(10), + ]); + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + + public function test_handle_publishes_when_no_previous_publications_exist(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + ]); + + Setting::setArticlePublishingInterval(10); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once(); + + $job = new PublishNextArticleJob(); + $job->handle($articleFetcherMock, $publishingServiceMock); + + $this->assertTrue(true); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php new file mode 100644 index 0000000..1165b81 --- /dev/null +++ b/tests/Unit/Models/SettingTest.php @@ -0,0 +1,42 @@ +assertSame(5, Setting::getArticlePublishingInterval()); + } + + public function test_get_article_publishing_interval_returns_stored_value(): void + { + Setting::set('article_publishing_interval', '10'); + + $this->assertSame(10, Setting::getArticlePublishingInterval()); + } + + public function test_set_article_publishing_interval_persists_value(): void + { + Setting::setArticlePublishingInterval(15); + + $this->assertSame(15, Setting::getArticlePublishingInterval()); + $this->assertDatabaseHas('settings', [ + 'key' => 'article_publishing_interval', + 'value' => '15', + ]); + } + + public function test_set_article_publishing_interval_zero(): void + { + Setting::setArticlePublishingInterval(0); + + $this->assertSame(0, Setting::getArticlePublishingInterval()); + } +}