From 9fb373d1396f9ecab83b64f4f34be08bfe701ed3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:20:15 +0100 Subject: [PATCH] 98 - Add RouteArticle API endpoints for approve, reject, restore, and clear --- .../Api/V1/RouteArticlesController.php | 101 +++++++ app/Http/Resources/RouteArticleResource.php | 37 +++ routes/api.php | 8 + .../Api/V1/RouteArticlesControllerTest.php | 253 ++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/RouteArticlesController.php create mode 100644 app/Http/Resources/RouteArticleResource.php create mode 100644 tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php diff --git a/app/Http/Controllers/Api/V1/RouteArticlesController.php b/app/Http/Controllers/Api/V1/RouteArticlesController.php new file mode 100644 index 0000000..b41763b --- /dev/null +++ b/app/Http/Controllers/Api/V1/RouteArticlesController.php @@ -0,0 +1,101 @@ +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); + } + } +} diff --git a/app/Http/Resources/RouteArticleResource.php b/app/Http/Resources/RouteArticleResource.php new file mode 100644 index 0000000..2bb350e --- /dev/null +++ b/app/Http/Resources/RouteArticleResource.php @@ -0,0 +1,37 @@ + + */ + 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, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 0ab6dfc..38a35e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php new file mode 100644 index 0000000..715dc20 --- /dev/null +++ b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php @@ -0,0 +1,253 @@ +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, + ], + ], + ]); + } +}