Release v1.4.0 #146
10 changed files with 279 additions and 111 deletions
|
|
@ -42,8 +42,7 @@ public function execute(RouteArticle $routeArticle): PublishOutcome
|
||||||
? $this->publishingService->publishRouteArticle($routeArticle, $extractedData)
|
? $this->publishingService->publishRouteArticle($routeArticle, $extractedData)
|
||||||
: PublishOutcome::failure('Could not recover the article content to publish');
|
: PublishOutcome::failure('Could not recover the article content to publish');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
$routeArticle->recordPublishFailed($e->getMessage());
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
|
||||||
|
|
||||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
|
|
@ -103,8 +102,10 @@ private function resolvePublishData(Article $article): array
|
||||||
|
|
||||||
private function recordPublished(RouteArticle $routeArticle): void
|
private function recordPublished(RouteArticle $routeArticle): void
|
||||||
{
|
{
|
||||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
$routeArticle->update([
|
||||||
$routeArticle->clearPublishAttempts();
|
'publish_status' => PublishStatusEnum::PUBLISHED,
|
||||||
|
'publish_error' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
|
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
|
||||||
'article_id' => $routeArticle->article->id,
|
'article_id' => $routeArticle->article->id,
|
||||||
|
|
@ -141,20 +142,18 @@ private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcom
|
||||||
{
|
{
|
||||||
$article = $routeArticle->article;
|
$article = $routeArticle->article;
|
||||||
|
|
||||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
$routeArticle->recordPublishFailed($outcome->reason ?? 'Publishing failed');
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
|
||||||
|
|
||||||
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'title' => $article->title,
|
'title' => $article->title,
|
||||||
'reason' => $outcome->reason,
|
'reason' => $outcome->reason,
|
||||||
'attempt' => $routeArticle->publish_attempts,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
ActivityTypeEnum::ERROR,
|
ActivityTypeEnum::ERROR,
|
||||||
"Failed to publish \"{$article->title}\"",
|
"Failed to publish \"{$article->title}\"",
|
||||||
['reason' => $outcome->reason, 'attempt' => $routeArticle->publish_attempts],
|
['reason' => $outcome->reason],
|
||||||
$article,
|
$article,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,9 +121,28 @@ public function refresh(): void
|
||||||
$this->dispatch('refresh-started');
|
$this->dispatch('refresh-started');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function retryPublish(int $routeArticleId): void
|
||||||
|
{
|
||||||
|
$routeArticle = RouteArticle::failed()->find($routeArticleId);
|
||||||
|
|
||||||
|
if (! $routeArticle instanceof RouteArticle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeArticle->clearPublishFailure();
|
||||||
|
|
||||||
|
ActivityLogged::dispatch(
|
||||||
|
ActivityTypeEnum::PUBLISH,
|
||||||
|
"Queued \"{$routeArticle->article->title}\" for another publish attempt",
|
||||||
|
['route_article_id' => $routeArticle->id],
|
||||||
|
$routeArticle->article,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||||
|
$failedCount = RouteArticle::failed()->count();
|
||||||
|
|
||||||
if ($this->tab === 'pending') {
|
if ($this->tab === 'pending') {
|
||||||
return view('livewire.articles', [
|
return view('livewire.articles', [
|
||||||
|
|
@ -131,6 +150,7 @@ public function render(): View
|
||||||
'pendingFeeds' => $this->pendingFeeds(),
|
'pendingFeeds' => $this->pendingFeeds(),
|
||||||
'feedOptions' => $this->feedOptions(),
|
'feedOptions' => $this->feedOptions(),
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
'clearableCount' => $this->clearableQuery()->count(),
|
'clearableCount' => $this->clearableQuery()->count(),
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +158,10 @@ public function render(): View
|
||||||
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($this->tab === 'failed') {
|
||||||
|
$query->failed();
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->feedId !== null) {
|
if ($this->feedId !== null) {
|
||||||
$query->where('feed_id', $this->feedId);
|
$query->where('feed_id', $this->feedId);
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +179,7 @@ public function render(): View
|
||||||
'pendingFeeds' => null,
|
'pendingFeeds' => null,
|
||||||
'feedOptions' => $this->feedOptions(),
|
'feedOptions' => $this->feedOptions(),
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
|
'failedCount' => $failedCount,
|
||||||
'clearableCount' => 0,
|
'clearableCount' => 0,
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@
|
||||||
* @property int $article_id
|
* @property int $article_id
|
||||||
* @property ApprovalStatusEnum $approval_status
|
* @property ApprovalStatusEnum $approval_status
|
||||||
* @property PublishStatusEnum $publish_status
|
* @property PublishStatusEnum $publish_status
|
||||||
* @property int $publish_attempts
|
* @property string|null $publish_error
|
||||||
* @property Carbon|null $next_attempt_at
|
|
||||||
* @property Carbon|null $validated_at
|
* @property Carbon|null $validated_at
|
||||||
* @property Carbon|null $decided_at
|
* @property Carbon|null $decided_at
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
|
|
@ -39,8 +38,7 @@ class RouteArticle extends Model
|
||||||
'article_id',
|
'article_id',
|
||||||
'approval_status',
|
'approval_status',
|
||||||
'publish_status',
|
'publish_status',
|
||||||
'publish_attempts',
|
'publish_error',
|
||||||
'next_attempt_at',
|
|
||||||
'validated_at',
|
'validated_at',
|
||||||
'decided_at',
|
'decided_at',
|
||||||
];
|
];
|
||||||
|
|
@ -48,8 +46,6 @@ class RouteArticle extends Model
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'approval_status' => ApprovalStatusEnum::class,
|
'approval_status' => ApprovalStatusEnum::class,
|
||||||
'publish_status' => PublishStatusEnum::class,
|
'publish_status' => PublishStatusEnum::class,
|
||||||
'publish_attempts' => 'integer',
|
|
||||||
'next_attempt_at' => 'datetime',
|
|
||||||
'validated_at' => 'datetime',
|
'validated_at' => 'datetime',
|
||||||
'decided_at' => 'datetime',
|
'decided_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
@ -142,41 +138,39 @@ public function reject(): void
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const RETRY_BACKOFF_MINUTES = [5, 30, 120, 360];
|
public function recordPublishFailed(string $reason): void
|
||||||
|
|
||||||
public const MAX_PUBLISH_ATTEMPTS = 4;
|
|
||||||
|
|
||||||
public function recordPublishAttemptFailed(): void
|
|
||||||
{
|
{
|
||||||
$attempts = $this->publish_attempts + 1;
|
|
||||||
$backoff = self::RETRY_BACKOFF_MINUTES;
|
|
||||||
|
|
||||||
$this->update([
|
$this->update([
|
||||||
'publish_attempts' => $attempts,
|
'publish_status' => PublishStatusEnum::ERROR,
|
||||||
'next_attempt_at' => now()->addMinutes($backoff[$attempts - 1] ?? end($backoff)),
|
'publish_error' => $reason,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearPublishAttempts(): void
|
public function clearPublishFailure(): void
|
||||||
{
|
{
|
||||||
$this->update(['publish_attempts' => 0, 'next_attempt_at' => null]);
|
$this->update([
|
||||||
|
'publish_status' => PublishStatusEnum::UNPUBLISHED,
|
||||||
|
'publish_error' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasExhaustedPublishAttempts(): bool
|
/**
|
||||||
|
* A failed article is never picked up again on its own; the user retries it from the Articles page.
|
||||||
|
*
|
||||||
|
* @param Builder<RouteArticle> $query
|
||||||
|
* @return Builder<RouteArticle>
|
||||||
|
*/
|
||||||
|
public function scopeDueForPublishing(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $this->publish_attempts >= self::MAX_PUBLISH_ATTEMPTS;
|
return $query->where('publish_status', '!=', PublishStatusEnum::ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Builder<RouteArticle> $query
|
* @param Builder<RouteArticle> $query
|
||||||
* @return Builder<RouteArticle>
|
* @return Builder<RouteArticle>
|
||||||
*/
|
*/
|
||||||
public function scopeDueForPublishing(Builder $query): Builder
|
public function scopeFailed(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('publish_attempts', '<', self::MAX_PUBLISH_ATTEMPTS)
|
return $query->where('publish_status', PublishStatusEnum::ERROR);
|
||||||
->where(function (Builder $query) {
|
|
||||||
$query->whereNull('next_attempt_at')
|
|
||||||
->orWhere('next_attempt_at', '<=', now());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('route_articles', function (Blueprint $table) {
|
||||||
|
$table->text('publish_error')->nullable()->after('publish_status');
|
||||||
|
|
||||||
|
$table->dropIndex(['next_attempt_at']);
|
||||||
|
$table->dropColumn(['publish_attempts', 'next_attempt_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('route_articles', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('publish_error');
|
||||||
|
|
||||||
|
$table->unsignedTinyInteger('publish_attempts')->default(0)->after('publish_status');
|
||||||
|
$table->timestamp('next_attempt_at')->nullable()->after('publish_attempts');
|
||||||
|
|
||||||
|
$table->index('next_attempt_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -23,8 +23,31 @@ class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ $routeArticle->created_at->format('M d, Y H:i') }}
|
{{ $routeArticle->created_at->format('M d, Y H:i') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($routeArticle->publish_error !== null)
|
||||||
|
<p class="mt-2 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
|
||||||
|
{{ $routeArticle->publish_error }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
|
@if ($tab === 'failed')
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
wire:click="retryPublish({{ $routeArticle->id }})"
|
||||||
|
class="inline-flex items-center p-1.5 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md dark:text-blue-400"
|
||||||
|
title="Retry publishing"
|
||||||
|
aria-label="Retry publishing {{ $routeArticle->article->title ?? 'article' }}"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Status badge (All tab) --}}
|
{{-- Status badge (All tab) --}}
|
||||||
@if ($tab === 'all')
|
@if ($tab === 'all')
|
||||||
@if ($routeArticle->isApproved())
|
@if ($routeArticle->isApproved())
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,17 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'p
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
wire:click="setTab('failed')"
|
||||||
|
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'failed' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}"
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
@if ($failedCount > 0)
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{{ $failedCount }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
wire:click="setTab('all')"
|
wire:click="setTab('all')"
|
||||||
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}"
|
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}"
|
||||||
|
|
@ -128,6 +139,8 @@ class="border-t border-gray-100 bg-gray-50 p-4 space-y-4 dark:border-gray-700 da
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
@if ($tab === 'pending')
|
@if ($tab === 'pending')
|
||||||
No pending articles
|
No pending articles
|
||||||
|
@elseif ($tab === 'failed')
|
||||||
|
No failed articles
|
||||||
@else
|
@else
|
||||||
No articles found
|
No articles found
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -137,6 +150,8 @@ class="border-t border-gray-100 bg-gray-50 p-4 space-y-4 dark:border-gray-700 da
|
||||||
No pending articles for the selected feed.
|
No pending articles for the selected feed.
|
||||||
@elseif ($tab === 'pending')
|
@elseif ($tab === 'pending')
|
||||||
All route articles have been reviewed.
|
All route articles have been reviewed.
|
||||||
|
@elseif ($tab === 'failed')
|
||||||
|
Nothing has failed to publish.
|
||||||
@elseif ($search !== '')
|
@elseif ($search !== '')
|
||||||
No results for "{{ $search }}".
|
No results for "{{ $search }}".
|
||||||
@elseif ($feedId !== null)
|
@elseif ($feedId !== null)
|
||||||
|
|
|
||||||
|
|
@ -558,4 +558,93 @@ public function test_empty_state_on_all_tab(): void
|
||||||
->call('setTab', 'all')
|
->call('setTab', 'all')
|
||||||
->assertSee('No route articles have been created yet.');
|
->assertSee('No route articles have been created yet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function failedRouteArticle(string $reason = 'couldnt_find_community'): RouteArticle
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
$routeArticle->recordPublishFailed($reason);
|
||||||
|
|
||||||
|
return $routeArticle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_failed_tab_lists_only_failed_articles(): void
|
||||||
|
{
|
||||||
|
$failed = $this->failedRouteArticle();
|
||||||
|
/** @var RouteArticle $ok */
|
||||||
|
$ok = RouteArticle::factory()->approved()->create();
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)
|
||||||
|
->call('setTab', 'failed')
|
||||||
|
->assertSee($failed->article->title)
|
||||||
|
->assertDontSee($ok->article->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_failed_tab_shows_the_failure_reason(): void
|
||||||
|
{
|
||||||
|
$this->failedRouteArticle('couldnt_find_community');
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)
|
||||||
|
->call('setTab', 'failed')
|
||||||
|
->assertSee('couldnt_find_community');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_failed_tab_badge_counts_failed_articles(): void
|
||||||
|
{
|
||||||
|
$this->failedRouteArticle();
|
||||||
|
$this->failedRouteArticle();
|
||||||
|
RouteArticle::factory()->approved()->create();
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)
|
||||||
|
->assertViewHas('failedCount', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_failed_tab_is_empty_when_nothing_failed(): void
|
||||||
|
{
|
||||||
|
RouteArticle::factory()->approved()->create();
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)
|
||||||
|
->call('setTab', 'failed')
|
||||||
|
->assertSee('Nothing has failed to publish.')
|
||||||
|
->assertViewHas('failedCount', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrying_clears_the_failure_and_requeues_the_article(): void
|
||||||
|
{
|
||||||
|
$routeArticle = $this->failedRouteArticle();
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)
|
||||||
|
->call('setTab', 'failed')
|
||||||
|
->call('retryPublish', $routeArticle->id);
|
||||||
|
|
||||||
|
$routeArticle->refresh();
|
||||||
|
|
||||||
|
$this->assertNull($routeArticle->publish_error);
|
||||||
|
$this->assertTrue(
|
||||||
|
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrying_keeps_the_approval_decision_intact(): void
|
||||||
|
{
|
||||||
|
$routeArticle = $this->failedRouteArticle();
|
||||||
|
$decidedAt = $routeArticle->decided_at;
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)->call('retryPublish', $routeArticle->id);
|
||||||
|
|
||||||
|
$routeArticle->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue($routeArticle->isApproved());
|
||||||
|
$this->assertEquals($decidedAt, $routeArticle->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrying_an_article_that_did_not_fail_does_nothing(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)->call('retryPublish', $routeArticle->id);
|
||||||
|
|
||||||
|
$this->assertNull($routeArticle->fresh()->publish_error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ public function test_publish_fails_when_fallback_fetch_returns_nothing(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_repeated_failures_exhaust_the_retry_attempts(): void
|
public function test_a_failure_stores_the_reason_and_stops_further_attempts(): void
|
||||||
{
|
{
|
||||||
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
||||||
|
|
||||||
|
|
@ -185,23 +185,25 @@ public function test_repeated_failures_exhaust_the_retry_attempts(): void
|
||||||
$fetcher->shouldReceive('fetchArticleData')->andReturn([]);
|
$fetcher->shouldReceive('fetchArticleData')->andReturn([]);
|
||||||
|
|
||||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||||
$action = new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService);
|
|
||||||
|
|
||||||
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS; $i++) {
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||||
$action->execute($routeArticle);
|
->execute($routeArticle);
|
||||||
$routeArticle->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertSame(RouteArticle::MAX_PUBLISH_ATTEMPTS, $routeArticle->publish_attempts);
|
$routeArticle->refresh();
|
||||||
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
|
|
||||||
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
|
||||||
|
$this->assertNotNull($routeArticle->publish_error);
|
||||||
|
$this->assertFalse(
|
||||||
|
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_a_successful_publish_resets_earlier_failed_attempts(): void
|
public function test_a_successful_publish_clears_an_earlier_failure(): void
|
||||||
{
|
{
|
||||||
$routeArticle = $this->createRouteArticle([
|
$routeArticle = $this->createRouteArticle([
|
||||||
'description' => 'Stored description',
|
'description' => 'Stored description',
|
||||||
]);
|
]);
|
||||||
$routeArticle->update(['publish_attempts' => 2, 'next_attempt_at' => now()->subMinute()]);
|
$routeArticle->recordPublishFailed('an earlier failure');
|
||||||
|
|
||||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||||
$fetcher->shouldNotReceive('fetchArticleData');
|
$fetcher->shouldNotReceive('fetchArticleData');
|
||||||
|
|
@ -216,8 +218,8 @@ public function test_a_successful_publish_resets_earlier_failed_attempts(): void
|
||||||
|
|
||||||
$routeArticle->refresh();
|
$routeArticle->refresh();
|
||||||
|
|
||||||
$this->assertSame(0, $routeArticle->publish_attempts);
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->publish_status);
|
||||||
$this->assertNull($routeArticle->next_attempt_at);
|
$this->assertNull($routeArticle->publish_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_publish_fails_when_fallback_recovers_only_a_title(): void
|
public function test_publish_fails_when_fallback_recovers_only_a_title(): void
|
||||||
|
|
|
||||||
|
|
@ -515,10 +515,10 @@ public function test_handle_creates_notification_on_publish_exception(): void
|
||||||
$this->assertStringContainsString('Failing Article', $notification->title);
|
$this->assertStringContainsString('Failing Article', $notification->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_route_articles_that_are_not_due_for_retry(): void
|
public function test_handle_skips_route_articles_that_previously_failed(): void
|
||||||
{
|
{
|
||||||
$routeArticle = $this->createApprovedRouteArticle();
|
$routeArticle = $this->createApprovedRouteArticle();
|
||||||
$routeArticle->update(['publish_attempts' => 1, 'next_attempt_at' => now()->addMinutes(5)]);
|
$routeArticle->recordPublishFailed('couldnt_find_community');
|
||||||
|
|
||||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||||
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
||||||
|
|
@ -529,37 +529,14 @@ public function test_handle_skips_route_articles_that_are_not_due_for_retry(): v
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
||||||
|
|
||||||
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->fresh()->publish_status);
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_route_articles_that_exhausted_their_attempts(): void
|
public function test_handle_publishes_a_later_article_when_the_oldest_has_failed(): void
|
||||||
{
|
|
||||||
$routeArticle = $this->createApprovedRouteArticle();
|
|
||||||
$routeArticle->update([
|
|
||||||
'publish_attempts' => RouteArticle::MAX_PUBLISH_ATTEMPTS,
|
|
||||||
'next_attempt_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
|
||||||
$articleFetcherMock->shouldNotReceive('fetchArticleData');
|
|
||||||
|
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
|
||||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
|
||||||
|
|
||||||
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->fresh()->publish_status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_handle_publishes_a_later_article_when_the_oldest_is_backing_off(): void
|
|
||||||
{
|
{
|
||||||
$blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']);
|
$blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']);
|
||||||
$blocked->update([
|
$blocked->update(['created_at' => now()->subDays(2)]);
|
||||||
'created_at' => now()->subDays(2),
|
$blocked->recordPublishFailed('couldnt_find_community');
|
||||||
'publish_attempts' => 1,
|
|
||||||
'next_attempt_at' => now()->addMinutes(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
|
$next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
|
||||||
$next->update(['created_at' => now()->subDay()]);
|
$next->update(['created_at' => now()->subDay()]);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Unit\Models;
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
|
use App\Enums\PublishStatusEnum;
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -10,67 +11,80 @@ class RouteArticleRetryTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_first_failure_schedules_the_shortest_backoff(): void
|
public function test_recording_a_failure_stores_the_reason(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
|
||||||
$this->freezeTime(function () use ($routeArticle) {
|
$routeArticle->recordPublishFailed('couldnt_find_community');
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
|
||||||
|
|
||||||
$this->assertSame(1, $routeArticle->publish_attempts);
|
$routeArticle->refresh();
|
||||||
$this->assertSame(
|
|
||||||
now()->addMinutes(5)->format('Y-m-d H:i:s'),
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
|
||||||
$routeArticle->next_attempt_at->format('Y-m-d H:i:s')
|
$this->assertSame('couldnt_find_community', $routeArticle->publish_error);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_backoff_grows_with_each_failure(): void
|
public function test_clearing_a_failure_makes_it_publishable_again(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
$routeArticle->recordPublishFailed('boom');
|
||||||
|
|
||||||
$this->freezeTime(function () use ($routeArticle) {
|
$routeArticle->clearPublishFailure();
|
||||||
foreach ([5, 30, 120, 360] as $expectedDelay) {
|
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
|
||||||
|
|
||||||
$this->assertSame(
|
$routeArticle->refresh();
|
||||||
now()->addMinutes($expectedDelay)->format('Y-m-d H:i:s'),
|
|
||||||
$routeArticle->next_attempt_at->format('Y-m-d H:i:s'),
|
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->publish_status);
|
||||||
"Attempt {$routeArticle->publish_attempts} should wait {$expectedDelay} minutes"
|
$this->assertNull($routeArticle->publish_error);
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_attempts_are_exhausted_after_the_configured_maximum(): void
|
public function test_a_failed_article_is_not_due_for_publishing(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
$routeArticle->recordPublishFailed('boom');
|
||||||
|
|
||||||
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS - 1; $i++) {
|
$this->assertFalse(
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||||
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts());
|
);
|
||||||
}
|
|
||||||
|
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
|
||||||
|
|
||||||
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_a_successful_publish_clears_previous_attempts(): void
|
public function test_clearing_the_failure_returns_it_to_the_publish_queue(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
$routeArticle->recordPublishFailed('boom');
|
||||||
|
$routeArticle->clearPublishFailure();
|
||||||
|
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
$this->assertTrue(
|
||||||
$routeArticle->recordPublishAttemptFailed();
|
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$routeArticle->clearPublishAttempts();
|
public function test_the_failed_scope_returns_only_failed_articles(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $failed */
|
||||||
|
$failed = RouteArticle::factory()->approved()->create();
|
||||||
|
$failed->recordPublishFailed('boom');
|
||||||
|
|
||||||
$this->assertSame(0, $routeArticle->publish_attempts);
|
RouteArticle::factory()->approved()->create();
|
||||||
$this->assertNull($routeArticle->next_attempt_at);
|
|
||||||
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts());
|
$ids = RouteArticle::failed()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertSame([$failed->id], $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_failure_does_not_change_the_approval_decision(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||||
|
$decidedAt = $routeArticle->decided_at;
|
||||||
|
|
||||||
|
$routeArticle->recordPublishFailed('boom');
|
||||||
|
|
||||||
|
$routeArticle->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue($routeArticle->isApproved());
|
||||||
|
$this->assertEquals($decidedAt, $routeArticle->decided_at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue