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