98 - Add RouteArticle API endpoints for approve, reject, restore, and clear
This commit is contained in:
parent
2b74f24356
commit
9fb373d139
4 changed files with 399 additions and 0 deletions
101
app/Http/Controllers/Api/V1/RouteArticlesController.php
Normal file
101
app/Http/Controllers/Api/V1/RouteArticlesController.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Http\Resources\RouteArticleResource;
|
||||
use App\Models\RouteArticle;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RouteArticlesController extends BaseController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min($request->get('per_page', 15), 100);
|
||||
|
||||
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->has('status')) {
|
||||
$status = ApprovalStatusEnum::tryFrom($request->get('status'));
|
||||
if ($status) {
|
||||
$query->where('approval_status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
$routeArticles = $query->paginate($perPage);
|
||||
|
||||
return $this->sendResponse([
|
||||
'route_articles' => RouteArticleResource::collection($routeArticles->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $routeArticles->currentPage(),
|
||||
'last_page' => $routeArticles->lastPage(),
|
||||
'per_page' => $routeArticles->perPage(),
|
||||
'total' => $routeArticles->total(),
|
||||
'from' => $routeArticles->firstItem(),
|
||||
'to' => $routeArticles->lastItem(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(RouteArticle $routeArticle): JsonResponse
|
||||
{
|
||||
try {
|
||||
$routeArticle->approve();
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
||||
'Route article approved and queued for publishing.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to approve route article: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function reject(RouteArticle $routeArticle): JsonResponse
|
||||
{
|
||||
try {
|
||||
$routeArticle->reject();
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
||||
'Route article rejected.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to reject route article: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function restore(RouteArticle $routeArticle): JsonResponse
|
||||
{
|
||||
try {
|
||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
||||
|
||||
return $this->sendResponse(
|
||||
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
||||
'Route article restored to pending.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to restore route article: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||
|
||||
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
||||
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
||||
|
||||
return $this->sendResponse(
|
||||
['rejected_count' => $count],
|
||||
"Rejected {$count} pending route articles."
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to clear pending route articles: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Http/Resources/RouteArticleResource.php
Normal file
37
app/Http/Resources/RouteArticleResource.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\RouteArticle
|
||||
*/
|
||||
class RouteArticleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'feed_id' => $this->feed_id,
|
||||
'platform_channel_id' => $this->platform_channel_id,
|
||||
'article_id' => $this->article_id,
|
||||
'approval_status' => $this->approval_status->value,
|
||||
'validated_at' => $this->validated_at?->toISOString(),
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'article' => [
|
||||
'id' => $this->article->id,
|
||||
'title' => $this->article->title,
|
||||
'url' => $this->article->url,
|
||||
'description' => $this->article->description,
|
||||
'feed_name' => $this->article->feed->name,
|
||||
],
|
||||
'route_name' => $this->feed->name.' → '.$this->platformChannel->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Http\Controllers\Api\V1\OnboardingController;
|
||||
use App\Http\Controllers\Api\V1\PlatformAccountsController;
|
||||
use App\Http\Controllers\Api\V1\PlatformChannelsController;
|
||||
use App\Http\Controllers\Api\V1\RouteArticlesController;
|
||||
use App\Http\Controllers\Api\V1\RoutingController;
|
||||
use App\Http\Controllers\Api\V1\SettingsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -102,6 +103,13 @@
|
|||
Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy');
|
||||
Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle');
|
||||
|
||||
// Route Articles
|
||||
Route::get('/route-articles', [RouteArticlesController::class, 'index'])->name('api.route-articles.index');
|
||||
Route::post('/route-articles/clear', [RouteArticlesController::class, 'clear'])->name('api.route-articles.clear');
|
||||
Route::post('/route-articles/{routeArticle}/approve', [RouteArticlesController::class, 'approve'])->name('api.route-articles.approve');
|
||||
Route::post('/route-articles/{routeArticle}/reject', [RouteArticlesController::class, 'reject'])->name('api.route-articles.reject');
|
||||
Route::post('/route-articles/{routeArticle}/restore', [RouteArticlesController::class, 'restore'])->name('api.route-articles.restore');
|
||||
|
||||
// Settings
|
||||
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
|
||||
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RouteArticlesControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusEnum::PENDING): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->create([
|
||||
'article_id' => $article->id,
|
||||
'approval_status' => $status,
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
|
||||
return $routeArticle;
|
||||
}
|
||||
|
||||
public function test_index_returns_successful_response(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/route-articles');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'route_articles',
|
||||
'pagination' => [
|
||||
'current_page',
|
||||
'last_page',
|
||||
'per_page',
|
||||
'total',
|
||||
'from',
|
||||
'to',
|
||||
],
|
||||
],
|
||||
'message',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_index_returns_route_articles_with_pagination(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
RouteArticle::factory()->forRoute($route)->create([
|
||||
'article_id' => $article->id,
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->getJson('/api/v1/route-articles?per_page=10');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'pagination' => [
|
||||
'per_page' => 10,
|
||||
'total' => 20,
|
||||
'last_page' => 2,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCount(10, $response->json('data.route_articles'));
|
||||
}
|
||||
|
||||
public function test_index_filters_by_status(): void
|
||||
{
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::REJECTED);
|
||||
|
||||
$response = $this->getJson('/api/v1/route-articles?status=pending');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertCount(2, $response->json('data.route_articles'));
|
||||
}
|
||||
|
||||
public function test_index_returns_all_when_no_status_filter(): void
|
||||
{
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::REJECTED);
|
||||
|
||||
$response = $this->getJson('/api/v1/route-articles');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertCount(3, $response->json('data.route_articles'));
|
||||
}
|
||||
|
||||
public function test_index_includes_article_and_route_data(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle();
|
||||
|
||||
$response = $this->getJson('/api/v1/route-articles');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json('data.route_articles.0');
|
||||
$this->assertArrayHasKey('article', $data);
|
||||
$this->assertArrayHasKey('title', $data['article']);
|
||||
$this->assertArrayHasKey('url', $data['article']);
|
||||
$this->assertArrayHasKey('route_name', $data);
|
||||
$this->assertArrayHasKey('approval_status', $data);
|
||||
}
|
||||
|
||||
public function test_approve_route_article_successfully(): void
|
||||
{
|
||||
Event::fake([RouteArticleApproved::class]);
|
||||
|
||||
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
|
||||
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/approve");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'Route article approved and queued for publishing.',
|
||||
]);
|
||||
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
|
||||
|
||||
Event::assertDispatched(RouteArticleApproved::class, function ($event) use ($routeArticle) {
|
||||
return $event->routeArticle->id === $routeArticle->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_approve_nonexistent_route_article_returns_404(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/route-articles/999/approve');
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_reject_route_article_successfully(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
|
||||
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/reject");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'Route article rejected.',
|
||||
]);
|
||||
|
||||
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_reject_nonexistent_route_article_returns_404(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/route-articles/999/reject');
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_restore_route_article_successfully(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED);
|
||||
|
||||
$response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/restore");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'Route article restored to pending.',
|
||||
]);
|
||||
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_restore_nonexistent_route_article_returns_404(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/route-articles/999/restore');
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_clear_rejects_all_pending_route_articles(): void
|
||||
{
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::PENDING);
|
||||
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
|
||||
|
||||
$response = $this->postJson('/api/v1/route-articles/clear');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'rejected_count' => 3,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertEquals(0, RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count());
|
||||
$this->assertEquals(1, RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)->count());
|
||||
$this->assertEquals(3, RouteArticle::where('approval_status', ApprovalStatusEnum::REJECTED)->count());
|
||||
}
|
||||
|
||||
public function test_clear_returns_zero_when_no_pending(): void
|
||||
{
|
||||
$this->createRouteArticle(ApprovalStatusEnum::APPROVED);
|
||||
|
||||
$response = $this->postJson('/api/v1/route-articles/clear');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'rejected_count' => 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_index_respects_per_page_limit(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/route-articles?per_page=150');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'data' => [
|
||||
'pagination' => [
|
||||
'per_page' => 100,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue