diff --git a/app/Http/Controllers/Api/V1/Article/GetCommentsController.php b/app/Http/Controllers/Api/V1/Article/GetCommentsController.php new file mode 100644 index 0000000..ec33ce3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Article/GetCommentsController.php @@ -0,0 +1,61 @@ +withDefaults(); + + try { + $parentId = $params['parent_id'] !== null ? (int) $params['parent_id'] : null; + + $commentsDataResponse = CommentResource::collection($this->articleService->getArticleComments( + $article->id, + $parentId, + (int) $params['per_page'], + (int) $params['page'] + )); + /** @var array{data: array, meta: array} $commentsData */ + $commentsData = $commentsDataResponse->response()->getData(true); + + return response()->apiSuccess( + [ + 'comments' => $commentsData['data'], + 'meta' => MetaResource::make($commentsData['meta']), + ], + __('common.success') + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Requests/V1/Article/GetCommentsRequest.php b/app/Http/Requests/V1/Article/GetCommentsRequest.php new file mode 100644 index 0000000..8939d1e --- /dev/null +++ b/app/Http/Requests/V1/Article/GetCommentsRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'per_page' => 'integer|min:1|max:100', + 'page' => 'integer|min:1', + 'parent_id' => 'nullable|integer|exists:comments,id', + ]; + } + + /** + * @return array + */ + public function withDefaults(): array + { + return [ + 'per_page' => $this->input('per_page', 10), + 'page' => $this->input('page', 1), + 'parent_id' => $this->input('parent_id'), + ]; + } +} diff --git a/app/Http/Resources/V1/Comment/CommentResource.php b/app/Http/Resources/V1/Comment/CommentResource.php new file mode 100644 index 0000000..1a0bd79 --- /dev/null +++ b/app/Http/Resources/V1/Comment/CommentResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'user' => new UserResource($this->whenLoaded('user')), + 'content' => $this->content, + 'created_at' => $this->created_at, + 'replies_count' => $this->replies_count, + 'replies' => CommentResource::collection($this->whenLoaded('replies_page')), + ]; + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 6dc7ab3..b9f8ef5 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -14,6 +14,8 @@ * @property int $user_id * @property string $content * @property int|null $parent_comment_id + * @property-read int $replies_count + * @property-read \Illuminate\Database\Eloquent\Collection|null $replies_page * * @mixin \Eloquent * @@ -54,10 +56,12 @@ public function user(): BelongsTo } /** - * @return BelongsTo + * Get the replies (child comments) for this comment. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Comment, Comment> */ - public function parent(): BelongsTo + public function replies(): \Illuminate\Database\Eloquent\Relations\HasMany { - return $this->belongsTo(Comment::class, 'parent_comment_id'); + return $this->hasMany(Comment::class, 'parent_comment_id'); } } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 2ca864c..6bc624e 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -6,6 +6,7 @@ use App\Models\Article; use App\Models\Category; +use App\Models\Comment; use App\Models\Tag; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -145,4 +146,53 @@ public function getAllTags() { return Tag::query()->get(['id', 'name', 'slug']); } + + /** + * Get paginated comments for an article (with 1 child level or for a parent comment). + * + * Loads the comment's user, count of replies, and top replies (limited by $repliesPerPage). + * + * @param int $articleId The ID of the article. + * @param int|null $parentId The ID of the parent comment (if loading child comments). + * @param int $perPage Number of parent comments per page. + * @param int $page Current page number. + * @param int $repliesPerPage Number of child comments per parent. + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getArticleComments( + int $articleId, + ?int $parentId = null, + int $perPage = 10, + int $page = 1, + int $repliesPerPage = 3 + ): \Illuminate\Contracts\Pagination\LengthAwarePaginator { + $query = Comment::query() + ->where('article_id', $articleId) + ->when($parentId !== null, fn ($q) => $q->where('parent_comment_id', $parentId)) + ->when($parentId === null, fn ($q) => $q->whereNull('parent_comment_id')) + ->orderBy('created_at'); + + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + + /** @var \Illuminate\Database\Eloquent\Collection $comments */ + $comments = $paginator->getCollection(); + + $comments->load(['user']); + $comments->loadCount('replies'); + + // Load replies for each parent comment + $comments->each(function (Comment $comment) use ($repliesPerPage) { + $replies = $comment->replies() + ->with('user') + ->withCount('replies') + ->orderBy('created_at') + ->limit($repliesPerPage) + ->get(); + + $comment->setRelation('replies_page', $replies); + }); + + // Replace the collection on paginator so it's returned with relations loaded + return $paginator->setCollection($comments); + } } diff --git a/database/seeders/ArticleCommentSeeder.php b/database/seeders/ArticleCommentSeeder.php new file mode 100644 index 0000000..988bfd8 --- /dev/null +++ b/database/seeders/ArticleCommentSeeder.php @@ -0,0 +1,65 @@ +create(); + + // Create 20 categories and 30 tags + $categories = \App\Models\Category::factory(20)->create(); + $tags = \App\Models\Tag::factory(30)->create(); + + // Create 100 articles + $articles = Article::factory(100)->create(); + + foreach ($articles as $article) { + // Attach 1-3 random categories to each article + $article->categories()->attach($categories->random(rand(1, 3))->pluck('id')->toArray()); + // Attach 2-5 random tags to each article + $article->tags()->attach($tags->random(rand(2, 5))->pluck('id')->toArray()); + + // Create 10 top-level comments for each article + $topComments = []; + for ($i = 0; $i < 10; $i++) { + $topComments[$i] = Comment::factory()->create([ + 'article_id' => $article->id, + 'user_id' => $users->random()->id, + 'parent_comment_id' => null, + ]); + } + // For each top-level comment, create 2 child comments (level 1) + foreach ($topComments as $parentComment) { + for ($j = 0; $j < 2; $j++) { + $child = Comment::factory()->create([ + 'article_id' => $article->id, + 'user_id' => $users->random()->id, + 'parent_comment_id' => $parentComment->id, + ]); + // For each child comment, create 2 more child comments (level 2) + for ($k = 0; $k < 2; $k++) { + Comment::factory()->create([ + 'article_id' => $article->id, + 'user_id' => $users->random()->id, + 'parent_comment_id' => $child->id, + ]); + } + } + } + } + } +} diff --git a/routes/api_v1.php b/routes/api_v1.php index 88ffd43..055c8a7 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -23,6 +23,7 @@ Route::prefix('articles')->group(function () { Route::get('/', \App\Http\Controllers\Api\V1\Article\GetArticlesController::class)->name('api.v1.articles.index'); Route::get('/{slug}', \App\Http\Controllers\Api\V1\Article\ShowArticleController::class)->name('api.v1.articles.show'); + Route::get('/{article:slug}/comments', \App\Http\Controllers\Api\V1\Article\GetCommentsController::class)->name('api.v1.articles.comments.index'); }); // Category Routes (Public) diff --git a/tests/Feature/API/V1/Article/GetCommentsControllerTest.php b/tests/Feature/API/V1/Article/GetCommentsControllerTest.php new file mode 100644 index 0000000..c2bd775 --- /dev/null +++ b/tests/Feature/API/V1/Article/GetCommentsControllerTest.php @@ -0,0 +1,124 @@ +create(); + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $topComments = Comment::factory() + ->count(5) + ->for($article) + ->for($user) + ->create(); + + $topComments->each(fn ($parent) => Comment::factory() + ->count(2) + ->for($article) + ->for($user) + ->reply($parent->id) + ->create() + ); + + $response = $this->getJson(route('api.v1.articles.comments.index', [ + 'article' => $article->slug, + 'per_page' => 3, + 'page' => 1, + ])); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'comments' => [ + '*' => [ + 'id', + 'user' => ['id', 'name', 'email'], + 'content', + 'created_at', + 'replies_count', + 'replies' => [ + '*' => [ + 'id', + 'user' => ['id', 'name', 'email'], + 'content', + 'created_at', + 'replies_count', + ], + ], + ], + ], + 'meta' => [ + 'current_page', + 'per_page', + 'total', + // Optional keys if MetaResource includes them + // 'last_page', 'from', 'to', + ], + ], + ]); + + expect($response->json('data.comments'))->toHaveCount(3); + expect($response->json('data.meta.total'))->toBe(5); + }); + + it('returns empty comment list if article has no comments', function () { + $user = User::factory()->create(); + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $response = $this->getJson(route('api.v1.articles.comments.index', [ + 'article' => $article->slug, + ])); + + $response->assertStatus(200) + ->assertJson(fn (AssertableJson $json) => $json->where('status', true) + ->where('message', __('common.success')) + ->where('data.comments', []) + ->where('data.meta.current_page', 1) + ->where('data.meta.per_page', 10) + ->where('data.meta.total', 0) + ->etc() + ); + }); + + it('returns 500 on service exception', function () { + $user = User::factory()->create(); + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $this->mock(\App\Services\ArticleService::class, function ($mock) { + $mock->shouldReceive('getArticleComments') + ->andThrow(new \Exception('Test Exception')); + }); + + $response = $this->getJson(route('api.v1.articles.comments.index', [ + 'article' => $article->slug, + ])); + + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + }); +});