From 8ea86b8ef2766fd45cea9c4c0112e38fc112948f Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Sun, 20 Jul 2025 00:49:41 +0500 Subject: [PATCH 1/7] fix: fix user resource for undefined key access_token --- app/Http/Resources/V1/Auth/UserResource.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/Http/Resources/V1/Auth/UserResource.php b/app/Http/Resources/V1/Auth/UserResource.php index 5bcdfc9..281f713 100644 --- a/app/Http/Resources/V1/Auth/UserResource.php +++ b/app/Http/Resources/V1/Auth/UserResource.php @@ -49,13 +49,16 @@ public function toArray(Request $request): array return array_values(array_unique($permissionSlugs)); }), - $this->mergeWhen(isset($this->resource->access_token), [ - 'access_token' => $this->resource->access_token, - 'refresh_token' => $this->resource->refresh_token, - 'access_token_expires_at' => $this->resource->access_token_expires_at?->toISOString(), - 'refresh_token_expires_at' => $this->resource->refresh_token_expires_at?->toISOString(), - 'token_type' => 'Bearer', - ]), + $this->mergeWhen( + array_key_exists('access_token', $this->resource->getAttributes()), + fn () => [ + 'access_token' => $this->resource->getAttributes()['access_token'], + 'refresh_token' => $this->resource->getAttributes()['refresh_token'] ?? null, + 'access_token_expires_at' => optional($this->resource->getAttributes()['access_token_expires_at'] ?? null)?->toISOString(), + 'refresh_token_expires_at' => optional($this->resource->getAttributes()['refresh_token_expires_at'] ?? null)?->toISOString(), + 'token_type' => 'Bearer', + ] + ), ]; } } From 29dc842af648c5dbd7c18467ecf9ec12f5d6ab96 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Sun, 20 Jul 2025 00:51:37 +0500 Subject: [PATCH 2/7] feat: added Me controller for get user profile API --- .../Controllers/Api/V1/User/MeController.php | 36 +++++++++++++++++++ lang/en/common.php | 1 + routes/api_v1.php | 4 +-- 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/User/MeController.php diff --git a/app/Http/Controllers/Api/V1/User/MeController.php b/app/Http/Controllers/Api/V1/User/MeController.php new file mode 100644 index 0000000..0cb8f6c --- /dev/null +++ b/app/Http/Controllers/Api/V1/User/MeController.php @@ -0,0 +1,36 @@ +user(); + $user->load(['roles.permissions']); + + return response()->apiSuccess( + new \App\Http\Resources\V1\Auth\UserResource($user), + __('common.success') + ); + } +} diff --git a/lang/en/common.php b/lang/en/common.php index cf59e63..0a67c9d 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -11,4 +11,5 @@ */ 'something_went_wrong' => 'Something went wrong! Try again later.', + 'success' => 'Response returned successfully.', ]; diff --git a/routes/api_v1.php b/routes/api_v1.php index 2dbf567..f105c64 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -18,8 +18,6 @@ // User Routes Route::middleware(['auth:sanctum', 'ability:access-api'])->group(function () { - Route::get('/me', function (Request $request) { - return auth()->user(); - }); + Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class); }); }); From c8549c9106f3a5d54d34e7fba8b9b6a8c47b9966 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Sun, 20 Jul 2025 00:59:26 +0500 Subject: [PATCH 3/7] feat: add test coverage for me controller --- .../Controllers/Api/V1/User/MeController.php | 2 + .../Feature/API/V1/User/MeControllerTest.php | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/Feature/API/V1/User/MeControllerTest.php diff --git a/app/Http/Controllers/Api/V1/User/MeController.php b/app/Http/Controllers/Api/V1/User/MeController.php index 0cb8f6c..98d9aeb 100644 --- a/app/Http/Controllers/Api/V1/User/MeController.php +++ b/app/Http/Controllers/Api/V1/User/MeController.php @@ -1,5 +1,7 @@ create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response structure + $response + ->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'email_verified_at', + 'bio', + 'avatar_url', + 'twitter', + 'facebook', + 'linkedin', + 'github', + 'website', + ], + ]) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + ]); + }); +}); From 8fbb984d89c3780942b1eebe90d68737ed909ff3 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 23 Jul 2025 01:43:52 +0500 Subject: [PATCH 4/7] feat(article): update models to have respective relationships --- app/Models/Article.php | 25 +++++++++++++++++++++++++ app/Models/Category.php | 8 ++++---- app/Models/Tag.php | 9 +++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index 0a462dc..cf2ad3d 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; /** @@ -81,4 +82,28 @@ public function comments(): HasMany { return $this->hasMany(Comment::class); } + + /** + * @return BelongsToMany + */ + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'article_categories'); + } + + /** + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'article_tags'); + } + + /** + * @return BelongsToMany + */ + public function authors(): BelongsToMany + { + return $this->belongsToMany(User::class, 'article_authors')->withPivot('role'); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php index db4a132..b3009c6 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * @property int $id @@ -34,10 +34,10 @@ protected function casts(): array } /** - * @return HasMany + * @return BelongsToMany */ - public function articles(): HasMany + public function articles(): BelongsToMany { - return $this->hasMany(ArticleCategory::class); + return $this->belongsToMany(Article::class, 'article_categories'); } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index f896d68..7f72751 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * @property int $id @@ -33,4 +34,12 @@ protected function casts(): array { return []; } + + /** + * @return BelongsToMany + */ + public function articles(): BelongsToMany + { + return $this->belongsToMany(Article::class, 'article_tags'); + } } From 3d8ea965a96fa36993384519693c7dfe21ccf797 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 23 Jul 2025 01:46:28 +0500 Subject: [PATCH 5/7] feat(article): added Get All and Show Article APIs --- .../Api/V1/Article/GetArticlesController.php | 71 ++++++++++ .../Api/V1/Article/ShowArticleController.php | 69 ++++++++++ .../Api/V1/Article/GetArticlesRequest.php | 61 +++++++++ .../Api/V1/Article/ArticleResource.php | 97 ++++++++++++++ app/Services/ArticleService.php | 126 ++++++++++++++++++ lang/en/common.php | 1 + routes/api_v1.php | 12 +- 7 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Article/GetArticlesController.php create mode 100644 app/Http/Controllers/Api/V1/Article/ShowArticleController.php create mode 100644 app/Http/Requests/Api/V1/Article/GetArticlesRequest.php create mode 100644 app/Http/Resources/Api/V1/Article/ArticleResource.php create mode 100644 app/Services/ArticleService.php diff --git a/app/Http/Controllers/Api/V1/Article/GetArticlesController.php b/app/Http/Controllers/Api/V1/Article/GetArticlesController.php new file mode 100644 index 0000000..562627d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Article/GetArticlesController.php @@ -0,0 +1,71 @@ +withDefaults(); + + $articles = $this->articleService->getArticles($params); + + $articleCollection = ArticleResource::collection($articles); + + /** + * Successful articles retrieval + */ + $articleCollectionData = $articleCollection->response()->getData(true); + + // Ensure we have the expected array structure + if (! is_array($articleCollectionData) || ! isset($articleCollectionData['data'], $articleCollectionData['meta'])) { + throw new \RuntimeException('Unexpected response format from ArticleResource collection'); + } + + return response()->apiSuccess( + [ + 'articles' => $articleCollectionData['data'], + 'meta' => $articleCollectionData['meta'], + ], + __('common.success') + ); + } catch (\Throwable $e) { + /** + * Internal server error + * + * @status 500 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Article/ShowArticleController.php b/app/Http/Controllers/Api/V1/Article/ShowArticleController.php new file mode 100644 index 0000000..8aa9498 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Article/ShowArticleController.php @@ -0,0 +1,69 @@ +articleService->getArticleBySlug($slug); + + /** + * Successful article retrieval + */ + return response()->apiSuccess( + new ArticleResource($article), + __('common.success') + ); + } catch (ModelNotFoundException $e) { + /** + * Article not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.not_found'), + Response::HTTP_NOT_FOUND + ); + } catch (\Throwable $e) { + /** + * Internal server error + * + * @status 500 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Requests/Api/V1/Article/GetArticlesRequest.php b/app/Http/Requests/Api/V1/Article/GetArticlesRequest.php new file mode 100644 index 0000000..5744691 --- /dev/null +++ b/app/Http/Requests/Api/V1/Article/GetArticlesRequest.php @@ -0,0 +1,61 @@ +|string> + */ + public function rules(): array + { + return [ + 'page' => ['integer', 'min:1'], + 'per_page' => ['integer', 'min:1', 'max:100'], + 'search' => ['string', 'max:255'], + 'status' => [Rule::enum(ArticleStatus::class)], + 'category_slug' => ['string'], + 'category_slug.*' => ['string', 'exists:categories,slug'], + 'tag_slug' => ['string'], + 'tag_slug.*' => ['string', 'exists:tags,slug'], + 'author_id' => ['integer', 'exists:users,id'], + 'created_by' => ['integer', 'exists:users,id'], + 'published_after' => ['date'], + 'published_before' => ['date'], + 'sort_by' => [Rule::in(['title', 'published_at', 'created_at', 'updated_at'])], + 'sort_direction' => [Rule::in(['asc', 'desc'])], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'page' => 1, + 'per_page' => 15, + 'sort_by' => 'published_at', + 'sort_direction' => 'desc', + 'status' => ArticleStatus::PUBLISHED->value, + ], $this->validated()); + } +} diff --git a/app/Http/Resources/Api/V1/Article/ArticleResource.php b/app/Http/Resources/Api/V1/Article/ArticleResource.php new file mode 100644 index 0000000..c746ffe --- /dev/null +++ b/app/Http/Resources/Api/V1/Article/ArticleResource.php @@ -0,0 +1,97 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'excerpt' => $this->excerpt, + 'content_html' => $this->content_html, + 'content_markdown' => $this->content_markdown, + 'featured_image' => $this->featured_image, + 'status' => $this->status, + 'published_at' => $this->published_at?->toISOString(), + 'meta_title' => $this->meta_title, + 'meta_description' => $this->meta_description, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + + // Relationships + 'author' => $this->whenLoaded('author', function () { + return $this->author ? [ + 'id' => $this->author->id, + 'name' => $this->author->name, + 'email' => $this->author->email, + 'avatar_url' => $this->author->avatar_url, + 'bio' => $this->author->bio, + ] : null; + }), + + 'categories' => $this->whenLoaded('categories', function () { + /** @var \Illuminate\Database\Eloquent\Collection $categories */ + $categories = $this->categories; + + return $categories->map(function ($category) { + return [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + ]; + })->values()->all(); + }), + + 'tags' => $this->whenLoaded('tags', function () { + /** @var \Illuminate\Database\Eloquent\Collection $tags */ + $tags = $this->tags; + + return $tags->map(function ($tag) { + return [ + 'id' => $tag->id, + 'name' => $tag->name, + 'slug' => $tag->slug, + ]; + })->values()->all(); + }), + + 'authors' => $this->whenLoaded('authors', function () { + /** @var \Illuminate\Database\Eloquent\Collection $authors */ + $authors = $this->authors; + + return $authors->map(function ($author) { + /** @var \Illuminate\Database\Eloquent\Relations\Pivot|null $pivot */ + $pivot = $author->getAttribute('pivot'); + + return [ + 'id' => $author->id, + 'name' => $author->name, + 'email' => $author->email, + 'avatar_url' => $author->avatar_url, + 'bio' => $author->bio, + 'role' => $pivot?->getAttribute('role'), + ]; + })->values()->all(); + }), + + 'comments_count' => $this->whenCounted('comments'), + ]; + } +} diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php new file mode 100644 index 0000000..4454e8a --- /dev/null +++ b/app/Services/ArticleService.php @@ -0,0 +1,126 @@ + $params + * @return LengthAwarePaginator + */ + public function getArticles(array $params): LengthAwarePaginator + { + $query = Article::query() + ->with(['author:id,name,email,avatar_url,bio', 'categories:id,name,slug', 'tags:id,name,slug']) + ->withCount('comments'); + + // Apply filters + $this->applyFilters($query, $params); + + // Apply sorting + $query->orderBy($params['sort_by'], $params['sort_direction']); + + // Apply pagination + return $query->paginate($params['per_page'], ['*'], 'page', $params['page']); + } + + /** + * Get a single article by slug + */ + public function getArticleBySlug(string $slug): Article + { + return Article::query() + ->with([ + 'author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', + 'categories:id,name,slug', + 'tags:id,name,slug', + 'authors:id,name,email,avatar_url,bio', + ]) + ->withCount('comments') + ->where('slug', $slug) + ->firstOrFail(); + } + + /** + * Apply filters to the query + * + * @param Builder
$query + * @param array $params + */ + private function applyFilters(Builder $query, array $params): void + { + // Search in title, subtitle, excerpt, and content + if (! empty($params['search'])) { + $searchTerm = (string) $params['search']; + $query->where(function (Builder $q) use ($searchTerm) { + $q->where('title', 'like', "%{$searchTerm}%") + ->orWhere('subtitle', 'like', "%{$searchTerm}%") + ->orWhere('excerpt', 'like', "%{$searchTerm}%") + ->orWhere('content_markdown', 'like', "%{$searchTerm}%"); + }); + } + + // Filter by status + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // Filter by categories (support multiple categories) + if (! empty($params['category_slug'])) { + $categorySlugs = is_array($params['category_slug']) + ? $params['category_slug'] + : [$params['category_slug']]; + + $query->whereHas('categories', function (Builder $q) use ($categorySlugs) { + $q->whereIn('slug', $categorySlugs); + }); + } + + // Filter by tags (support multiple tags) + if (! empty($params['tag_slug'])) { + $tagSlugs = is_array($params['tag_slug']) + ? $params['tag_slug'] + : [$params['tag_slug']]; + + $query->whereHas('tags', function (Builder $q) use ($tagSlugs) { + $q->whereIn('slug', $tagSlugs); + }); + } + + // Filter by author (from article_authors table) + if (! empty($params['author_id'])) { + $query->whereHas('authors', function (Builder $q) use ($params) { + $q->where('user_id', $params['author_id']); + }); + } + + // Filter by creator + if (! empty($params['created_by'])) { + $query->where('created_by', $params['created_by']); + } + + // Filter by publication date range + if (! empty($params['published_after'])) { + $query->where('published_at', '>=', $params['published_after']); + } + + if (! empty($params['published_before'])) { + $query->where('published_at', '<=', $params['published_before']); + } + + // Only include published articles for public access (unless specifically querying other statuses) + if (empty($params['status'])) { + $query->where('status', 'published') + ->whereNotNull('published_at') + ->where('published_at', '<=', now()); + } + } +} diff --git a/lang/en/common.php b/lang/en/common.php index 0a67c9d..df682c0 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -12,4 +12,5 @@ 'something_went_wrong' => 'Something went wrong! Try again later.', 'success' => 'Response returned successfully.', + 'not_found' => 'Resource not found.', ]; diff --git a/routes/api_v1.php b/routes/api_v1.php index f105c64..65d432a 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -8,16 +8,20 @@ return 'Laravel Blog API V1 Root is working'; })->name('api.v1.status'); - // Auth + // Auth Routes Route::post('/auth/login', \App\Http\Controllers\Api\V1\Auth\LoginController::class)->name('api.v1.auth.login'); Route::post('/auth/refresh', \App\Http\Controllers\Api\V1\Auth\RefreshTokenController::class)->name('api.v1.auth.refresh'); + // User Routes Route::middleware(['auth:sanctum', 'ability:access-api'])->group(function () { + Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class); + Route::post('/auth/logout', \App\Http\Controllers\Api\V1\Auth\LogoutController::class)->name('api.v1.auth.logout'); }); - // User Routes - Route::middleware(['auth:sanctum', 'ability:access-api'])->group(function () { - Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class); + // Article Routes (Public) + 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'); }); }); From e743a8bc91cd3dd7a2a9c15ec188d12bd84b5ed1 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 23 Jul 2025 01:48:48 +0500 Subject: [PATCH 6/7] test(article): added tests coverage for article APIs --- .../V1/Article/GetArticlesControllerTest.php | 235 ++++++++++++++++++ .../V1/Article/ShowArticleControllerTest.php | 106 ++++++++ 2 files changed, 341 insertions(+) create mode 100644 tests/Feature/API/V1/Article/GetArticlesControllerTest.php create mode 100644 tests/Feature/API/V1/Article/ShowArticleControllerTest.php diff --git a/tests/Feature/API/V1/Article/GetArticlesControllerTest.php b/tests/Feature/API/V1/Article/GetArticlesControllerTest.php new file mode 100644 index 0000000..4510d1d --- /dev/null +++ b/tests/Feature/API/V1/Article/GetArticlesControllerTest.php @@ -0,0 +1,235 @@ +create(); + $articles = Article::factory() + ->count(25) + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $response = $this->getJson('/api/v1/articles'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'articles' => [ + '*' => [ + 'id', + 'slug', + 'title', + 'subtitle', + 'excerpt', + 'featured_image', + 'status', + 'published_at', + 'created_at', + 'updated_at', + 'author' => [ + 'id', + 'name', + 'avatar_url', + ], + 'categories', + 'tags', + 'comments_count', + ], + ], + 'meta', + ], + ]); + + // Should return 15 articles per page by default + expect($response->json('data.articles'))->toHaveCount(15); + }); + + it('can filter articles by category', function () { + $user = User::factory()->create(); + $category = Category::factory()->create(); + + // Create article with category + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $article->categories()->attach($category->id); + + // Create article without category + Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $response = $this->getJson("/api/v1/articles?category_slug={$category->slug}"); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(1); + expect($response->json('data.articles.0.id'))->toBe($article->id); + }); + + it('can filter articles by tag', function () { + $user = User::factory()->create(); + $tag = Tag::factory()->create(); + + // Create article with tag + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $article->tags()->attach($tag->id); + + // Create article without tag + Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $response = $this->getJson("/api/v1/articles?tag_slug={$tag->slug}"); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(1); + expect($response->json('data.articles.0.id'))->toBe($article->id); + }); + + it('can search articles', function () { + $user = User::factory()->create(); + + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(['title' => 'Laravel Testing Guide']); + + Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(['title' => 'PHP Best Practices']); + + $response = $this->getJson('/api/v1/articles?search=Laravel'); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(1); + expect($response->json('data.articles.0.id'))->toBe($article->id); + }); + + it('can filter articles by author', function () { + $author1 = User::factory()->create(); + $author2 = User::factory()->create(); + + $article1 = Article::factory() + ->for($author1, 'author') + ->for($author1, 'approver') + ->published() + ->create(); + + Article::factory() + ->for($author2, 'author') + ->for($author2, 'approver') + ->published() + ->create(); + + $response = $this->getJson("/api/v1/articles?created_by={$author1->id}"); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(1); + expect($response->json('data.articles.0.id'))->toBe($article1->id); + }); + + it('can filter articles by status', function () { + $user = User::factory()->create(); + + $publishedArticle = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->draft() + ->create(); + + $response = $this->getJson('/api/v1/articles?status=published'); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(1); + expect($response->json('data.articles.0.id'))->toBe($publishedArticle->id); + }); + + it('can customize pagination', function () { + $user = User::factory()->create(); + Article::factory() + ->count(30) + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $response = $this->getJson('/api/v1/articles?per_page=5&page=2'); + + $response->assertStatus(200); + expect($response->json('data.articles'))->toHaveCount(5); + expect($response->json('data.meta.current_page'))->toBe(2); + expect($response->json('data.meta.per_page'))->toBe(5); + }); + + it('can sort articles', function () { + $user = User::factory()->create(); + + $article1 = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(['title' => 'A Article']); + + $article2 = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(['title' => 'Z Article']); + + $response = $this->getJson('/api/v1/articles?sort_by=title&sort_direction=asc'); + + $response->assertStatus(200); + expect($response->json('data.articles.0.id'))->toBe($article1->id); + expect($response->json('data.articles.1.id'))->toBe($article2->id); + }); + + it('returns 500 when getting articles fails with exception', function () { + // Mock ArticleService to throw an exception + $this->mock(\App\Services\ArticleService::class, function ($mock) { + $mock->shouldReceive('getArticles') + ->andThrow(new \Exception('Database connection failed')); + }); + + $response = $this->getJson('/api/v1/articles'); + + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + }); +}); diff --git a/tests/Feature/API/V1/Article/ShowArticleControllerTest.php b/tests/Feature/API/V1/Article/ShowArticleControllerTest.php new file mode 100644 index 0000000..1d35f2f --- /dev/null +++ b/tests/Feature/API/V1/Article/ShowArticleControllerTest.php @@ -0,0 +1,106 @@ +create(); + $category = Category::factory()->create(); + $tag = Tag::factory()->create(); + + $article = Article::factory() + ->for($user, 'author') + ->for($user, 'approver') + ->published() + ->create(); + + $article->categories()->attach($category->id); + $article->tags()->attach($tag->id); + + $response = $this->getJson("/api/v1/articles/{$article->slug}"); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'slug', + 'title', + 'subtitle', + 'excerpt', + 'content_html', + 'content_markdown', + 'featured_image', + 'status', + 'published_at', + 'meta_title', + 'meta_description', + 'created_at', + 'updated_at', + 'author' => [ + 'id', + 'name', + 'email', + 'avatar_url', + 'bio', + ], + 'categories' => [ + '*' => [ + 'id', + 'name', + 'slug', + ], + ], + 'tags' => [ + '*' => [ + 'id', + 'name', + 'slug', + ], + ], + 'authors', + 'comments_count', + ], + ]); + + expect($response->json('data.id'))->toBe($article->id); + expect($response->json('data.slug'))->toBe($article->slug); + }); + + it('returns 404 when article not found by slug', function () { + $response = $this->getJson('/api/v1/articles/non-existent-slug'); + + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('returns 500 when showing article fails with exception', function () { + // Mock ArticleService to throw an exception + $this->mock(\App\Services\ArticleService::class, function ($mock) { + $mock->shouldReceive('getArticleBySlug') + ->with('test-slug') + ->andThrow(new \Exception('Database connection failed')); + }); + + $response = $this->getJson('/api/v1/articles/test-slug'); + + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + }); +}); From 644b63a0993e3b406de5c64c56682e60910c0cb6 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 23 Jul 2025 02:09:30 +0500 Subject: [PATCH 7/7] feat(article): add all author related fields --- app/Http/Resources/Api/V1/Article/ArticleResource.php | 10 ++++++++++ app/Services/ArticleService.php | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/Http/Resources/Api/V1/Article/ArticleResource.php b/app/Http/Resources/Api/V1/Article/ArticleResource.php index c746ffe..bd416b2 100644 --- a/app/Http/Resources/Api/V1/Article/ArticleResource.php +++ b/app/Http/Resources/Api/V1/Article/ArticleResource.php @@ -43,6 +43,11 @@ public function toArray(Request $request): array 'email' => $this->author->email, 'avatar_url' => $this->author->avatar_url, 'bio' => $this->author->bio, + 'twitter' => $this->author->twitter, + 'facebook' => $this->author->facebook, + 'linkedin' => $this->author->linkedin, + 'github' => $this->author->github, + 'website' => $this->author->website, ] : null; }), @@ -86,6 +91,11 @@ public function toArray(Request $request): array 'email' => $author->email, 'avatar_url' => $author->avatar_url, 'bio' => $author->bio, + 'twitter' => $author->twitter, + 'facebook' => $author->facebook, + 'linkedin' => $author->linkedin, + 'github' => $author->github, + 'website' => $author->website, 'role' => $pivot?->getAttribute('role'), ]; })->values()->all(); diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 4454e8a..b45d7b4 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -19,7 +19,7 @@ class ArticleService public function getArticles(array $params): LengthAwarePaginator { $query = Article::query() - ->with(['author:id,name,email,avatar_url,bio', 'categories:id,name,slug', 'tags:id,name,slug']) + ->with(['author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', 'categories:id,name,slug', 'tags:id,name,slug']) ->withCount('comments'); // Apply filters @@ -42,7 +42,7 @@ public function getArticleBySlug(string $slug): Article 'author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', 'categories:id,name,slug', 'tags:id,name,slug', - 'authors:id,name,email,avatar_url,bio', + 'authors:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', ]) ->withCount('comments') ->where('slug', $slug)