Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
8 changed files with 258 additions and 0 deletions
Showing only changes of commit cb63a05aa5 - Show all commits

View file

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

View file

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

View file

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

View file

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

View file

@ -72,6 +72,36 @@ class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-
<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">
Daily Publish Cap
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Maximum posts per UTC day, counted across all channels. An article sent to
three channels counts as three. Set to 0 for no limit.
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
wire:model="dailyPublishCap"
min="0"
step="1"
class="w-24 rounded-md border border-gray-300 px-3 py-2 shadow-xs focus:border-indigo-500 focus:ring-indigo-500 text-sm dark:border-gray-600"
/>
<button
wire:click="updateDailyPublishCap"
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs 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('dailyPublishCap')
<p class="text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100">

View file

@ -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', [

View file

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

View file

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