Release v1.4.0 #146
10 changed files with 324 additions and 42 deletions
|
|
@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse
|
||||||
public function restore(RouteArticle $routeArticle): JsonResponse
|
public function restore(RouteArticle $routeArticle): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
$routeArticle->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
||||||
|
|
@ -88,7 +91,10 @@ public function clear(): JsonResponse
|
||||||
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||||
|
|
||||||
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
||||||
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
['rejected_count' => $count],
|
['rejected_count' => $count],
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void
|
||||||
public function restore(int $routeArticleId): void
|
public function restore(int $routeArticleId): void
|
||||||
{
|
{
|
||||||
$routeArticle = RouteArticle::findOrFail($routeArticleId);
|
$routeArticle = RouteArticle::findOrFail($routeArticleId);
|
||||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
$routeArticle->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
|
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
|
||||||
$cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
$cleared = $this->clearableQuery()->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
if ($cleared > 0) {
|
if ($cleared > 0) {
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class Dashboard extends Component
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->applyPreset('today');
|
$this->applyPreset('month');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,10 @@ public function approve(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
$this->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
ActivityTypeEnum::APPROVE,
|
ActivityTypeEnum::APPROVE,
|
||||||
|
|
@ -126,7 +129,10 @@ public function reject(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
$this->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
ActivityTypeEnum::REJECT,
|
ActivityTypeEnum::REJECT,
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void
|
||||||
[
|
[
|
||||||
'approval_status' => $status,
|
'approval_status' => $status,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ public function pending(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::PENDING,
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +71,7 @@ public function approved(): static
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::APPROVED,
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +80,7 @@ public function rejected(): static
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::REJECTED,
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,8 +183,9 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Articles Fetched vs Published -->
|
<!-- Trend and breakdowns -->
|
||||||
<div class="mt-8">
|
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
||||||
|
|
||||||
@island('articles-trend')
|
@island('articles-trend')
|
||||||
|
|
@ -196,8 +197,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Articles per Feed -->
|
<div>
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
||||||
|
|
||||||
@island('articles-per-feed')
|
@island('articles-per-feed')
|
||||||
|
|
@ -208,8 +208,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Publications per Channel -->
|
<div>
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
|
||||||
|
|
||||||
@island('publications-per-channel')
|
@island('publications-per-channel')
|
||||||
|
|
@ -219,4 +218,5 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
])
|
])
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,13 @@ class DashboardTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_it_defaults_to_the_current_day(): void
|
public function test_it_defaults_to_the_current_month(): void
|
||||||
{
|
{
|
||||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
Livewire::test(Dashboard::class)
|
Livewire::test(Dashboard::class)
|
||||||
->assertSet('from', '2026-07-15')
|
->assertSet('from', '2026-07-01')
|
||||||
->assertSet('to', '2026-07-15');
|
->assertSet('to', '2026-07-31');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_recomputes_stats_when_the_range_changes(): void
|
public function test_it_recomputes_stats_when_the_range_changes(): void
|
||||||
|
|
@ -63,8 +63,8 @@ public function test_it_ignores_an_unknown_preset(): void
|
||||||
|
|
||||||
Livewire::test(Dashboard::class)
|
Livewire::test(Dashboard::class)
|
||||||
->call('applyPreset', 'fortnight')
|
->call('applyPreset', 'fortnight')
|
||||||
->assertSet('from', '2026-07-15')
|
->assertSet('from', '2026-07-01')
|
||||||
->assertSet('to', '2026-07-15')
|
->assertSet('to', '2026-07-31')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
174
tests/Feature/RouteArticleDecisionStampingTest.php
Normal file
174
tests/Feature/RouteArticleDecisionStampingTest.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Livewire\Articles;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Keyword;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\Route;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Article\ValidationService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RouteArticleDecisionStampingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_clearing_pending_articles_stamps_every_rejected_row(): void
|
||||||
|
{
|
||||||
|
$pending = RouteArticle::factory()->count(3)->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)->call('clear');
|
||||||
|
|
||||||
|
foreach ($pending as $routeArticle) {
|
||||||
|
/** @var RouteArticle $fresh */
|
||||||
|
$fresh = $routeArticle->fresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($fresh->decided_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_api_clear_endpoint_stamps_every_rejected_row(): void
|
||||||
|
{
|
||||||
|
$pending = RouteArticle::factory()->count(3)->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->postJson('/api/v1/route-articles/clear')
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
foreach ($pending as $routeArticle) {
|
||||||
|
/** @var RouteArticle $fresh */
|
||||||
|
$fresh = $routeArticle->fresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($fresh->decided_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restoring_to_pending_clears_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Articles::class)->call('restore', $routeArticle->id);
|
||||||
|
|
||||||
|
$this->assertNull($routeArticle->fresh()->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_api_restore_endpoint_clears_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->postJson("/api/v1/route-articles/{$routeArticle->id}/restore")
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertNull($routeArticle->fresh()->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_auto_approved_articles_are_stamped_on_creation(): void
|
||||||
|
{
|
||||||
|
$article = $this->articleOnRouteWithoutKeywords(autoApprove: true);
|
||||||
|
|
||||||
|
$this->validate($article);
|
||||||
|
|
||||||
|
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertSame(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
|
||||||
|
$this->assertNotNull($routeArticle->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_keyword_rejected_articles_are_stamped_on_creation(): void
|
||||||
|
{
|
||||||
|
$article = $this->articleOnRouteWithKeyword('nothing-that-matches');
|
||||||
|
|
||||||
|
$this->validate($article);
|
||||||
|
|
||||||
|
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertSame(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status);
|
||||||
|
$this->assertNotNull($routeArticle->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pending_articles_are_not_stamped_on_creation(): void
|
||||||
|
{
|
||||||
|
$article = $this->articleOnRouteWithoutKeywords(autoApprove: false);
|
||||||
|
|
||||||
|
$this->validate($article);
|
||||||
|
|
||||||
|
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertSame(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||||
|
$this->assertNull($routeArticle->decided_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(Article $article): void
|
||||||
|
{
|
||||||
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||||
|
$fetcher->shouldReceive('fetchArticleData')
|
||||||
|
->with($article)
|
||||||
|
->once()
|
||||||
|
->andReturn([
|
||||||
|
'title' => 'Test Title',
|
||||||
|
'description' => 'Test description',
|
||||||
|
'full_article' => 'Body text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new ValidationService($fetcher))->validate($article);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function articleOnRouteWithoutKeywords(bool $autoApprove): Article
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create();
|
||||||
|
$channel = PlatformChannel::factory()->create();
|
||||||
|
|
||||||
|
Route::factory()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'platform_channel_id' => $channel->id,
|
||||||
|
'is_active' => true,
|
||||||
|
'auto_approve' => $autoApprove,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function articleOnRouteWithKeyword(string $keyword): Article
|
||||||
|
{
|
||||||
|
$feed = Feed::factory()->create();
|
||||||
|
$channel = PlatformChannel::factory()->create();
|
||||||
|
|
||||||
|
Route::factory()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'platform_channel_id' => $channel->id,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Keyword::factory()->active()->create([
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'platform_channel_id' => $channel->id,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/Unit/Models/RouteArticleDecisionTest.php
Normal file
86
tests/Unit/Models/RouteArticleDecisionTest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RouteArticleDecisionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_approving_stamps_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->freezeTime(function () use ($routeArticle) {
|
||||||
|
$routeArticle->approve();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
now()->format('Y-m-d H:i:s'),
|
||||||
|
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rejecting_stamps_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->freezeTime(function () use ($routeArticle) {
|
||||||
|
$routeArticle->reject();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
now()->format('Y-m-d H:i:s'),
|
||||||
|
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_re_approving_does_not_move_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routeArticle->approve();
|
||||||
|
$first = $routeArticle->fresh()->decided_at;
|
||||||
|
|
||||||
|
$this->travel(1)->hours();
|
||||||
|
$routeArticle->fresh()->approve();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$first->format('Y-m-d H:i:s'),
|
||||||
|
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rejecting_an_approved_article_moves_the_decision_time(): void
|
||||||
|
{
|
||||||
|
/** @var RouteArticle $routeArticle */
|
||||||
|
$routeArticle = RouteArticle::factory()->create([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routeArticle->approve();
|
||||||
|
$first = $routeArticle->fresh()->decided_at;
|
||||||
|
|
||||||
|
$this->travel(1)->hours();
|
||||||
|
$routeArticle->fresh()->reject();
|
||||||
|
|
||||||
|
$this->assertTrue($routeArticle->fresh()->decided_at->greaterThan($first));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue