Release v1.1.0 #79

Merged
myrmidex merged 12 commits from release/v1.1.0 into main 2026-03-08 11:44:53 +01:00
8 changed files with 309 additions and 1 deletions
Showing only changes of commit 677d1cab6e - Show all commits

View file

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

View file

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

View file

@ -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!';

View file

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

View file

@ -47,6 +47,36 @@ class="flex-shrink-0"
</button>
</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>
<h3 class="text-sm font-medium text-gray-900">

View file

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

View file

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

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