Release v1.1.0 #79
8 changed files with 309 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ public function index(): JsonResponse
|
||||||
$settings = [
|
$settings = [
|
||||||
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
||||||
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
||||||
|
'article_publishing_interval' => Setting::getArticlePublishingInterval(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $this->sendResponse($settings, 'Settings retrieved successfully.');
|
return $this->sendResponse($settings, 'Settings retrieved successfully.');
|
||||||
|
|
@ -35,6 +36,7 @@ public function update(Request $request): JsonResponse
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'article_processing_enabled' => 'boolean',
|
'article_processing_enabled' => 'boolean',
|
||||||
'publishing_approvals_enabled' => 'boolean',
|
'publishing_approvals_enabled' => 'boolean',
|
||||||
|
'article_publishing_interval' => 'integer|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isset($validated['article_processing_enabled'])) {
|
if (isset($validated['article_processing_enabled'])) {
|
||||||
|
|
@ -45,9 +47,14 @@ public function update(Request $request): JsonResponse
|
||||||
Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
|
Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($validated['article_publishing_interval'])) {
|
||||||
|
Setting::setArticlePublishingInterval($validated['article_publishing_interval']);
|
||||||
|
}
|
||||||
|
|
||||||
$updatedSettings = [
|
$updatedSettings = [
|
||||||
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
|
||||||
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
|
||||||
|
'article_publishing_interval' => Setting::getArticlePublishingInterval(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
use App\Exceptions\PublishException;
|
use App\Exceptions\PublishException;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
|
use App\Models\ArticlePublication;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Publishing\ArticlePublishingService;
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
|
@ -30,6 +32,16 @@ public function __construct()
|
||||||
*/
|
*/
|
||||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
|
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
|
// Get the oldest approved article that hasn't been published yet
|
||||||
$article = Article::where('approval_status', 'approved')
|
$article = Article::where('approval_status', 'approved')
|
||||||
->whereDoesntHave('articlePublication')
|
->whereDoesntHave('articlePublication')
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class Settings extends Component
|
||||||
{
|
{
|
||||||
public bool $articleProcessingEnabled = true;
|
public bool $articleProcessingEnabled = true;
|
||||||
public bool $publishingApprovalsEnabled = false;
|
public bool $publishingApprovalsEnabled = false;
|
||||||
|
public int $articlePublishingInterval = 5;
|
||||||
|
|
||||||
public ?string $successMessage = null;
|
public ?string $successMessage = null;
|
||||||
public ?string $errorMessage = null;
|
public ?string $errorMessage = null;
|
||||||
|
|
@ -17,6 +18,7 @@ public function mount(): void
|
||||||
{
|
{
|
||||||
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
|
||||||
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
|
||||||
|
$this->articlePublishingInterval = Setting::getArticlePublishingInterval();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleArticleProcessing(): void
|
public function toggleArticleProcessing(): void
|
||||||
|
|
@ -33,6 +35,16 @@ public function togglePublishingApprovals(): void
|
||||||
$this->showSuccess();
|
$this->showSuccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateArticlePublishingInterval(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'articlePublishingInterval' => 'required|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Setting::setArticlePublishingInterval($this->articlePublishingInterval);
|
||||||
|
$this->showSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
protected function showSuccess(): void
|
protected function showSuccess(): void
|
||||||
{
|
{
|
||||||
$this->successMessage = 'Settings updated successfully!';
|
$this->successMessage = 'Settings updated successfully!';
|
||||||
|
|
|
||||||
|
|
@ -59,4 +59,14 @@ public static function setPublishingApprovalsEnabled(bool $enabled): void
|
||||||
{
|
{
|
||||||
static::setBool('enable_publishing_approvals', $enabled);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,36 @@ class="flex-shrink-0"
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">
|
||||||
|
Publishing Interval (minutes)
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Minimum time between publishing articles. Set to 0 for no delay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
wire:model="articlePublishingInterval"
|
||||||
|
min="0"
|
||||||
|
max="1440"
|
||||||
|
step="1"
|
||||||
|
class="w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
wire:click="updateArticlePublishingInterval"
|
||||||
|
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@error('articlePublishingInterval')
|
||||||
|
<p class="text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-gray-900">
|
<h3 class="text-sm font-medium text-gray-900">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ public function test_index_returns_current_settings(): void
|
||||||
'data' => [
|
'data' => [
|
||||||
'article_processing_enabled',
|
'article_processing_enabled',
|
||||||
'publishing_approvals_enabled',
|
'publishing_approvals_enabled',
|
||||||
|
'article_publishing_interval',
|
||||||
],
|
],
|
||||||
'message'
|
'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([
|
$response->assertJsonStructure([
|
||||||
'data' => [
|
'data' => [
|
||||||
'article_processing_enabled',
|
'article_processing_enabled',
|
||||||
'publishing_approvals_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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\ArticlePublication;
|
use App\Models\ArticlePublication;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Publishing\ArticlePublishingService;
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -288,6 +289,153 @@ public function test_handle_fetches_article_data_before_publishing(): void
|
||||||
$this->assertTrue(true);
|
$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
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
Mockery::close();
|
Mockery::close();
|
||||||
|
|
|
||||||
42
tests/Unit/Models/SettingTest.php
Normal file
42
tests/Unit/Models/SettingTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SettingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_get_article_publishing_interval_returns_default_when_not_set(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue