From 008ea11db7015bfc17939624f8cb7357480d4765 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Sun, 3 Aug 2025 23:51:22 +0500 Subject: [PATCH 1/5] build(cursor): added rule for cursor --- .cursor/rules/laravel-blog-api-rules.mdc | 899 +++++++++++++++++++++++ 1 file changed, 899 insertions(+) create mode 100644 .cursor/rules/laravel-blog-api-rules.mdc diff --git a/.cursor/rules/laravel-blog-api-rules.mdc b/.cursor/rules/laravel-blog-api-rules.mdc new file mode 100644 index 0000000..b945cc1 --- /dev/null +++ b/.cursor/rules/laravel-blog-api-rules.mdc @@ -0,0 +1,899 @@ +--- +description: Cursor Rules for Laravel 12 Blog API Project for writing codes +globs: +alwaysApply: true +--- + +# Cursor Rules for Laravel 12 Blog API Project + +## Project Overview + +This is a modern Laravel 12 Blog API built with PHP 8.4, featuring clean architecture, comprehensive testing, Docker-based development environment, and advanced code quality tools. The API serves as a production-ready backend for a blog platform with authentication, role-based permissions, content management, and automated quality assurance. + +## Technology Stack + +- **Laravel Framework**: 12.0+ (latest features) +- **PHP**: 8.2+ (with strict typing enabled, targeting 8.4+ features) +- **Database**: MySQL 8.0 (development) + MySQL 8.0 (testing - isolated environment) +- **Cache/Session**: Redis (development) + Redis (testing - isolated environment) +- **Authentication**: Laravel Sanctum (API tokens with abilities) +- **Testing**: Pest PHP 3.8+ (BDD-style testing framework) +- **Static Analysis**: Larastan 3.0+ (PHPStan level 10 for Laravel) +- **Code Formatting**: Laravel Pint (PHP-CS-Fixer preset) +- **API Documentation**: Scramble (OpenAPI/Swagger automatic generation) +- **Containerization**: Docker & Docker Compose (multi-service architecture) +- **Quality Analysis**: SonarQube integration (optional) +- **Git Tools**: Husky hooks, semantic commits, automated validation + +## PHP Coding Standards (MANDATORY) + +### File Structure Requirements + +- **ALWAYS** use `declare(strict_types=1);` at the top of all PHP files after the `)** for safe method/property access +- Use **Named Arguments** for clarity when calling functions with multiple parameters +- Prefer **final classes** for utility or domain-specific classes that shouldn't be extended +- Adopt **new `Override` attribute** (PHP 8.4) to explicitly mark overridden methods +- Use **dynamic class constants in Enums** where version-specific behavior is needed + +### PHP 8.4 Override Attribute Example + +```php + 'datetime', + 'updated_at' => 'datetime', + ]; + } +} +``` + +## Laravel 12 Project Structure & Conventions + +### Directory Structure + +``` +app/ +├── Actions/ # Single-responsibility action classes (create as needed) +├── Console/ # Artisan commands (create as needed) +├── Data/ # Data Transfer Objects (DTOs) (create as needed) +├── Enums/ # Enums for type-safe constants ✅ +├── Events/ # Domain events (create as needed) +├── Exceptions/ # Custom exceptions (create as needed) +├── Http/ +│ ├── Controllers/ # Thin controllers ✅ +│ ├── Middleware/ # HTTP middleware ✅ +│ ├── Requests/ # Form Request validation ✅ +│ ├── Resources/ # API Resource responses ✅ +├── Jobs/ # Queued jobs (create as needed) +├── Listeners/ # Event listeners (create as needed) +├── Models/ # Eloquent models ✅ +├── Policies/ # Authorization policies ✅ +├── Providers/ # Service providers ✅ +├── Services/ # Business logic ✅ +├── Support/ # Helpers & utility classes (create as needed) +└── Rules/ # Custom validation rules (create as needed) +``` + +### Domain Models + +The application follows a blog-centric domain model with the following entities: + +#### Core Entities + +- **User**: Blog users with role-based permissions +- **Article**: Blog posts with status management +- **Category**: Hierarchical content organization +- **Tag**: Flexible content labeling +- **Comment**: User interactions on articles + +#### Supporting Entities + +- **Role**: User access levels (Administrator, Editor, Author, Contributor, Subscriber) +- **Permission**: Granular access control +- **Notification**: System-wide messaging +- **NewsletterSubscriber**: Email subscription management + +### Enums (PHP 8.1+ Features) + +All status and type fields use PHP enums for type safety: + +- `UserRole`: User permission levels +- `ArticleStatus`: Article publication states +- `ArticleAuthorRole`: Multi-author support +- `NotificationType`: System notification types + +## PHPStan Level 10 Compliance (CRITICAL) + +### Type Safety Requirements + +- **ALL** properties must have explicit type declarations +- **ALL** method parameters must have explicit type declarations +- **ALL** method return types must be explicitly declared +- **ALL** variables must have explicit type declarations where possible +- Use **Union Types** and **Intersection Types** appropriately +- Use **Generic Types** for collections and arrays +- Use **Template Types** for complex generic scenarios + +### PHPDoc Requirements + +- **ALL** classes must have comprehensive PHPDoc with `@property` annotations for all properties +- **ALL** methods must have PHPDoc with `@param` and `@return` annotations +- Use **@template** annotations for generic classes +- Use **@extends** and **@implements** annotations for inheritance +- Use **@mixin** annotations for traits and mixins +- Use **@var** annotations for complex variable types +- Use **@phpstan-*** annotations for PHPStan-specific directives + +### Example PHPStan Level 10 Compliant Code + +```php + $categories + * @mixin \Eloquent + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory + */ +final class Article extends Model +{ + use HasFactory; + + protected $guarded = []; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Get the user that owns the article. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class); + + return $relation; + } + + /** + * Get the categories for the article. + * + * @return BelongsToMany + */ + public function categories(): BelongsToMany + { + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Category::class); + + return $relation; + } +} +``` + +## Controller Guidelines (MANDATORY) + +### Single Action Controllers + +Controllers must: + +- Remain **thin**; business logic belongs in Services or Actions +- Use **dependency injection** with readonly properties +- Use **Form Requests** for validation +- Return **typed responses**, e.g., `JsonResponse` +- Use **Resource classes** for API responses +- Be **final classes** for immutability +- Use **invokable pattern** with `__invoke()` method + +### Controller Template + +```php +yourService->doSomething($request->withDefaults()); + + return response()->apiSuccess( + new YourResource($result), + __('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 + ); + } + } +} +``` + +## Request Validation Guidelines + +### Form Request Template + +```php +|string> + */ + public function rules(): array + { + return [ + 'field1' => ['required', 'string', 'max:255'], + 'field2' => ['required', 'integer', 'min:1'], + 'enum_field' => [Rule::enum(YourEnum::class)], + 'array_field' => ['array'], + 'array_field.*' => ['string', 'exists:table,column'], + 'date_field' => ['date'], + 'email_field' => ['email', 'max:255'], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'default_field' => 'default_value', + 'page' => 1, + 'per_page' => 15, + ], $this->validated()); + } +} +``` + +## Service Layer Guidelines + +### Service Template + +```php + $data + * @return YourModel + */ + public function doSomething(array $data): YourModel + { + // Business logic here + // Database operations + // Return result + } + + /** + * Get a collection of items + * + * @param array $params + * @return Collection + */ + public function getItems(array $params): Collection + { + // Implementation + } +} +``` + +## Model Guidelines + +### Model Template + +```php + $roles + * @property-read \Illuminate\Database\Eloquent\Collection $articles + * @mixin \Eloquent + * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory + */ +final class User extends Authenticatable +{ + use HasApiTokens, HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + /** + * Get the roles that belong to the user. + * + * @return BelongsToMany + */ + public function roles(): BelongsToMany + { + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Role::class); + + return $relation; + } + + /** + * Get the articles for the user. + * + * @return HasMany + */ + public function articles(): HasMany + { + /** @var HasMany $relation */ + $relation = $this->hasMany(Article::class); + + return $relation; + } +} +``` + +## Resource Guidelines + +### Resource Template + +```php + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'related_data' => $this->whenLoaded('relation', function () { + return new RelatedResource($this->resource->relation); + }), + $this->mergeWhen( + array_key_exists('access_token', $this->resource->getAttributes()), + fn () => [ + 'access_token' => $this->resource->getAttributes()['access_token'], + 'token_type' => 'Bearer', + ] + ), + ]; + } +} +``` + +## Enum Guidelines + +### Enum Template + +```php + 'Administrator', + self::EDITOR => 'Editor', + self::AUTHOR => 'Author', + self::CONTRIBUTOR => 'Contributor', + self::SUBSCRIBER => 'Subscriber', + }; + } +} +``` + +## Testing Guidelines + +### Pest Test Template + +```php +create(); + $article = Article::factory()->for($user, 'author')->create(); + + // Act + $response = $this->getJson(route('api.v1.your.endpoint')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + // ... other fields + ], + ]); + }); + + it('returns 500 when operation fails with exception', function () { + // Mock service to throw exception + $this->mock(\App\Services\YourService::class, function ($mock) { + $mock->shouldReceive('doSomething') + ->andThrow(new \Exception('Operation failed')); + }); + + $response = $this->getJson(route('api.v1.your.endpoint')); + + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + }); +}); +``` + +## Development Workflow Commands + +### Always Use Make Commands + +```bash +# Setup +make local-setup # Complete development environment setup +make sonarqube-setup # Optional SonarQube setup + +# Development +make commit # Interactive semantic commits +make test # Run test suite +make test-coverage # Run tests with coverage (80% required) +make lint # Code formatting with Pint +make analyze # Static analysis with PHPStan level 10 + +# Container management +make docker-up # Start containers +make docker-down # Stop containers +make status # Check container status +make health # Check application health +make shell # Access container shell +``` + +## Error Handling Guidelines + +### Exception Handling Pattern + +All controllers should follow this error handling pattern: + +```php +try { + $result = $this->yourService->doSomething($request->withDefaults()); + + return response()->apiSuccess( + new YourResource($result), + __('common.success') + ); +} catch (\Throwable $e) { + // Log the error for debugging + \Log::error('Operation failed', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ]); + + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); +} +``` + +### Custom Exception Classes + +Create custom exceptions for domain-specific errors: + +```php +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +**Valid types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `ci`, `build`, `revert` + +**Examples:** + +```bash +feat(api): add user registration endpoint +fix(auth): resolve token validation issue +docs: update API documentation +test(api): add integration tests for auth +``` + +## Environment Configuration + +### Access Points + +- **Main API**: +- **Health Check**: +- **API Documentation**: +- **MySQL Development**: localhost:3306 (laravel_user/laravel_password) +- **Redis**: localhost:6379 +- **MySQL Test**: localhost:3307 (separate test database) +- **Redis Test**: localhost:6380 (separate test cache) +- **SonarQube Dashboard**: (when started) + +### Current Project Status + +- **PHP Version**: 8.2+ (composer.json requirement) +- **Laravel Version**: 12.0+ (latest) +- **PHPStan Level**: 10 (configured in phpstan.neon) +- **Testing Framework**: Pest PHP 3.8+ +- **Code Quality**: Laravel Pint + PHPStan + SonarQube +- **Containerization**: Docker Compose with isolated test environment +- **Git Workflow**: Semantic commits with Husky hooks + +### Project-Specific Features + +- **Multi-author Articles**: Support for multiple authors per article +- **Role-based Access Control**: 5 user roles with granular permissions +- **Article Status Management**: Draft, Review, Published, Archived states +- **Comment System**: User interactions on articles +- **Newsletter Integration**: Email subscription management +- **Notification System**: System-wide messaging with audience targeting +- **API Versioning**: All endpoints under `/api/v1/` +- **Comprehensive Testing**: 80%+ coverage requirement + +## Troubleshooting + +### Common Commands + +```bash +make help # Show all available commands +make health # Check application health +make logs # View container logs +make docker-cleanup # Clean up everything +``` + +### Quality Assurance + +- Always run `make test` before committing +- Always run `make lint` to ensure code formatting +- Always run `make analyze` for static analysis +- Use `make commit` for guided semantic commits +- Maintain 80%+ test coverage at all times + +## IMPORTANT REMINDERS + +1. **ALWAYS** use `declare(strict_types=1);` at the top of every PHP file +2. **ALWAYS** use **final classes** for immutability +3. **ALWAYS** use **strict typing** for all properties and methods +4. **ALWAYS** use **comprehensive PHPDoc** annotations +5. **ALWAYS** use **service layer** for business logic +6. **ALWAYS** use **Form Requests** for validation +7. **ALWAYS** use **API Resources** for responses +8. **ALWAYS** use **Enums** for type-safe constants +9. **ALWAYS** use **Make commands** for all operations +10. **ALWAYS** maintain **PHPStan level 10** compliance +11. **ALWAYS** maintain **80% test coverage** +12. **ALWAYS** use **semantic commits** with `make commit` + +## Project-Specific Patterns + +### Response Format + +All API responses must follow this structure: + +```json +{ + "status": true, + "message": "Success message", + "data": { + // Response data + } +} +``` + +### Error Response Format + +```json +{ + "status": false, + "message": "Error message", + "data": null, + "error": null +} +``` + +### API Versioning + +- All routes must be prefixed with `/api/v1/` +- Use proper HTTP status codes (200, 201, 204, 400, 422, 500, etc.) +- Use RESTful conventions with proper HTTP methods + +### Database Relationships + +- Use proper **foreign key constraints** +- Implement **soft deletes** where appropriate +- Use **pivot tables** for many-to-many relationships +- Apply **database indexes** for frequently queried fields + +This project follows **modern Laravel 12 best practices** with a focus on **type safety**, **clean architecture**, and **comprehensive quality assurance**. All code must adhere to these standards for consistency and maintainability. From 43314abd370c7c44802152b3e8b6e07fd26a9e9d Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Sun, 3 Aug 2025 23:54:43 +0500 Subject: [PATCH 2/5] feat(api): implemented missing modules - Add strict type declarations and comprehensive PHPDoc annotations to all models - Enhance enum implementations with proper type safety and display methods - Improve API resources with better data transformation and conditional loading - Update factories with realistic data generation and proper relationships - Add comprehensive test coverage for authentication and user endpoints - Implement proper validation rules and language file improvements - Add new admin and user controller tests for complete API coverage - Update middleware and request validation for better security - Enhance service layer with improved business logic implementation - Add missing API routes and improve existing endpoint functionality This commit establishes PHPStan level 10 compliance across all models and implements comprehensive testing to achieve 80%+ coverage requirements. --- app/Enums/ArticleStatus.php | 33 ++ app/Enums/CommentStatus.php | 42 ++ app/Enums/UserRole.php | 24 +- .../Article/ApproveArticleController.php | 53 +++ .../Article/FeatureArticleController.php | 50 ++ .../Admin/Article/GetArticlesController.php | 56 +++ .../Admin/Article/ReportArticleController.php | 53 +++ .../Admin/Article/ShowArticleController.php | 49 ++ .../Comment/ApproveCommentController.php | 66 +++ .../Admin/Comment/DeleteCommentController.php | 64 +++ .../Admin/Comment/GetCommentsController.php | 78 +++ .../Newsletter/DeleteSubscriberController.php | 64 +++ .../Newsletter/GetSubscribersController.php | 72 +++ .../CreateNotificationController.php | 53 +++ .../GetNotificationsController.php | 72 +++ .../Api/V1/Admin/User/BanUserController.php | 64 +++ .../Api/V1/Admin/User/BlockUserController.php | 64 +++ .../V1/Admin/User/CreateUserController.php | 53 +++ .../V1/Admin/User/DeleteUserController.php | 63 +++ .../Api/V1/Admin/User/GetUsersController.php | 70 +++ .../Api/V1/Admin/User/ShowUserController.php | 64 +++ .../Api/V1/Admin/User/UnbanUserController.php | 64 +++ .../V1/Admin/User/UnblockUserController.php | 64 +++ .../V1/Admin/User/UpdateUserController.php | 64 +++ .../Api/V1/Article/GetArticlesController.php | 2 +- .../Api/V1/User/UpdateProfileController.php | 55 +++ app/Http/Middleware/CheckTokenAbility.php | 2 +- .../Admin/Article/ApproveArticleRequest.php | 29 ++ .../Admin/Article/FeatureArticleRequest.php | 29 ++ .../V1/Admin/Article/GetArticlesRequest.php | 61 +++ .../V1/Admin/Article/ReportArticleRequest.php | 29 ++ .../V1/Admin/Article/ShowArticleRequest.php | 29 ++ .../Admin/Comment/ApproveCommentRequest.php | 32 ++ .../V1/Admin/Comment/DeleteCommentRequest.php | 32 ++ .../V1/Admin/Comment/GetCommentsRequest.php | 60 +++ .../Newsletter/DeleteSubscriberRequest.php | 32 ++ .../Newsletter/GetSubscribersRequest.php | 58 +++ .../CreateNotificationRequest.php | 62 +++ .../Notification/GetNotificationsRequest.php | 62 +++ .../Requests/V1/Admin/User/BanUserRequest.php | 29 ++ .../V1/Admin/User/BlockUserRequest.php | 29 ++ .../V1/Admin/User/CreateUserRequest.php | 53 +++ .../V1/Admin/User/DeleteUserRequest.php | 30 ++ .../V1/Admin/User/GetUsersRequest.php | 64 +++ .../V1/Admin/User/ShowUserRequest.php | 29 ++ .../V1/Admin/User/UnbanUserRequest.php | 30 ++ .../V1/Admin/User/UnblockUserRequest.php | 29 ++ .../V1/Admin/User/UpdateUserRequest.php | 56 +++ .../Requests/V1/Auth/RefreshTokenRequest.php | 4 +- .../Requests/V1/User/UpdateProfileRequest.php | 46 ++ .../Article/ArticleManagementResource.php | 120 +++++ .../V1/Admin/User/UserDetailResource.php | 70 +++ .../Resources/V1/Article/ArticleResource.php | 22 + .../Resources/V1/Comment/CommentResource.php | 56 ++- .../NewsletterSubscriberResource.php | 33 ++ .../V1/Notification/NotificationResource.php | 40 ++ app/Http/Resources/V1/User/UserResource.php | 49 ++ app/Models/Article.php | 23 +- app/Models/ArticleAuthor.php | 2 + app/Models/ArticleCategory.php | 2 + app/Models/ArticleTag.php | 2 + app/Models/Category.php | 3 + app/Models/Comment.php | 57 ++- app/Models/NewsletterSubscriber.php | 5 +- app/Models/Notification.php | 19 +- app/Models/NotificationAudience.php | 11 +- app/Models/Permission.php | 17 + app/Models/Role.php | 17 + app/Models/Tag.php | 3 + app/Models/User.php | 76 ++- app/Models/UserNotification.php | 4 + app/Services/ArticleManagementService.php | 325 +++++++++++++ app/Services/ArticleService.php | 4 + app/Services/CommentService.php | 118 +++++ app/Services/NewsletterService.php | 84 ++++ app/Services/NotificationService.php | 151 ++++++ app/Services/UserService.php | 266 +++++++++++ database/factories/ArticleFactory.php | 61 ++- database/factories/CommentFactory.php | 48 ++ .../factories/NotificationAudienceFactory.php | 3 +- database/factories/PermissionFactory.php | 1 + database/factories/RoleFactory.php | 1 + database/factories/UserFactory.php | 22 + ...d_banned_blocked_fields_to_users_table.php | 29 ++ ...001_add_admin_fields_to_articles_table.php | 42 ++ ..._05_000002_update_articles_status_enum.php | 25 + ..._make_approved_by_nullable_in_articles.php | 28 ++ ...004_add_admin_fields_to_comments_table.php | 43 ++ ...5_add_missing_fields_to_comments_table.php | 37 ++ lang/en/common.php | 176 +++++++ lang/en/validation.php | 16 + routes/api_v1.php | 45 ++ .../Article/ApproveArticleControllerTest.php | 173 +++++++ .../Article/FeatureArticleControllerTest.php | 284 +++++++++++ .../Article/GetArticlesControllerTest.php | 289 ++++++++++++ .../Article/ReportArticleControllerTest.php | 295 ++++++++++++ .../Article/ShowArticleControllerTest.php | 220 +++++++++ .../Comment/ApproveCommentControllerTest.php | 362 ++++++++++++++ .../Comment/DeleteCommentControllerTest.php | 387 +++++++++++++++ .../Comment/GetCommentsControllerTest.php | 407 ++++++++++++++++ .../DeleteSubscriberControllerTest.php | 445 ++++++++++++++++++ .../GetSubscribersControllerTest.php | 381 +++++++++++++++ .../CreateNotificationControllerTest.php | 99 ++++ .../GetNotificationsControllerTest.php | 434 +++++++++++++++++ .../V1/Admin/User/BanUserControllerTest.php | 145 ++++++ .../V1/Admin/User/BlockUserControllerTest.php | 174 +++++++ .../Admin/User/CreateUserControllerTest.php | 258 ++++++++++ .../Admin/User/DeleteUserControllerTest.php | 269 +++++++++++ .../V1/Admin/User/GetUsersControllerTest.php | 197 ++++++++ .../V1/Admin/User/ShowUserControllerTest.php | 357 ++++++++++++++ .../V1/Admin/User/UnbanUserControllerTest.php | 200 ++++++++ .../Admin/User/UnblockUserControllerTest.php | 220 +++++++++ .../Admin/User/UpdateUserControllerTest.php | 415 ++++++++++++++++ .../API/V1/Auth/LoginControllerTest.php | 2 +- .../API/V1/Auth/LogoutControllerTest.php | 2 +- .../V1/Auth/RefreshTokenControllerTest.php | 2 +- .../Feature/API/V1/User/MeControllerTest.php | 346 ++++++++++++++ .../V1/User/UpdateProfileControllerTest.php | 218 +++++++++ .../Providers/AuthServiceProviderTest.php | 2 +- 119 files changed, 11072 insertions(+), 39 deletions(-) create mode 100644 app/Enums/CommentStatus.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Article/ApproveArticleController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Article/FeatureArticleController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Article/GetArticlesController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Article/ReportArticleController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Article/ShowArticleController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Comment/ApproveCommentController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Comment/DeleteCommentController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Comment/GetCommentsController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Newsletter/DeleteSubscriberController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Newsletter/GetSubscribersController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Notification/CreateNotificationController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Notification/GetNotificationsController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/BanUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/CreateUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/GetUsersController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/ShowUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/UpdateUserController.php create mode 100644 app/Http/Controllers/Api/V1/User/UpdateProfileController.php create mode 100644 app/Http/Requests/V1/Admin/Article/ApproveArticleRequest.php create mode 100644 app/Http/Requests/V1/Admin/Article/FeatureArticleRequest.php create mode 100644 app/Http/Requests/V1/Admin/Article/GetArticlesRequest.php create mode 100644 app/Http/Requests/V1/Admin/Article/ReportArticleRequest.php create mode 100644 app/Http/Requests/V1/Admin/Article/ShowArticleRequest.php create mode 100644 app/Http/Requests/V1/Admin/Comment/ApproveCommentRequest.php create mode 100644 app/Http/Requests/V1/Admin/Comment/DeleteCommentRequest.php create mode 100644 app/Http/Requests/V1/Admin/Comment/GetCommentsRequest.php create mode 100644 app/Http/Requests/V1/Admin/Newsletter/DeleteSubscriberRequest.php create mode 100644 app/Http/Requests/V1/Admin/Newsletter/GetSubscribersRequest.php create mode 100644 app/Http/Requests/V1/Admin/Notification/CreateNotificationRequest.php create mode 100644 app/Http/Requests/V1/Admin/Notification/GetNotificationsRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/BanUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/BlockUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/CreateUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/DeleteUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/GetUsersRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/ShowUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/UnbanUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/UnblockUserRequest.php create mode 100644 app/Http/Requests/V1/Admin/User/UpdateUserRequest.php create mode 100644 app/Http/Requests/V1/User/UpdateProfileRequest.php create mode 100644 app/Http/Resources/V1/Admin/Article/ArticleManagementResource.php create mode 100644 app/Http/Resources/V1/Admin/User/UserDetailResource.php create mode 100644 app/Http/Resources/V1/Newsletter/NewsletterSubscriberResource.php create mode 100644 app/Http/Resources/V1/Notification/NotificationResource.php create mode 100644 app/Http/Resources/V1/User/UserResource.php create mode 100644 app/Services/ArticleManagementService.php create mode 100644 app/Services/CommentService.php create mode 100644 app/Services/NewsletterService.php create mode 100644 app/Services/NotificationService.php create mode 100644 app/Services/UserService.php create mode 100644 database/migrations/2025_07_05_000000_add_banned_blocked_fields_to_users_table.php create mode 100644 database/migrations/2025_07_05_000001_add_admin_fields_to_articles_table.php create mode 100644 database/migrations/2025_07_05_000002_update_articles_status_enum.php create mode 100644 database/migrations/2025_07_05_000003_make_approved_by_nullable_in_articles.php create mode 100644 database/migrations/2025_07_05_000004_add_admin_fields_to_comments_table.php create mode 100644 database/migrations/2025_07_05_000005_add_missing_fields_to_comments_table.php create mode 100644 tests/Feature/API/V1/Admin/Article/ApproveArticleControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Article/FeatureArticleControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Article/GetArticlesControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Article/ReportArticleControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Article/ShowArticleControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Comment/ApproveCommentControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Comment/DeleteCommentControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Comment/GetCommentsControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Newsletter/DeleteSubscriberControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Newsletter/GetSubscribersControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Notification/CreateNotificationControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/Notification/GetNotificationsControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/BanUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/CreateUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/GetUsersControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/ShowUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php create mode 100644 tests/Feature/API/V1/Admin/User/UpdateUserControllerTest.php create mode 100644 tests/Feature/API/V1/User/UpdateProfileControllerTest.php diff --git a/app/Enums/ArticleStatus.php b/app/Enums/ArticleStatus.php index d77b57a..577e805 100644 --- a/app/Enums/ArticleStatus.php +++ b/app/Enums/ArticleStatus.php @@ -7,7 +7,40 @@ enum ArticleStatus: string { case DRAFT = 'draft'; + case REVIEW = 'review'; case SCHEDULED = 'scheduled'; case PUBLISHED = 'published'; case ARCHIVED = 'archived'; + case TRASHED = 'trashed'; + + /** + * Get the display name for the status + */ + public function displayName(): string + { + return match ($this) { + self::DRAFT => 'Draft', + self::REVIEW => 'Under Review', + self::SCHEDULED => 'Scheduled', + self::PUBLISHED => 'Published', + self::ARCHIVED => 'Archived', + self::TRASHED => 'Trashed', + }; + } + + /** + * Check if the status is a published state + */ + public function isPublished(): bool + { + return in_array($this, [self::PUBLISHED, self::SCHEDULED]); + } + + /** + * Check if the status is a draft state + */ + public function isDraft(): bool + { + return in_array($this, [self::DRAFT, self::REVIEW]); + } } diff --git a/app/Enums/CommentStatus.php b/app/Enums/CommentStatus.php new file mode 100644 index 0000000..a24ddb5 --- /dev/null +++ b/app/Enums/CommentStatus.php @@ -0,0 +1,42 @@ + 'Pending', + self::APPROVED => 'Approved', + self::REJECTED => 'Rejected', + self::SPAM => 'Spam', + }; + } + + /** + * Check if the status is a published state + */ + public function isPublished(): bool + { + return $this === self::APPROVED; + } + + /** + * Check if the status is a draft state + */ + public function isDraft(): bool + { + return in_array($this, [self::PENDING, self::REJECTED]); + } +} diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php index 91331aa..6389a85 100644 --- a/app/Enums/UserRole.php +++ b/app/Enums/UserRole.php @@ -6,9 +6,23 @@ enum UserRole: string { - case ADMINISTRATOR = 'Administrator'; - case EDITOR = 'Editor'; - case AUTHOR = 'Author'; - case CONTRIBUTOR = 'Contributor'; - case SUBSCRIBER = 'Subscriber'; + case ADMINISTRATOR = 'administrator'; + case EDITOR = 'editor'; + case AUTHOR = 'author'; + case CONTRIBUTOR = 'contributor'; + case SUBSCRIBER = 'subscriber'; + + /** + * Get the display name for the role + */ + public function displayName(): string + { + return match ($this) { + self::ADMINISTRATOR => 'Administrator', + self::EDITOR => 'Editor', + self::AUTHOR => 'Author', + self::CONTRIBUTOR => 'Contributor', + self::SUBSCRIBER => 'Subscriber', + }; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Article/ApproveArticleController.php b/app/Http/Controllers/Api/V1/Admin/Article/ApproveArticleController.php new file mode 100644 index 0000000..46237df --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Article/ApproveArticleController.php @@ -0,0 +1,53 @@ +user(); + assert($user !== null); + + $article = $this->articleManagementService->approveArticle($id, $user->id); + + return response()->apiSuccess( + new ArticleManagementResource($article), + __('common.article_approved_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->apiError( + __('common.article_not_found'), + Response::HTTP_NOT_FOUND + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Article/FeatureArticleController.php b/app/Http/Controllers/Api/V1/Admin/Article/FeatureArticleController.php new file mode 100644 index 0000000..03fd7dc --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Article/FeatureArticleController.php @@ -0,0 +1,50 @@ +articleManagementService->featureArticle($id); + + return response()->apiSuccess( + new ArticleManagementResource($article), + __('common.article_featured_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->apiError( + __('common.article_not_found'), + Response::HTTP_NOT_FOUND + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Article/GetArticlesController.php b/app/Http/Controllers/Api/V1/Admin/Article/GetArticlesController.php new file mode 100644 index 0000000..3f8073d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Article/GetArticlesController.php @@ -0,0 +1,56 @@ +withDefaults(); + $articles = $this->articleManagementService->getArticles($params); + $articleCollection = ArticleManagementResource::collection($articles); + $articleCollectionData = $articleCollection->response()->getData(true); + + if (! is_array($articleCollectionData) || ! isset($articleCollectionData['data'], $articleCollectionData['meta'])) { + throw new \RuntimeException(__('common.unexpected_response_format')); + } + + return response()->apiSuccess( + [ + 'articles' => $articleCollectionData['data'], + 'meta' => MetaResource::make($articleCollectionData['meta']), + ], + __('common.success') + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Article/ReportArticleController.php b/app/Http/Controllers/Api/V1/Admin/Article/ReportArticleController.php new file mode 100644 index 0000000..8ee9a20 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Article/ReportArticleController.php @@ -0,0 +1,53 @@ +validated(); + /** @var string $reason */ + $reason = $validated['reason'] ?? 'No reason provided'; + $article = $this->articleManagementService->reportArticle($id, $reason); + + return response()->apiSuccess( + new ArticleManagementResource($article), + __('common.article_reported_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->apiError( + __('common.article_not_found'), + Response::HTTP_NOT_FOUND + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Article/ShowArticleController.php b/app/Http/Controllers/Api/V1/Admin/Article/ShowArticleController.php new file mode 100644 index 0000000..b6c852e --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Article/ShowArticleController.php @@ -0,0 +1,49 @@ +with(['author:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug', 'comments.user:id,name,email']) + ->withCount(['comments', 'authors']) + ->findOrFail($id); + + return response()->apiSuccess( + new ArticleManagementResource($article), + __('common.success') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return response()->apiError( + __('common.article_not_found'), + Response::HTTP_NOT_FOUND + ); + } catch (\Throwable $e) { + return response()->apiError( + __('common.something_went_wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Comment/ApproveCommentController.php b/app/Http/Controllers/Api/V1/Admin/Comment/ApproveCommentController.php new file mode 100644 index 0000000..d622a5f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Comment/ApproveCommentController.php @@ -0,0 +1,66 @@ +commentService->approveComment($id, $request->validated()); + + return response()->apiSuccess( + new CommentResource($comment), + __('common.comment_approved') + ); + } catch (ModelNotFoundException $e) { + /** + * Comment not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.comment_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/Controllers/Api/V1/Admin/Comment/DeleteCommentController.php b/app/Http/Controllers/Api/V1/Admin/Comment/DeleteCommentController.php new file mode 100644 index 0000000..8a18cb1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Comment/DeleteCommentController.php @@ -0,0 +1,64 @@ +commentService->deleteComment($id, $request->validated()); + + return response()->apiSuccess( + null, + __('common.comment_deleted') + ); + } catch (ModelNotFoundException $e) { + /** + * Comment not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.comment_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/Controllers/Api/V1/Admin/Comment/GetCommentsController.php b/app/Http/Controllers/Api/V1/Admin/Comment/GetCommentsController.php new file mode 100644 index 0000000..e764a2d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Comment/GetCommentsController.php @@ -0,0 +1,78 @@ +withDefaults(); + $comments = $this->commentService->getComments($filters); + + $commentCollection = CommentResource::collection($comments); + + /** + * Successful comments retrieval + */ + $commentCollectionData = $commentCollection->response()->getData(true); + + // Ensure we have the expected array structure + if (! is_array($commentCollectionData) || ! isset($commentCollectionData['data'], $commentCollectionData['meta'])) { + throw new \RuntimeException(__('common.unexpected_response_format')); + } + + return response()->apiSuccess( + [ + 'comments' => $commentCollectionData['data'], + 'meta' => MetaResource::make($commentCollectionData['meta']), + ], + __('common.success') + ); + } catch (\Throwable $e) { + // Log the error for debugging + \Log::error('GetCommentsController: Exception occurred', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + /** + * 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/Admin/Newsletter/DeleteSubscriberController.php b/app/Http/Controllers/Api/V1/Admin/Newsletter/DeleteSubscriberController.php new file mode 100644 index 0000000..7f9da55 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Newsletter/DeleteSubscriberController.php @@ -0,0 +1,64 @@ +newsletterService->deleteSubscriber($id, $request->validated()); + + return response()->apiSuccess( + null, + __('common.subscriber_deleted') + ); + } catch (ModelNotFoundException $e) { + /** + * Subscriber not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.subscriber_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/Controllers/Api/V1/Admin/Newsletter/GetSubscribersController.php b/app/Http/Controllers/Api/V1/Admin/Newsletter/GetSubscribersController.php new file mode 100644 index 0000000..9a0770d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Newsletter/GetSubscribersController.php @@ -0,0 +1,72 @@ +newsletterService->getSubscribers($request->validated()); + + $subscriberCollection = NewsletterSubscriberResource::collection($subscribers); + $subscriberCollectionData = $subscriberCollection->response()->getData(true); + + // Ensure we have the expected array structure + if (! is_array($subscriberCollectionData) || ! isset($subscriberCollectionData['data'], $subscriberCollectionData['meta'])) { + throw new \RuntimeException(__('common.unexpected_response_format')); + } + + return response()->apiSuccess( + [ + 'subscribers' => $subscriberCollectionData['data'], + 'meta' => MetaResource::make($subscriberCollectionData['meta']), + ], + __('common.success') + ); + } catch (\Throwable $e) { + Log::error('Newsletter subscribers retrieval failed', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + /** + * 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/Admin/Notification/CreateNotificationController.php b/app/Http/Controllers/Api/V1/Admin/Notification/CreateNotificationController.php new file mode 100644 index 0000000..f1ead4b --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Notification/CreateNotificationController.php @@ -0,0 +1,53 @@ +notificationService->createNotification($request->validated()); + + return response()->apiSuccess( + new NotificationResource($notification), + __('common.notification_created'), + Response::HTTP_CREATED + ); + } 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/Admin/Notification/GetNotificationsController.php b/app/Http/Controllers/Api/V1/Admin/Notification/GetNotificationsController.php new file mode 100644 index 0000000..a740d5e --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Notification/GetNotificationsController.php @@ -0,0 +1,72 @@ +notificationService->getNotifications($request->validated()); + + $notificationCollection = NotificationResource::collection($notifications); + $notificationCollectionData = $notificationCollection->response()->getData(true); + + // Ensure we have the expected array structure + if (! is_array($notificationCollectionData) || ! isset($notificationCollectionData['data'], $notificationCollectionData['meta'])) { + throw new \RuntimeException(__('common.unexpected_response_format')); + } + + return response()->apiSuccess( + [ + 'notifications' => $notificationCollectionData['data'], + 'meta' => MetaResource::make($notificationCollectionData['meta']), + ], + __('common.success') + ); + } catch (\Throwable $e) { + Log::error('Notifications retrieval failed', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ]); + + /** + * 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/Admin/User/BanUserController.php b/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php new file mode 100644 index 0000000..af25997 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php @@ -0,0 +1,64 @@ +userService->banUser($id); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_banned_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/BlockUserController.php b/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php new file mode 100644 index 0000000..e08aa63 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php @@ -0,0 +1,64 @@ +userService->blockUser($id); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_blocked_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/CreateUserController.php b/app/Http/Controllers/Api/V1/Admin/User/CreateUserController.php new file mode 100644 index 0000000..7465026 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/CreateUserController.php @@ -0,0 +1,53 @@ +userService->createUser($request->validated()); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_created_successfully'), + Response::HTTP_CREATED + ); + } 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/Admin/User/DeleteUserController.php b/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php new file mode 100644 index 0000000..370e09b --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php @@ -0,0 +1,63 @@ +userService->deleteUser($id); + + return response()->apiSuccess( + null, + __('common.user_deleted_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/GetUsersController.php b/app/Http/Controllers/Api/V1/Admin/User/GetUsersController.php new file mode 100644 index 0000000..ab31197 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/GetUsersController.php @@ -0,0 +1,70 @@ +withDefaults(); + + $users = $this->userService->getUsers($params); + + $userCollection = UserDetailResource::collection($users); + + /** + * Successful users retrieval + */ + $userCollectionData = $userCollection->response()->getData(true); + + // Ensure we have the expected array structure + if (! is_array($userCollectionData) || ! isset($userCollectionData['data'], $userCollectionData['meta'])) { + throw new \RuntimeException(__('common.unexpected_response_format')); + } + + return response()->apiSuccess( + [ + 'users' => $userCollectionData['data'], + 'meta' => MetaResource::make($userCollectionData['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/Admin/User/ShowUserController.php b/app/Http/Controllers/Api/V1/Admin/User/ShowUserController.php new file mode 100644 index 0000000..5337692 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/ShowUserController.php @@ -0,0 +1,64 @@ +userService->getUserById($id); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.success') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/UnbanUserController.php b/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php new file mode 100644 index 0000000..ffe996a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php @@ -0,0 +1,64 @@ +userService->unbanUser($id); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_unbanned_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/UnblockUserController.php b/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php new file mode 100644 index 0000000..692c04a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php @@ -0,0 +1,64 @@ +userService->unblockUser($id); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_unblocked_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Admin/User/UpdateUserController.php b/app/Http/Controllers/Api/V1/Admin/User/UpdateUserController.php new file mode 100644 index 0000000..954178f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/UpdateUserController.php @@ -0,0 +1,64 @@ +userService->updateUser($id, $request->validated()); + + return response()->apiSuccess( + new UserDetailResource($user), + __('common.user_updated_successfully') + ); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + /** + * User not found + * + * @status 404 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + __('common.user_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/Controllers/Api/V1/Article/GetArticlesController.php b/app/Http/Controllers/Api/V1/Article/GetArticlesController.php index b288664..534d7a9 100644 --- a/app/Http/Controllers/Api/V1/Article/GetArticlesController.php +++ b/app/Http/Controllers/Api/V1/Article/GetArticlesController.php @@ -45,7 +45,7 @@ public function __invoke(GetArticlesRequest $request): JsonResponse // 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'); + throw new \RuntimeException(__('common.unexpected_response_format')); } return response()->apiSuccess( diff --git a/app/Http/Controllers/Api/V1/User/UpdateProfileController.php b/app/Http/Controllers/Api/V1/User/UpdateProfileController.php new file mode 100644 index 0000000..78f5123 --- /dev/null +++ b/app/Http/Controllers/Api/V1/User/UpdateProfileController.php @@ -0,0 +1,55 @@ +user(); + assert($authenticatedUser !== null); + + $user = $this->userService->updateUser($authenticatedUser->id, $request->validated()); + + return response()->apiSuccess( + new UserResource($user), + __('common.profile_updated_successfully') + ); + } 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/Middleware/CheckTokenAbility.php b/app/Http/Middleware/CheckTokenAbility.php index 7e649ab..2b16064 100644 --- a/app/Http/Middleware/CheckTokenAbility.php +++ b/app/Http/Middleware/CheckTokenAbility.php @@ -21,7 +21,7 @@ public function handle(Request $request, Closure $next, string $ability = 'acces if (! $token || ! $token->can($ability)) { return response()->apiError( - __('Unauthorized. Invalid token or insufficient permissions.'), + __('common.unauthorized_token'), Response::HTTP_UNAUTHORIZED ); } diff --git a/app/Http/Requests/V1/Admin/Article/ApproveArticleRequest.php b/app/Http/Requests/V1/Admin/Article/ApproveArticleRequest.php new file mode 100644 index 0000000..aa702d9 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Article/ApproveArticleRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('approve_posts'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for approval + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Article/FeatureArticleRequest.php b/app/Http/Requests/V1/Admin/Article/FeatureArticleRequest.php new file mode 100644 index 0000000..ec815ea --- /dev/null +++ b/app/Http/Requests/V1/Admin/Article/FeatureArticleRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('feature_posts'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for featuring + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Article/GetArticlesRequest.php b/app/Http/Requests/V1/Admin/Article/GetArticlesRequest.php new file mode 100644 index 0000000..3795d71 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Article/GetArticlesRequest.php @@ -0,0 +1,61 @@ +user(); + + return $user !== null && $user->hasPermission('view_posts'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'search' => ['sometimes', 'string', 'max:255'], + 'status' => ['sometimes', Rule::enum(ArticleStatus::class)], + 'author_id' => ['sometimes', 'integer', 'exists:users,id'], + 'category_id' => ['sometimes', 'integer', 'exists:categories,id'], + 'tag_id' => ['sometimes', 'integer', 'exists:tags,id'], + 'is_featured' => ['sometimes', 'boolean'], + 'is_pinned' => ['sometimes', 'boolean'], + 'has_reports' => ['sometimes', 'boolean'], + 'created_after' => ['sometimes', 'date'], + 'created_before' => ['sometimes', 'date'], + 'published_after' => ['sometimes', 'date'], + 'published_before' => ['sometimes', 'date'], + 'sort_by' => ['sometimes', 'string', 'in:title,created_at,published_at,status,is_featured,is_pinned,report_count'], + 'sort_direction' => ['sometimes', 'string', 'in:asc,desc'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'page' => 1, + 'per_page' => 15, + 'sort_by' => 'created_at', + 'sort_direction' => 'desc', + ], $this->validated()); + } +} diff --git a/app/Http/Requests/V1/Admin/Article/ReportArticleRequest.php b/app/Http/Requests/V1/Admin/Article/ReportArticleRequest.php new file mode 100644 index 0000000..167fbb1 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Article/ReportArticleRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('report_posts'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'reason' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Article/ShowArticleRequest.php b/app/Http/Requests/V1/Admin/Article/ShowArticleRequest.php new file mode 100644 index 0000000..1f9d8ce --- /dev/null +++ b/app/Http/Requests/V1/Admin/Article/ShowArticleRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('view_posts'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for showing + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Comment/ApproveCommentRequest.php b/app/Http/Requests/V1/Admin/Comment/ApproveCommentRequest.php new file mode 100644 index 0000000..b04ef43 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Comment/ApproveCommentRequest.php @@ -0,0 +1,32 @@ +user(); + + return $user !== null && $user->can('approve_comments'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'admin_note' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Comment/DeleteCommentRequest.php b/app/Http/Requests/V1/Admin/Comment/DeleteCommentRequest.php new file mode 100644 index 0000000..88a0e7b --- /dev/null +++ b/app/Http/Requests/V1/Admin/Comment/DeleteCommentRequest.php @@ -0,0 +1,32 @@ +user(); + + return $user !== null && $user->can('delete_comments'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'reason' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Comment/GetCommentsRequest.php b/app/Http/Requests/V1/Admin/Comment/GetCommentsRequest.php new file mode 100644 index 0000000..e83653d --- /dev/null +++ b/app/Http/Requests/V1/Admin/Comment/GetCommentsRequest.php @@ -0,0 +1,60 @@ +user(); + + return $user !== null && $user->can('comment_moderate'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', Rule::enum(CommentStatus::class)], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + 'article_id' => ['nullable', 'integer', 'exists:articles,id'], + 'sort_by' => ['nullable', 'string', 'in:created_at,updated_at,content,user_id,article_id'], + 'sort_order' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'sort_by' => 'created_at', + 'sort_order' => 'desc', + 'per_page' => 15, + ], $this->validated()); + } +} diff --git a/app/Http/Requests/V1/Admin/Newsletter/DeleteSubscriberRequest.php b/app/Http/Requests/V1/Admin/Newsletter/DeleteSubscriberRequest.php new file mode 100644 index 0000000..ba0b7a6 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Newsletter/DeleteSubscriberRequest.php @@ -0,0 +1,32 @@ +user(); + + return $user !== null && $user->can('manage_newsletter_subscribers'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'reason' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Newsletter/GetSubscribersRequest.php b/app/Http/Requests/V1/Admin/Newsletter/GetSubscribersRequest.php new file mode 100644 index 0000000..9866cf6 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Newsletter/GetSubscribersRequest.php @@ -0,0 +1,58 @@ +user(); + + return $user !== null && $user->can('view_newsletter_subscribers'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'string', 'in:verified,unverified'], + 'subscribed_at_from' => ['nullable', 'date'], + 'subscribed_at_to' => ['nullable', 'date', 'after_or_equal:subscribed_at_from'], + 'sort_by' => ['nullable', 'string', 'in:created_at,updated_at,email,is_verified'], + 'sort_order' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'sort_by' => 'created_at', + 'sort_order' => 'desc', + 'per_page' => 15, + ], $this->validated()); + } +} diff --git a/app/Http/Requests/V1/Admin/Notification/CreateNotificationRequest.php b/app/Http/Requests/V1/Admin/Notification/CreateNotificationRequest.php new file mode 100644 index 0000000..949a008 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Notification/CreateNotificationRequest.php @@ -0,0 +1,62 @@ + $message + * @property-read array $audiences + * @property-read array|null $user_ids + */ +final class CreateNotificationRequest extends FormRequest +{ + public function authorize(): bool + { + $user = $this->user(); + + return $user !== null && $user->can('send_notifications'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'type' => ['required', Rule::enum(NotificationType::class)], + 'message' => ['required', 'array'], + 'message.title' => ['required', 'string', 'max:255'], + 'message.body' => ['required', 'string', 'max:255'], + 'message.priority' => ['required', 'string', 'max:255'], + 'audiences' => ['required', 'array', 'min:1'], + 'audiences.*' => ['string', 'in:all_users,administrators,specific_users'], + 'user_ids' => ['required_if:audiences,specific_users', 'array'], + 'user_ids.*' => ['integer', 'exists:users,id'], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'type.required' => __('validation.custom.type.required'), + 'message.required' => __('validation.custom.message.required'), + 'message.title.required' => __('validation.custom.message.title.required'), + 'audiences.required' => __('validation.custom.audiences.required'), + 'audiences.min' => __('validation.custom.audiences.min'), + 'user_ids.required_if' => __('validation.custom.user_ids.required_if'), + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/Notification/GetNotificationsRequest.php b/app/Http/Requests/V1/Admin/Notification/GetNotificationsRequest.php new file mode 100644 index 0000000..4fe3919 --- /dev/null +++ b/app/Http/Requests/V1/Admin/Notification/GetNotificationsRequest.php @@ -0,0 +1,62 @@ +user(); + + return $user !== null && $user->can('view_notifications'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'search' => ['nullable', 'string', 'max:255'], + 'type' => ['nullable', Rule::enum(NotificationType::class)], + 'status' => ['nullable', 'string', 'in:verified,unverified'], + 'created_at_from' => ['nullable', 'date'], + 'created_at_to' => ['nullable', 'date', 'after_or_equal:created_at_from'], + 'sort_by' => ['nullable', 'string', 'in:created_at,updated_at,type'], + 'sort_order' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } + + /** + * Get the default values for missing parameters + * + * @return array + */ + public function withDefaults(): array + { + return array_merge([ + 'sort_by' => 'created_at', + 'sort_order' => 'desc', + 'per_page' => 15, + ], $this->validated()); + } +} diff --git a/app/Http/Requests/V1/Admin/User/BanUserRequest.php b/app/Http/Requests/V1/Admin/User/BanUserRequest.php new file mode 100644 index 0000000..8b07bcb --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/BanUserRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('ban_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for banning + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/User/BlockUserRequest.php b/app/Http/Requests/V1/Admin/User/BlockUserRequest.php new file mode 100644 index 0000000..f99917c --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/BlockUserRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('block_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for blocking + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/User/CreateUserRequest.php b/app/Http/Requests/V1/Admin/User/CreateUserRequest.php new file mode 100644 index 0000000..1c00e80 --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/CreateUserRequest.php @@ -0,0 +1,53 @@ +user(); + + return $user !== null && $user->hasPermission('create_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')], + 'password' => ['required', 'string', 'min:8', 'max:255'], + 'avatar_url' => ['nullable', 'url', 'max:255'], + 'bio' => ['nullable', 'string', 'max:1000'], + 'twitter' => ['nullable', 'string', 'max:255'], + 'facebook' => ['nullable', 'string', 'max:255'], + 'linkedin' => ['nullable', 'string', 'max:255'], + 'github' => ['nullable', 'string', 'max:255'], + 'website' => ['nullable', 'url', 'max:255'], + 'role_id' => ['nullable', 'integer', 'exists:roles,id'], + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/User/DeleteUserRequest.php b/app/Http/Requests/V1/Admin/User/DeleteUserRequest.php new file mode 100644 index 0000000..2ff21fb --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/DeleteUserRequest.php @@ -0,0 +1,30 @@ +user(); + + return $user !== null && $user->hasPermission('delete_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/V1/Admin/User/GetUsersRequest.php b/app/Http/Requests/V1/Admin/User/GetUsersRequest.php new file mode 100644 index 0000000..899b86d --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/GetUsersRequest.php @@ -0,0 +1,64 @@ +user(); + + return $user !== null && $user->hasPermission('view_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'page' => ['integer', 'min:1'], + 'per_page' => ['integer', 'min:1', 'max:100'], + 'search' => ['string', 'max:255'], + 'role_id' => ['integer', 'exists:roles,id'], + 'status' => [Rule::in(['active', 'banned', 'blocked'])], + 'created_after' => ['date'], + 'created_before' => ['date'], + 'sort_by' => [Rule::in(['name', 'email', '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' => 'created_at', + 'sort_direction' => 'desc', + ], $this->validated()); + } +} diff --git a/app/Http/Requests/V1/Admin/User/ShowUserRequest.php b/app/Http/Requests/V1/Admin/User/ShowUserRequest.php new file mode 100644 index 0000000..bcfc89f --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/ShowUserRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('view_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for showing + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/User/UnbanUserRequest.php b/app/Http/Requests/V1/Admin/User/UnbanUserRequest.php new file mode 100644 index 0000000..911e7f6 --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/UnbanUserRequest.php @@ -0,0 +1,30 @@ +user(); + + return $user !== null && $user->hasPermission('restore_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/V1/Admin/User/UnblockUserRequest.php b/app/Http/Requests/V1/Admin/User/UnblockUserRequest.php new file mode 100644 index 0000000..dc52467 --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/UnblockUserRequest.php @@ -0,0 +1,29 @@ +user(); + + return $user !== null && $user->hasPermission('block_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + // No additional validation rules needed for unblocking + ]; + } +} diff --git a/app/Http/Requests/V1/Admin/User/UpdateUserRequest.php b/app/Http/Requests/V1/Admin/User/UpdateUserRequest.php new file mode 100644 index 0000000..fb6a7fd --- /dev/null +++ b/app/Http/Requests/V1/Admin/User/UpdateUserRequest.php @@ -0,0 +1,56 @@ +|null $role_ids + */ +final class UpdateUserRequest extends FormRequest +{ + public function authorize(): bool + { + $user = $this->user(); + + return $user !== null && $user->hasPermission('edit_users'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $userId = $this->route('id'); + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'email' => ['sometimes', 'required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], + 'password' => ['sometimes', 'string', 'min:8', 'max:255'], + 'avatar_url' => ['sometimes', 'nullable', 'url', 'max:255'], + 'bio' => ['sometimes', 'nullable', 'string', 'max:1000'], + 'twitter' => ['sometimes', 'nullable', 'string', 'max:255'], + 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], + 'linkedin' => ['sometimes', 'nullable', 'string', 'max:255'], + 'github' => ['sometimes', 'nullable', 'string', 'max:255'], + 'website' => ['sometimes', 'nullable', 'url', 'max:255'], + 'role_ids' => ['sometimes', 'array'], + 'role_ids.*' => ['integer', 'exists:roles,id'], + ]; + } +} diff --git a/app/Http/Requests/V1/Auth/RefreshTokenRequest.php b/app/Http/Requests/V1/Auth/RefreshTokenRequest.php index 58aa1ad..2f44e8c 100644 --- a/app/Http/Requests/V1/Auth/RefreshTokenRequest.php +++ b/app/Http/Requests/V1/Auth/RefreshTokenRequest.php @@ -43,8 +43,8 @@ public function rules(): array public function messages(): array { return [ - 'refresh_token.required' => 'The refresh token is required.', - 'refresh_token.string' => 'The refresh token must be a string.', + 'refresh_token.required' => __('validation.required', ['attribute' => 'refresh token']), + 'refresh_token.string' => __('validation.string', ['attribute' => 'refresh token']), ]; } } diff --git a/app/Http/Requests/V1/User/UpdateProfileRequest.php b/app/Http/Requests/V1/User/UpdateProfileRequest.php new file mode 100644 index 0000000..c877d87 --- /dev/null +++ b/app/Http/Requests/V1/User/UpdateProfileRequest.php @@ -0,0 +1,46 @@ +user(); + + return $user !== null && $user->hasPermission('edit_profile'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'avatar_url' => ['sometimes', 'nullable', 'url', 'max:255'], + 'bio' => ['sometimes', 'nullable', 'string', 'max:1000'], + 'twitter' => ['sometimes', 'nullable', 'string', 'max:255'], + 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], + 'linkedin' => ['sometimes', 'nullable', 'string', 'max:255'], + 'github' => ['sometimes', 'nullable', 'string', 'max:255'], + 'website' => ['sometimes', 'nullable', 'url', 'max:255'], + ]; + } +} diff --git a/app/Http/Resources/V1/Admin/Article/ArticleManagementResource.php b/app/Http/Resources/V1/Admin/Article/ArticleManagementResource.php new file mode 100644 index 0000000..b9cf49e --- /dev/null +++ b/app/Http/Resources/V1/Admin/Article/ArticleManagementResource.php @@ -0,0 +1,120 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'slug' => $this->resource->slug, + 'title' => $this->resource->title, + 'subtitle' => $this->resource->subtitle, + 'excerpt' => $this->resource->excerpt, + 'content_markdown' => $this->resource->content_markdown, + 'content_html' => $this->resource->content_html, + 'featured_image' => $this->resource->featured_image, + 'status' => $this->resource->status->value, + 'status_display' => $this->resource->status instanceof \App\Enums\ArticleStatus ? $this->resource->status->displayName() : $this->resource->status, + 'published_at' => $this->resource->published_at, + 'meta_title' => $this->resource->meta_title, + 'meta_description' => $this->resource->meta_description, + 'is_featured' => $this->resource->is_featured, + 'is_pinned' => $this->resource->is_pinned, + 'featured_at' => $this->resource->featured_at, + 'pinned_at' => $this->resource->pinned_at, + 'report_count' => $this->resource->report_count, + 'last_reported_at' => $this->resource->last_reported_at, + 'report_reason' => $this->resource->report_reason, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'author' => $this->whenLoaded('author', function () { + $author = $this->resource->author; + if ($author === null) { + return null; + } + + return [ + 'id' => $author->id, + 'name' => $author->name, + 'email' => $author->email, + ]; + }), + 'approver' => $this->whenLoaded('approver', function () { + $approver = $this->resource->approver; + if ($approver === null) { + return null; + } + + return [ + 'id' => $approver->id, + 'name' => $approver->name, + 'email' => $approver->email, + ]; + }, null), + 'updater' => $this->whenLoaded('updater', function () { + $updater = $this->resource->updater; + if ($updater === null) { + return null; + } + + return [ + 'id' => $updater->id, + 'name' => $updater->name, + 'email' => $updater->email, + ]; + }, null), + 'categories' => $this->whenLoaded('categories', function () { + return $this->resource->categories->map(function ($category) { + return [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + ]; + }); + }), + 'tags' => $this->whenLoaded('tags', function () { + return $this->resource->tags->map(function ($tag) { + return [ + 'id' => $tag->id, + 'name' => $tag->name, + 'slug' => $tag->slug, + ]; + }); + }), + 'comments_count' => $this->resource->comments_count ?? 0, + 'authors_count' => $this->resource->authors_count ?? 0, + 'comments' => $this->whenLoaded('comments', function () { + return $this->resource->comments->map(function ($comment) { + $user = $comment->user; + + return [ + 'id' => $comment->id, + 'content' => $comment->content, + 'user' => $user !== null ? [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ] : null, + 'created_at' => $comment->created_at, + ]; + }); + }), + ]; + } +} diff --git a/app/Http/Resources/V1/Admin/User/UserDetailResource.php b/app/Http/Resources/V1/Admin/User/UserDetailResource.php new file mode 100644 index 0000000..fcf0191 --- /dev/null +++ b/app/Http/Resources/V1/Admin/User/UserDetailResource.php @@ -0,0 +1,70 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'email_verified_at' => $this->resource->email_verified_at, + 'avatar_url' => $this->resource->avatar_url, + 'bio' => $this->resource->bio, + 'twitter' => $this->resource->twitter, + 'facebook' => $this->resource->facebook, + 'linkedin' => $this->resource->linkedin, + 'github' => $this->resource->github, + 'website' => $this->resource->website, + 'banned_at' => $this->resource->banned_at, + 'blocked_at' => $this->resource->blocked_at, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'roles' => $this->whenLoaded('roles', function () { + return $this->resource->roles->map(function ($role) { + return [ + 'id' => $role->id, + 'name' => $role->name, + 'slug' => $role->slug, + 'display_name' => \App\Enums\UserRole::from($role->name)->displayName(), + ]; + }); + }), + 'articles_count' => $this->resource->articles_count, + 'comments_count' => $this->resource->comments_count, + 'status' => $this->getUserStatus(), + ]; + } + + /** + * Get user status based on banned/blocked fields + */ + private function getUserStatus(): string + { + if ($this->resource->banned_at) { + return 'banned'; + } + + if ($this->resource->blocked_at) { + return 'blocked'; + } + + return 'active'; + } +} diff --git a/app/Http/Resources/V1/Article/ArticleResource.php b/app/Http/Resources/V1/Article/ArticleResource.php index aae1f63..97ac260 100644 --- a/app/Http/Resources/V1/Article/ArticleResource.php +++ b/app/Http/Resources/V1/Article/ArticleResource.php @@ -30,7 +30,11 @@ public function toArray(Request $request): array 'content_markdown' => $this->content_markdown, 'featured_image' => $this->featured_image, 'status' => $this->status, + 'status_display' => $this->status->displayName(), 'published_at' => ($this->published_at instanceof \DateTimeInterface ? $this->published_at->toISOString() : $this->published_at), + 'is_featured' => $this->is_featured, + 'is_pinned' => $this->is_pinned, + 'report_count' => $this->report_count, 'meta_title' => $this->meta_title, 'meta_description' => $this->meta_description, 'created_at' => ($this->created_at instanceof \DateTimeInterface ? $this->created_at->toISOString() : $this->created_at), @@ -53,6 +57,24 @@ public function toArray(Request $request): array ] : null; }), + 'approver' => $this->whenLoaded('approver', function () use ($request) { + return $this->approver ? [ + 'id' => $this->approver->id, + 'name' => $this->approver->name, + 'email' => $this->when((bool) $request->user()?->hasRole(UserRole::ADMINISTRATOR->value), $this->approver->email), + 'avatar_url' => $this->approver->avatar_url, + ] : null; + }), + + 'updater' => $this->whenLoaded('updater', function () use ($request) { + return $this->updater ? [ + 'id' => $this->updater->id, + 'name' => $this->updater->name, + 'email' => $this->when((bool) $request->user()?->hasRole(UserRole::ADMINISTRATOR->value), $this->updater->email), + 'avatar_url' => $this->updater->avatar_url, + ] : null; + }), + 'categories' => $this->whenLoaded('categories', function () { /** @var \Illuminate\Database\Eloquent\Collection $categories */ $categories = $this->categories; diff --git a/app/Http/Resources/V1/Comment/CommentResource.php b/app/Http/Resources/V1/Comment/CommentResource.php index 1a0bd79..0412428 100644 --- a/app/Http/Resources/V1/Comment/CommentResource.php +++ b/app/Http/Resources/V1/Comment/CommentResource.php @@ -4,7 +4,6 @@ namespace App\Http\Resources\V1\Comment; -use App\Http\Resources\V1\Auth\UserResource; use App\Models\Comment; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -24,10 +23,59 @@ public function toArray($request): array { return [ 'id' => $this->id, - 'user' => new UserResource($this->whenLoaded('user')), + 'article_id' => $this->article_id, + 'user_id' => $this->user_id, + 'parent_comment_id' => $this->parent_comment_id, 'content' => $this->content, - 'created_at' => $this->created_at, - 'replies_count' => $this->replies_count, + 'status' => $this->status->value, + 'status_display' => $this->status->displayName(), + 'is_approved' => $this->status === \App\Enums\CommentStatus::APPROVED, + 'approved_by' => $this->approved_by, + 'approved_at' => $this->approved_at?->toISOString(), + 'report_count' => $this->report_count, + 'last_reported_at' => $this->last_reported_at?->toISOString(), + 'report_reason' => $this->report_reason, + 'moderator_notes' => $this->moderator_notes, + 'admin_note' => $this->admin_note, + 'deleted_reason' => $this->deleted_reason, + 'deleted_by' => $this->deleted_by, + 'deleted_at' => $this->deleted_at?->toISOString(), + 'created_at' => $this->created_at->toISOString(), + 'updated_at' => $this->updated_at->toISOString(), + 'user' => $this->whenLoaded('user', function () { + $user = $this->user; + if ($user === null) { + return null; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ]; + }), + 'article' => $this->whenLoaded('article', function () { + return [ + 'id' => $this->article->id, + 'title' => $this->article->title, + 'slug' => $this->article->slug, + ]; + }), + 'approver' => $this->whenLoaded('approver', function () { + return $this->approver ? [ + 'id' => $this->approver->id, + 'name' => $this->approver->name, + 'email' => $this->approver->email, + ] : null; + }), + 'deleted_by_user' => $this->whenLoaded('deletedBy', function () { + return $this->deletedBy ? [ + 'id' => $this->deletedBy->id, + 'name' => $this->deletedBy->name, + 'email' => $this->deletedBy->email, + ] : null; + }), + 'replies_count' => $this->replies_count ?? 0, 'replies' => CommentResource::collection($this->whenLoaded('replies_page')), ]; } diff --git a/app/Http/Resources/V1/Newsletter/NewsletterSubscriberResource.php b/app/Http/Resources/V1/Newsletter/NewsletterSubscriberResource.php new file mode 100644 index 0000000..101e9b5 --- /dev/null +++ b/app/Http/Resources/V1/Newsletter/NewsletterSubscriberResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'email' => $this->resource->email, + 'user_id' => $this->resource->user_id, + 'is_verified' => $this->resource->is_verified, + 'subscribed_at' => $this->resource->subscribed_at->toISOString(), + 'created_at' => $this->resource->created_at->toISOString(), + 'updated_at' => $this->resource->updated_at->toISOString(), + ]; + } +} diff --git a/app/Http/Resources/V1/Notification/NotificationResource.php b/app/Http/Resources/V1/Notification/NotificationResource.php new file mode 100644 index 0000000..37caece --- /dev/null +++ b/app/Http/Resources/V1/Notification/NotificationResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'type' => $this->resource->type, + 'message' => $this->resource->message, + 'created_at' => $this->resource->created_at->toISOString(), + 'updated_at' => $this->resource->updated_at->toISOString(), + 'audiences' => $this->whenLoaded('audiences', function () { + return $this->resource->audiences->map(function ($audience) { + return [ + 'id' => $audience->id, + 'audience_type' => $audience->audience_type, + 'audience_id' => $audience->audience_id, + ]; + }); + }), + ]; + } +} diff --git a/app/Http/Resources/V1/User/UserResource.php b/app/Http/Resources/V1/User/UserResource.php new file mode 100644 index 0000000..0be8bd2 --- /dev/null +++ b/app/Http/Resources/V1/User/UserResource.php @@ -0,0 +1,49 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'email' => $this->resource->email, + 'avatar_url' => $this->resource->avatar_url, + 'bio' => $this->resource->bio, + 'twitter' => $this->resource->twitter, + 'facebook' => $this->resource->facebook, + 'linkedin' => $this->resource->linkedin, + 'github' => $this->resource->github, + 'website' => $this->resource->website, + 'banned_at' => $this->resource->banned_at, + 'blocked_at' => $this->resource->blocked_at, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'roles' => $this->whenLoaded('roles', function () { + return $this->resource->roles->map(function ($role) { + return [ + 'id' => $role->id, + 'name' => $role->name, + 'slug' => $role->slug, + ]; + }); + }), + ]; + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index d793047..bfca562 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -25,8 +25,24 @@ * @property string|null $meta_title * @property string|null $meta_description * @property int $created_by - * @property int $approved_by + * @property int|null $approved_by * @property int|null $updated_by + * @property bool $is_featured + * @property bool $is_pinned + * @property \Illuminate\Support\Carbon|null $featured_at + * @property \Illuminate\Support\Carbon|null $pinned_at + * @property int $report_count + * @property \Illuminate\Support\Carbon|null $last_reported_at + * @property string|null $report_reason + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read User $author + * @property-read User|null $approver + * @property-read User|null $updater + * @property-read \Illuminate\Database\Eloquent\Collection $comments + * @property-read \Illuminate\Database\Eloquent\Collection $categories + * @property-read \Illuminate\Database\Eloquent\Collection $tags + * @property-read \Illuminate\Database\Eloquent\Collection $authors * * @mixin \Eloquent * @@ -48,6 +64,11 @@ protected function casts(): array return [ 'published_at' => 'datetime', 'status' => ArticleStatus::class, + 'featured_at' => 'datetime', + 'pinned_at' => 'datetime', + 'last_reported_at' => 'datetime', + 'is_featured' => 'boolean', + 'is_pinned' => 'boolean', ]; } diff --git a/app/Models/ArticleAuthor.php b/app/Models/ArticleAuthor.php index 31c51b0..75657a7 100644 --- a/app/Models/ArticleAuthor.php +++ b/app/Models/ArticleAuthor.php @@ -13,6 +13,8 @@ * @property int $article_id * @property int $user_id * @property ArticleAuthorRole|null $role + * @property-read Article $article + * @property-read User $user * * @mixin \Eloquent * diff --git a/app/Models/ArticleCategory.php b/app/Models/ArticleCategory.php index 686e623..0567611 100644 --- a/app/Models/ArticleCategory.php +++ b/app/Models/ArticleCategory.php @@ -11,6 +11,8 @@ /** * @property int $article_id * @property int $category_id + * @property-read Article $article + * @property-read Category $category * * @mixin \Eloquent * diff --git a/app/Models/ArticleTag.php b/app/Models/ArticleTag.php index ac97954..0d66d4e 100644 --- a/app/Models/ArticleTag.php +++ b/app/Models/ArticleTag.php @@ -11,6 +11,8 @@ /** * @property int $article_id * @property int $tag_id + * @property-read Article $article + * @property-read Tag $tag * * @mixin \Eloquent * diff --git a/app/Models/Category.php b/app/Models/Category.php index 6db76c1..a38f7f9 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -12,6 +12,9 @@ * @property int $id * @property string $name * @property string $slug + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $articles * * @mixin \Eloquent * diff --git a/app/Models/Comment.php b/app/Models/Comment.php index cb57032..a986fee 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -4,26 +4,46 @@ namespace App\Models; +use App\Enums\CommentStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $id * @property int $article_id - * @property int $user_id + * @property int|null $user_id * @property string $content * @property int|null $parent_comment_id + * @property CommentStatus $status + * @property \Illuminate\Support\Carbon|null $approved_at + * @property int|null $approved_by + * @property int $report_count + * @property \Illuminate\Support\Carbon|null $last_reported_at + * @property string|null $report_reason + * @property string|null $moderator_notes + * @property string|null $admin_note + * @property string|null $deleted_reason + * @property int|null $deleted_by + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at * @property-read int $replies_count - * @property-read \Illuminate\Database\Eloquent\Collection|null $replies_page + * @property-read \Illuminate\Database\Eloquent\Collection|null $replies_page + * @property-read Article $article + * @property-read User|null $user + * @property-read User|null $approver + * @property-read User|null $deletedBy + * @property-read \Illuminate\Database\Eloquent\Collection $replies * * @mixin \Eloquent * * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ -final class Comment extends Model +class Comment extends Model { - use HasFactory; + use HasFactory, SoftDeletes; protected $guarded = []; @@ -34,7 +54,12 @@ final class Comment extends Model */ protected function casts(): array { - return []; + return [ + 'status' => CommentStatus::class, + 'approved_at' => 'datetime', + 'last_reported_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; } /** @@ -71,4 +96,26 @@ public function replies(): \Illuminate\Database\Eloquent\Relations\HasMany return $relation; } + + /** + * @return BelongsTo + */ + public function approver(): BelongsTo + { + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class, 'approved_by'); + + return $relation; + } + + /** + * @return BelongsTo + */ + public function deletedBy(): BelongsTo + { + /** @var BelongsTo $relation */ + $relation = $this->belongsTo(User::class, 'deleted_by'); + + return $relation; + } } diff --git a/app/Models/NewsletterSubscriber.php b/app/Models/NewsletterSubscriber.php index d320d2b..f5d469d 100644 --- a/app/Models/NewsletterSubscriber.php +++ b/app/Models/NewsletterSubscriber.php @@ -14,12 +14,15 @@ * @property int|null $user_id * @property bool $is_verified * @property \Illuminate\Support\Carbon $subscribed_at + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read User|null $user * * @mixin \Eloquent * * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ -final class NewsletterSubscriber extends Model +class NewsletterSubscriber extends Model { use HasFactory; diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 82bd78c..39d2fb8 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -7,17 +7,21 @@ use App\Enums\NotificationType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id * @property NotificationType $type * @property array $message + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $audiences * * @mixin \Eloquent * * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ -final class Notification extends Model +class Notification extends Model { use HasFactory; @@ -35,4 +39,17 @@ protected function casts(): array 'type' => NotificationType::class, ]; } + + /** + * Get the audiences for the notification. + * + * @return HasMany + */ + public function audiences(): HasMany + { + /** @var HasMany $relation */ + $relation = $this->hasMany(NotificationAudience::class); + + return $relation; + } } diff --git a/app/Models/NotificationAudience.php b/app/Models/NotificationAudience.php index b0d3d94..b29eb90 100644 --- a/app/Models/NotificationAudience.php +++ b/app/Models/NotificationAudience.php @@ -11,7 +11,12 @@ /** * @property int $id * @property int $notification_id - * @property int $user_id + * @property string $audience_type + * @property int|null $audience_id + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read Notification $notification + * @property-read User|null $user * * @mixin \Eloquent * @@ -45,12 +50,14 @@ public function notification(): BelongsTo } /** + * Get the user for this audience (if audience_type is 'user') + * * @return BelongsTo */ public function user(): BelongsTo { /** @var BelongsTo $relation */ - $relation = $this->belongsTo(User::class); + $relation = $this->belongsTo(User::class, 'audience_id'); return $relation; } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 4c29cf2..e62eb7c 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -6,11 +6,15 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; /** * @property int $id * @property string $name * @property string $slug + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $roles * * @mixin \Eloquent * @@ -31,4 +35,17 @@ protected function casts(): array { return []; } + + /** + * The roles that belong to the permission. + * + * @return BelongsToMany + */ + public function roles(): BelongsToMany + { + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(Role::class); + + return $relation; + } } diff --git a/app/Models/Role.php b/app/Models/Role.php index fae8a64..0ba1ee0 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -12,6 +12,10 @@ * @property int $id * @property string $name * @property string $slug + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $permissions + * @property-read \Illuminate\Database\Eloquent\Collection $users * * @mixin \Eloquent * @@ -45,4 +49,17 @@ public function permissions(): BelongsToMany return $relation; } + + /** + * The users that belong to the role. + * + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + /** @var BelongsToMany $relation */ + $relation = $this->belongsToMany(User::class); + + return $relation; + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 821d280..a756392 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -12,6 +12,9 @@ * @property int $id * @property string $name * @property string $slug + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $articles * * @mixin \Eloquent * diff --git a/app/Models/User.php b/app/Models/User.php index 1e10395..82044fc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -14,7 +15,7 @@ * @property int $id * @property string $name * @property string $email - * @property string|null $email_verified_at + * @property \Illuminate\Support\Carbon|null $email_verified_at * @property string $password * @property string|null $remember_token * @property string|null $avatar_url @@ -24,18 +25,24 @@ * @property string|null $linkedin * @property string|null $github * @property string|null $website + * @property \Illuminate\Support\Carbon|null $banned_at + * @property \Illuminate\Support\Carbon|null $blocked_at + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at * @property string|null $token Dynamic property set by auth service - * @property string|null $access_token * @property string|null $refresh_token * @property (\Illuminate\Support\Carbon|\Carbon\CarbonImmutable)|null $access_token_expires_at * @property (\Illuminate\Support\Carbon|\Carbon\CarbonImmutable)|null $refresh_token_expires_at + * @property-read \Illuminate\Database\Eloquent\Collection $roles + * @property-read \Illuminate\Database\Eloquent\Collection $articles + * @property-read \Illuminate\Database\Eloquent\Collection $comments * * @mixin \Eloquent * * @phpstan-use \Illuminate\Database\Eloquent\Factories\HasFactory */ -final class User extends Authenticatable +class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable; @@ -48,6 +55,15 @@ final class User extends Authenticatable 'name', 'email', 'password', + 'avatar_url', + 'bio', + 'twitter', + 'facebook', + 'linkedin', + 'github', + 'website', + 'banned_at', + 'blocked_at', ]; /** @@ -70,6 +86,8 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'banned_at' => 'datetime', + 'blocked_at' => 'datetime', ]; } @@ -99,14 +117,54 @@ public function hasRole(string $role): bool */ public function hasPermission(string $permission): bool { - foreach ($this->roles as $role) { - /** @var \Illuminate\Database\Eloquent\Collection $permissions */ - $permissions = $role->permissions; - if ($permissions->contains('name', $permission)) { - return true; + try { + // Load roles with permissions to avoid N+1 queries + $this->load('roles.permissions'); + + foreach ($this->roles as $role) { + /** @var \Illuminate\Database\Eloquent\Collection $permissions */ + $permissions = $role->permissions; + if ($permissions->contains('name', $permission)) { + return true; + } } + + return false; + } catch (\Throwable $e) { + \Log::error('hasPermission error', [ + 'user_id' => $this->id, + 'permission' => $permission, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return false; } + } + + /** + * Get the articles created by the user. + * + * @return HasMany + */ + public function articles(): HasMany + { + /** @var HasMany $relation */ + $relation = $this->hasMany(Article::class, 'created_by'); - return false; + return $relation; + } + + /** + * Get the comments created by the user. + * + * @return HasMany + */ + public function comments(): HasMany + { + /** @var HasMany $relation */ + $relation = $this->hasMany(Comment::class, 'user_id'); + + return $relation; } } diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php index 41a62ad..53282cc 100644 --- a/app/Models/UserNotification.php +++ b/app/Models/UserNotification.php @@ -13,6 +13,10 @@ * @property int $user_id * @property int $notification_id * @property bool $is_read + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-read User $user + * @property-read Notification $notification * * @mixin \Eloquent * diff --git a/app/Services/ArticleManagementService.php b/app/Services/ArticleManagementService.php new file mode 100644 index 0000000..6c66f73 --- /dev/null +++ b/app/Services/ArticleManagementService.php @@ -0,0 +1,325 @@ + $params + * @return LengthAwarePaginator + */ + public function getArticles(array $params): LengthAwarePaginator + { + $query = Article::query() + ->with(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']) + ->withCount(['comments', 'authors']); + + // Apply filters + $this->applyFilters($query, $params); + + // Apply sorting + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDirection = $params['sort_direction'] ?? 'desc'; + $query->orderBy((string) $sortBy, (string) $sortDirection); + + // Apply pagination + $perPage = $params['per_page'] ?? 15; + $page = $params['page'] ?? 1; + + return $query->paginate((int) $perPage, ['*'], 'page', (int) $page); + } + + /** + * Get a single article by ID for admin management + */ + public function getArticleById(int $id): Article + { + return Article::query() + ->with(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug', 'comments.user:id,name,email']) + ->withCount(['comments', 'authors']) + ->findOrFail($id); + } + + /** + * Approve an article + */ + public function approveArticle(int $id, int $approvedBy): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::PUBLISHED, + 'approved_by' => $approvedBy, + 'published_at' => now(), + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Reject an article (set to draft) + */ + public function rejectArticle(int $id, int $rejectedBy): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::DRAFT, + 'approved_by' => $rejectedBy, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Feature an article + */ + public function featureArticle(int $id): Article + { + try { + $article = Article::findOrFail($id); + $newFeaturedStatus = ! $article->is_featured; + $article->update([ + 'is_featured' => $newFeaturedStatus, + 'featured_at' => $newFeaturedStatus ? now() : null, + ]); + + /** @var Article $freshArticle */ + $freshArticle = $article->fresh(); + + return $freshArticle; + } catch (\Throwable $e) { + \Log::error('FeatureArticle error', [ + 'id' => $id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + } + + /** + * Unfeature an article + */ + public function unfeatureArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'is_featured' => false, + 'featured_at' => null, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Pin an article + */ + public function pinArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'is_pinned' => true, + 'pinned_at' => now(), + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Unpin an article + */ + public function unpinArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'is_pinned' => false, + 'pinned_at' => null, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Archive an article + */ + public function archiveArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::ARCHIVED, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Restore an article from archive + */ + public function restoreArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::PUBLISHED, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Trash an article + */ + public function trashArticle(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::TRASHED, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Restore an article from trash + */ + public function restoreFromTrash(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'status' => ArticleStatus::DRAFT, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Permanently delete an article + */ + public function deleteArticle(int $id): bool + { + $article = Article::findOrFail($id); + + /** @var bool $deleted */ + $deleted = $article->delete(); + + return $deleted; + } + + /** + * Report an article + */ + public function reportArticle(int $id, string $reason): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'report_count' => $article->report_count + 1, + 'last_reported_at' => now(), + 'report_reason' => $reason, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Clear article reports + */ + public function clearArticleReports(int $id): Article + { + $article = Article::findOrFail($id); + $article->update([ + 'report_count' => 0, + 'last_reported_at' => null, + 'report_reason' => null, + ]); + + return $article->load(['author:id,name,email', 'approver:id,name,email', 'updater:id,name,email', 'categories:id,name,slug', 'tags:id,name,slug']); + } + + /** + * Apply filters to the query + * + * @param Builder
$query + * @param array $params + */ + private function applyFilters(Builder $query, array $params): void + { + // Search in title and content + if (! empty($params['search'])) { + /** @var mixed $searchParam */ + $searchParam = $params['search']; + $searchTerm = (string) $searchParam; + $query->where(function (Builder $q) use ($searchTerm) { + $q->where('title', 'like', "%{$searchTerm}%") + ->orWhere('content_markdown', 'like', "%{$searchTerm}%") + ->orWhere('excerpt', 'like', "%{$searchTerm}%"); + }); + } + + // Filter by status + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // Filter by author + if (! empty($params['author_id'])) { + $query->where('created_by', (int) $params['author_id']); + } + + // Filter by category + if (! empty($params['category_id'])) { + $query->whereHas('categories', function (Builder $q) use ($params) { + $q->where('categories.id', (int) $params['category_id']); + }); + } + + // Filter by tag + if (! empty($params['tag_id'])) { + $query->whereHas('tags', function (Builder $q) use ($params) { + $q->where('tags.id', (int) $params['tag_id']); + }); + } + + // Filter by featured status + if (isset($params['is_featured'])) { + $query->where('is_featured', (bool) $params['is_featured']); + } + + // Filter by pinned status + if (isset($params['is_pinned'])) { + $query->where('is_pinned', (bool) $params['is_pinned']); + } + + // Filter by reported articles + if (isset($params['has_reports'])) { + if ((bool) $params['has_reports']) { + $query->where('report_count', '>', 0); + } else { + $query->where('report_count', 0); + } + } + + // Filter by date range + if (! empty($params['created_after'])) { + $query->where('created_at', '>=', $params['created_after']); + } + + if (! empty($params['created_before'])) { + $query->where('created_at', '<=', $params['created_before']); + } + + if (! empty($params['published_after'])) { + $query->where('published_at', '>=', $params['published_after']); + } + + if (! empty($params['published_before'])) { + $query->where('published_at', '<=', $params['published_before']); + } + } +} diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 95f0ca5..2a2137d 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -24,6 +24,8 @@ public function getArticles(array $params): LengthAwarePaginator $query = Article::query() ->with([ 'author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', + 'approver:id,name,email,avatar_url', + 'updater:id,name,email,avatar_url', 'categories:id,name,slug', 'tags:id,name,slug', 'authors:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', @@ -53,6 +55,8 @@ public function getArticleBySlug(string $slug): Article return Article::query() ->with([ 'author:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', + 'approver:id,name,email,avatar_url', + 'updater:id,name,email,avatar_url', 'categories:id,name,slug', 'tags:id,name,slug', 'authors:id,name,email,avatar_url,bio,twitter,facebook,linkedin,github,website', diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php new file mode 100644 index 0000000..aed27f0 --- /dev/null +++ b/app/Services/CommentService.php @@ -0,0 +1,118 @@ + $data + * + * @throws ModelNotFoundException + */ + public function approveComment(int $commentId, array $data): Comment + { + $comment = Comment::findOrFail($commentId); + + $comment->update([ + 'status' => CommentStatus::APPROVED, + 'admin_note' => $data['admin_note'] ?? null, + 'approved_at' => now(), + 'approved_by' => auth()->id(), + ]); + + return $comment->load(['user', 'article']); + } + + /** + * Delete a comment + * + * @param array $data + * + * @throws ModelNotFoundException + */ + public function deleteComment(int $commentId, array $data): void + { + $comment = Comment::findOrFail($commentId); + + $comment->update([ + 'deleted_reason' => $data['reason'] ?? null, + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + ]); + + // Force delete to completely remove from database + $comment->forceDelete(); + } + + /** + * Get comment by ID + * + * @throws ModelNotFoundException + */ + public function getCommentById(int $commentId): Comment + { + return Comment::with(['user', 'article'])->findOrFail($commentId); + } + + /** + * Get comments with filters + * + * @param array $filters + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getComments(array $filters): \Illuminate\Contracts\Pagination\LengthAwarePaginator + { + $query = Comment::with(['user:id,name,email', 'article:id,title,slug', 'approver:id,name,email', 'deletedBy:id,name,email']); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (isset($filters['search'])) { + /** @var string $searchTerm */ + $searchTerm = $filters['search']; + $query->where('content', 'like', "%{$searchTerm}%"); + } + + if (isset($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + if (isset($filters['article_id'])) { + $query->where('article_id', $filters['article_id']); + } + + if (isset($filters['parent_comment_id'])) { + $query->where('parent_comment_id', $filters['parent_comment_id']); + } + + if (isset($filters['approved_by'])) { + $query->where('approved_by', $filters['approved_by']); + } + + if (isset($filters['has_reports'])) { + if ((bool) $filters['has_reports']) { + $query->where('report_count', '>', 0); + } else { + $query->where('report_count', 0); + } + } + + /** @var string $sortBy */ + $sortBy = $filters['sort_by'] ?? 'created_at'; + /** @var string $sortOrder */ + $sortOrder = $filters['sort_order'] ?? 'desc'; + /** @var int $perPage */ + $perPage = $filters['per_page'] ?? 15; + + return $query->orderBy($sortBy, $sortOrder)->paginate($perPage); + } +} diff --git a/app/Services/NewsletterService.php b/app/Services/NewsletterService.php new file mode 100644 index 0000000..2158cd9 --- /dev/null +++ b/app/Services/NewsletterService.php @@ -0,0 +1,84 @@ + $data + * + * @throws ModelNotFoundException + */ + public function deleteSubscriber(int $subscriberId, array $data): void + { + $subscriber = NewsletterSubscriber::findOrFail($subscriberId); + $subscriber->delete(); + } + + /** + * Get subscriber by ID + * + * @throws ModelNotFoundException + */ + public function getSubscriberById(int $subscriberId): NewsletterSubscriber + { + return NewsletterSubscriber::findOrFail($subscriberId); + } + + /** + * Get subscribers with filters + * + * @param array $filters + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getSubscribers(array $filters): \Illuminate\Contracts\Pagination\LengthAwarePaginator + { + $query = NewsletterSubscriber::query(); + + if (isset($filters['search'])) { + /** @var string $searchTerm */ + $searchTerm = $filters['search']; + $query->where('email', 'like', "%{$searchTerm}%"); + } + + if (isset($filters['status'])) { + if ($filters['status'] === 'verified') { + $query->where('is_verified', true); + } elseif ($filters['status'] === 'unverified') { + $query->where('is_verified', false); + } + } + + if (isset($filters['subscribed_at_from'])) { + $query->where('created_at', '>=', $filters['subscribed_at_from']); + } + + if (isset($filters['subscribed_at_to'])) { + $query->where('created_at', '<=', $filters['subscribed_at_to']); + } + + /** @var string $sortBy */ + $sortBy = $filters['sort_by'] ?? 'created_at'; + /** @var string $sortOrder */ + $sortOrder = $filters['sort_order'] ?? 'desc'; + /** @var int $perPage */ + $perPage = $filters['per_page'] ?? 15; + + return $query->orderBy($sortBy, $sortOrder)->paginate($perPage); + } + + /** + * Get total subscriber count + */ + public function getTotalSubscribers(): int + { + return NewsletterSubscriber::count(); + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..f0fe351 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,151 @@ + $data + */ + public function createNotification(array $data): Notification + { + // Ensure we store the complete message structure + $notification = Notification::create([ + 'type' => $data['type'], + 'message' => $data['message'], // This should contain the complete message structure + ]); + + // Create audience records + if (isset($data['audiences']) && is_array($data['audiences'])) { + foreach ($data['audiences'] as $audience) { + if ($audience === 'specific_users' && isset($data['user_ids']) && is_array($data['user_ids'])) { + foreach ($data['user_ids'] as $userId) { + NotificationAudience::create([ + 'notification_id' => $notification->id, + 'audience_type' => 'user', + 'audience_id' => $userId, + ]); + } + } elseif ($audience === 'administrators') { + $adminRole = Role::where('name', 'administrator')->first(); + if ($adminRole) { + NotificationAudience::create([ + 'notification_id' => $notification->id, + 'audience_type' => 'role', + 'audience_id' => $adminRole->id, + ]); + } + } elseif ($audience === 'all_users') { + NotificationAudience::create([ + 'notification_id' => $notification->id, + 'audience_type' => 'all', + 'audience_id' => null, + ]); + } + } + } + + return $notification->load('audiences'); + } + + /** + * Send a notification + */ + public function sendNotification(Notification $notification): void + { + // Here you would implement the actual sending logic + // This could involve: + // - Sending emails + // - Sending push notifications + // - Sending SMS + // - Creating in-app notifications + // - etc. + } + + /** + * Get notification by ID + * + * @throws ModelNotFoundException + */ + public function getNotificationById(int $notificationId): Notification + { + return Notification::with(['audiences'])->findOrFail($notificationId); + } + + /** + * Get notifications with filters + * + * @param array $filters + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getNotifications(array $filters): \Illuminate\Contracts\Pagination\LengthAwarePaginator + { + $query = Notification::query(); + + if (isset($filters['search'])) { + /** @var string $searchTerm */ + $searchTerm = $filters['search']; + $query->where(function ($q) use ($searchTerm) { + $q->whereRaw("JSON_EXTRACT(message, '$.title') LIKE ?", ["%{$searchTerm}%"]) + ->orWhereRaw("JSON_EXTRACT(message, '$.body') LIKE ?", ["%{$searchTerm}%"]); + }); + } + + if (isset($filters['type'])) { + $query->where('type', $filters['type']); + } + + if (isset($filters['created_at_from'])) { + $query->where('created_at', '>=', $filters['created_at_from']); + } + + if (isset($filters['created_at_to'])) { + $query->where('created_at', '<=', $filters['created_at_to']); + } + + /** @var string $sortBy */ + $sortBy = $filters['sort_by'] ?? 'created_at'; + /** @var string $sortOrder */ + $sortOrder = $filters['sort_order'] ?? 'desc'; + /** @var int $perPage */ + $perPage = $filters['per_page'] ?? 15; + + return $query->orderBy($sortBy, $sortOrder)->paginate($perPage); + } + + /** + * Get total notification count + */ + public function getTotalNotifications(): int + { + return Notification::count(); + } + + /** + * Get notification statistics + * + * @return array> + */ + public function getNotificationStats(): array + { + return [ + 'total' => Notification::count(), + 'by_type' => [ + 'article_published' => Notification::where('type', NotificationType::ARTICLE_PUBLISHED)->count(), + 'new_comment' => Notification::where('type', NotificationType::NEW_COMMENT)->count(), + 'newsletter' => Notification::where('type', NotificationType::NEWSLETTER)->count(), + 'system_alert' => Notification::where('type', NotificationType::SYSTEM_ALERT)->count(), + ], + ]; + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 0000000..1e62ac3 --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,266 @@ + $params + * @return LengthAwarePaginator + */ + public function getUsers(array $params): LengthAwarePaginator + { + $query = User::query() + ->with(['roles:id,name,slug']) + ->withCount(['articles', 'comments']); + + // Apply filters + $this->applyFilters($query, $params); + + // Apply sorting + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDirection = $params['sort_direction'] ?? 'desc'; + $query->orderBy((string) $sortBy, (string) $sortDirection); + + // Apply pagination + $perPage = $params['per_page'] ?? 15; + $page = $params['page'] ?? 1; + + return $query->paginate((int) $perPage, ['*'], 'page', (int) $page); + } + + /** + * Get a single user by ID + */ + public function getUserById(int $id): User + { + return User::query() + ->with(['roles:id,name,slug']) + ->withCount(['articles', 'comments']) + ->findOrFail($id); + } + + /** + * Create a new user + * + * @param array $data + */ + public function createUser(array $data): User + { + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make((string) $data['password']), + 'avatar_url' => $data['avatar_url'] ?? null, + 'bio' => $data['bio'] ?? null, + 'twitter' => $data['twitter'] ?? null, + 'facebook' => $data['facebook'] ?? null, + 'linkedin' => $data['linkedin'] ?? null, + 'github' => $data['github'] ?? null, + 'website' => $data['website'] ?? null, + 'banned_at' => $data['banned_at'] ?? null, + 'blocked_at' => $data['blocked_at'] ?? null, + ]); + + // Assign default role if specified + if (isset($data['role_id'])) { + /** @var Role $role */ + $role = Role::findOrFail($data['role_id']); + $user->roles()->attach($role->id); + } + + return $user->load(['roles:id,name,slug']); + } + + /** + * Update an existing user + * + * @param array $data + */ + public function updateUser(int $id, array $data): User + { + $user = User::findOrFail($id); + + $updateData = [ + 'name' => array_key_exists('name', $data) ? $data['name'] : $user->name, + 'email' => array_key_exists('email', $data) ? $data['email'] : $user->email, + 'avatar_url' => array_key_exists('avatar_url', $data) ? $data['avatar_url'] : $user->avatar_url, + 'bio' => array_key_exists('bio', $data) ? $data['bio'] : $user->bio, + 'twitter' => array_key_exists('twitter', $data) ? $data['twitter'] : $user->twitter, + 'facebook' => array_key_exists('facebook', $data) ? $data['facebook'] : $user->facebook, + 'linkedin' => array_key_exists('linkedin', $data) ? $data['linkedin'] : $user->linkedin, + 'github' => array_key_exists('github', $data) ? $data['github'] : $user->github, + 'website' => array_key_exists('website', $data) ? $data['website'] : $user->website, + 'banned_at' => array_key_exists('banned_at', $data) ? $data['banned_at'] : $user->banned_at, + 'blocked_at' => array_key_exists('blocked_at', $data) ? $data['blocked_at'] : $user->blocked_at, + ]; + + // Update password if provided + if (isset($data['password'])) { + $updateData['password'] = Hash::make((string) $data['password']); + } + + $user->update($updateData); + + // Update roles if specified + if (isset($data['role_ids'])) { + /** @var array $roleIds */ + $roleIds = $data['role_ids']; + $user->roles()->sync($roleIds); + } + + return $user->load(['roles:id,name,slug'])->loadCount(['articles', 'comments']); + } + + /** + * Delete a user + */ + public function deleteUser(int $id): bool + { + $user = User::findOrFail($id); + + /** @var bool $deleted */ + $deleted = $user->delete(); + + return $deleted; + } + + /** + * Ban a user + */ + public function banUser(int $id): User + { + $user = User::findOrFail($id); + $user->update(['banned_at' => now()]); + + return $user->load(['roles:id,name,slug'])->loadCount(['articles', 'comments']); + } + + /** + * Unban a user + */ + public function unbanUser(int $id): User + { + $user = User::findOrFail($id); + $user->update(['banned_at' => null]); + + return $user->load(['roles:id,name,slug'])->loadCount(['articles', 'comments']); + } + + /** + * Block a user + */ + public function blockUser(int $id): User + { + $user = User::findOrFail($id); + $user->update(['blocked_at' => now()]); + + return $user->load(['roles:id,name,slug'])->loadCount(['articles', 'comments']); + } + + /** + * Unblock a user + */ + public function unblockUser(int $id): User + { + $user = User::findOrFail($id); + $user->update(['blocked_at' => null]); + + return $user->load(['roles:id,name,slug'])->loadCount(['articles', 'comments']); + } + + /** + * Get all roles + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getAllRoles(): \Illuminate\Database\Eloquent\Collection + { + return Role::query()->with(['permissions:id,name,slug'])->get(); + } + + /** + * Get all permissions + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getAllPermissions(): \Illuminate\Database\Eloquent\Collection + { + return Permission::query()->get(); + } + + /** + * Assign roles to user + * + * @param array $roleIds + */ + public function assignRoles(int $userId, array $roleIds): User + { + $user = User::findOrFail($userId); + $user->roles()->sync($roleIds); + + return $user->load(['roles:id,name,slug']); + } + + /** + * Apply filters to the query + * + * @param Builder $query + * @param array $params + */ + private function applyFilters(Builder $query, array $params): void + { + // Search in name and email + if (! empty($params['search'])) { + /** @var mixed $searchParam */ + $searchParam = $params['search']; + $searchTerm = (string) $searchParam; + $query->where(function (Builder $q) use ($searchTerm) { + $q->where('name', 'like', "%{$searchTerm}%") + ->orWhere('email', 'like', "%{$searchTerm}%"); + }); + } + + // Filter by role + if (! empty($params['role_id'])) { + $query->whereHas('roles', function (Builder $q) use ($params) { + $q->where('roles.id', (int) $params['role_id']); + }); + } + + // Filter by status + if (! empty($params['status'])) { + switch ($params['status']) { + case 'banned': + $query->whereNotNull('banned_at'); + break; + case 'blocked': + $query->whereNotNull('blocked_at'); + break; + case 'active': + $query->whereNull('banned_at')->whereNull('blocked_at'); + break; + } + } + + // Filter by date range + if (! empty($params['created_after'])) { + $query->where('created_at', '>=', $params['created_after']); + } + + if (! empty($params['created_before'])) { + $query->where('created_at', '<=', $params['created_before']); + } + } +} diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index e591601..0d45724 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -42,8 +42,15 @@ public function definition(): array 'meta_title' => $this->faker->optional()->sentence, 'meta_description' => $this->faker->optional()->text(200), 'created_by' => User::factory(), - 'approved_by' => User::factory(), - 'updated_by' => User::factory(), + 'approved_by' => null, // Will be set based on status + 'updated_by' => null, + 'is_featured' => false, + 'is_pinned' => false, + 'featured_at' => null, + 'pinned_at' => null, + 'report_count' => 0, + 'last_reported_at' => null, + 'report_reason' => null, ]; } @@ -55,6 +62,7 @@ public function published(): static return $this->state(fn (array $attributes) => [ 'status' => ArticleStatus::PUBLISHED->value, 'published_at' => now(), + 'approved_by' => User::factory(), ]); } @@ -66,6 +74,55 @@ public function draft(): static return $this->state(fn (array $attributes) => [ 'status' => ArticleStatus::DRAFT->value, 'published_at' => null, + 'approved_by' => null, + ]); + } + + /** + * Indicate that the article is under review. + */ + public function review(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ArticleStatus::REVIEW->value, + 'published_at' => null, + 'approved_by' => null, + ]); + } + + /** + * Indicate that the article is scheduled. + */ + public function scheduled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ArticleStatus::SCHEDULED->value, + 'published_at' => $this->faker->dateTimeBetween('+1 day', '+1 month'), + 'approved_by' => User::factory(), + ]); + } + + /** + * Indicate that the article is archived. + */ + public function archived(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ArticleStatus::ARCHIVED->value, + 'published_at' => $this->faker->dateTimeBetween('-1 year', '-1 day'), + 'approved_by' => User::factory(), + ]); + } + + /** + * Indicate that the article is trashed. + */ + public function trashed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ArticleStatus::TRASHED->value, + 'published_at' => null, + 'approved_by' => null, ]); } } diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php index bc84e0e..805bff0 100644 --- a/database/factories/CommentFactory.php +++ b/database/factories/CommentFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\CommentStatus; use App\Models\Article; use App\Models\Comment; use App\Models\User; @@ -33,6 +34,17 @@ public function definition(): array 'user_id' => User::factory(), 'content' => $this->faker->paragraph, 'parent_comment_id' => null, + 'status' => CommentStatus::PENDING->value, + 'approved_at' => null, + 'approved_by' => null, + 'report_count' => 0, + 'last_reported_at' => null, + 'report_reason' => null, + 'moderator_notes' => null, + 'admin_note' => null, + 'deleted_reason' => null, + 'deleted_by' => null, + 'deleted_at' => null, ]; } @@ -45,4 +57,40 @@ public function reply(int $parentId = 1): static 'parent_comment_id' => $parentId, ]); } + + /** + * Indicate that the comment is approved. + */ + public function approved(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CommentStatus::APPROVED->value, + 'approved_at' => now(), + 'approved_by' => User::factory(), + ]); + } + + /** + * Indicate that the comment is rejected. + */ + public function rejected(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CommentStatus::REJECTED->value, + 'approved_at' => null, + 'approved_by' => null, + ]); + } + + /** + * Indicate that the comment is spam. + */ + public function spam(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => CommentStatus::SPAM->value, + 'approved_at' => null, + 'approved_by' => null, + ]); + } } diff --git a/database/factories/NotificationAudienceFactory.php b/database/factories/NotificationAudienceFactory.php index 4e093cc..bdad01b 100644 --- a/database/factories/NotificationAudienceFactory.php +++ b/database/factories/NotificationAudienceFactory.php @@ -27,7 +27,8 @@ public function definition(): array { return [ 'notification_id' => Notification::factory(), - 'user_id' => User::factory(), + 'audience_type' => 'user', + 'audience_id' => User::factory(), ]; } } diff --git a/database/factories/PermissionFactory.php b/database/factories/PermissionFactory.php index 589d9dc..24efbc3 100644 --- a/database/factories/PermissionFactory.php +++ b/database/factories/PermissionFactory.php @@ -23,6 +23,7 @@ public function definition(): array { return [ 'name' => $this->faker->unique()->word(), + 'slug' => $this->faker->unique()->slug(), ]; } diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php index 59c6e6d..e86c3f2 100644 --- a/database/factories/RoleFactory.php +++ b/database/factories/RoleFactory.php @@ -24,6 +24,7 @@ public function definition(): array { return [ 'name' => $this->faker->unique()->word(), + 'slug' => $this->faker->unique()->slug(), ]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 0b4ff97..6a4887c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -46,6 +46,8 @@ public function definition(): array 'linkedin' => fake()->userName(), 'github' => fake()->userName(), 'website' => fake()->url(), + 'banned_at' => null, + 'blocked_at' => null, ]; } @@ -68,4 +70,24 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + /** + * Indicate that the user is banned. + */ + public function banned(): static + { + return $this->state(fn (array $attributes) => [ + 'banned_at' => now(), + ]); + } + + /** + * Indicate that the user is blocked. + */ + public function blocked(): static + { + return $this->state(fn (array $attributes) => [ + 'blocked_at' => now(), + ]); + } } diff --git a/database/migrations/2025_07_05_000000_add_banned_blocked_fields_to_users_table.php b/database/migrations/2025_07_05_000000_add_banned_blocked_fields_to_users_table.php new file mode 100644 index 0000000..938767e --- /dev/null +++ b/database/migrations/2025_07_05_000000_add_banned_blocked_fields_to_users_table.php @@ -0,0 +1,29 @@ +timestamp('banned_at')->nullable()->after('website'); + $table->timestamp('blocked_at')->nullable()->after('banned_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['banned_at', 'blocked_at']); + }); + } +}; diff --git a/database/migrations/2025_07_05_000001_add_admin_fields_to_articles_table.php b/database/migrations/2025_07_05_000001_add_admin_fields_to_articles_table.php new file mode 100644 index 0000000..e41e108 --- /dev/null +++ b/database/migrations/2025_07_05_000001_add_admin_fields_to_articles_table.php @@ -0,0 +1,42 @@ +boolean('is_featured')->default(false)->after('status'); + $table->boolean('is_pinned')->default(false)->after('is_featured'); + $table->timestamp('featured_at')->nullable()->after('is_pinned'); + $table->timestamp('pinned_at')->nullable()->after('featured_at'); + $table->integer('report_count')->default(0)->after('pinned_at'); + $table->timestamp('last_reported_at')->nullable()->after('report_count'); + $table->text('report_reason')->nullable()->after('last_reported_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn([ + 'is_featured', + 'is_pinned', + 'featured_at', + 'pinned_at', + 'report_count', + 'last_reported_at', + 'report_reason', + ]); + }); + } +}; diff --git a/database/migrations/2025_07_05_000002_update_articles_status_enum.php b/database/migrations/2025_07_05_000002_update_articles_status_enum.php new file mode 100644 index 0000000..7b21108 --- /dev/null +++ b/database/migrations/2025_07_05_000002_update_articles_status_enum.php @@ -0,0 +1,25 @@ +foreignId('approved_by')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->foreignId('approved_by')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_07_05_000004_add_admin_fields_to_comments_table.php b/database/migrations/2025_07_05_000004_add_admin_fields_to_comments_table.php new file mode 100644 index 0000000..a8fd1c7 --- /dev/null +++ b/database/migrations/2025_07_05_000004_add_admin_fields_to_comments_table.php @@ -0,0 +1,43 @@ +enum('status', ['pending', 'approved', 'rejected', 'spam'])->default('pending')->after('content'); + $table->timestamp('approved_at')->nullable()->after('status'); + $table->foreignId('approved_by')->nullable()->constrained('users')->after('approved_at'); + $table->integer('report_count')->default(0)->after('approved_by'); + $table->timestamp('last_reported_at')->nullable()->after('report_count'); + $table->text('report_reason')->nullable()->after('last_reported_at'); + $table->text('moderator_notes')->nullable()->after('report_reason'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->dropForeign(['approved_by']); + $table->dropColumn([ + 'status', + 'approved_at', + 'approved_by', + 'report_count', + 'last_reported_at', + 'report_reason', + 'moderator_notes', + ]); + }); + } +}; diff --git a/database/migrations/2025_07_05_000005_add_missing_fields_to_comments_table.php b/database/migrations/2025_07_05_000005_add_missing_fields_to_comments_table.php new file mode 100644 index 0000000..bf572eb --- /dev/null +++ b/database/migrations/2025_07_05_000005_add_missing_fields_to_comments_table.php @@ -0,0 +1,37 @@ +text('admin_note')->nullable()->after('moderator_notes'); + $table->text('deleted_reason')->nullable()->after('admin_note'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->after('deleted_reason'); + $table->timestamp('deleted_at')->nullable()->after('deleted_by'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->dropForeign(['deleted_by']); + $table->dropColumn([ + 'admin_note', + 'deleted_reason', + 'deleted_by', + 'deleted_at', + ]); + }); + } +}; diff --git a/lang/en/common.php b/lang/en/common.php index df682c0..698d963 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -10,7 +10,183 @@ | */ + // General Messages 'something_went_wrong' => 'Something went wrong! Try again later.', 'success' => 'Response returned successfully.', + 'error' => 'An error occurred while processing your request.', 'not_found' => 'Resource not found.', + 'unauthorized' => 'You are not authorized to perform this action.', + 'forbidden' => 'Access denied.', + 'validation_failed' => 'The provided data is invalid.', + 'unexpected_response_format' => 'Unexpected response format from resource collection.', + + // User Management + 'user_not_found' => 'User not found.', + 'user_created_successfully' => 'User created successfully.', + 'user_updated_successfully' => 'User updated successfully.', + 'user_deleted_successfully' => 'User deleted successfully.', + 'user_banned_successfully' => 'User banned successfully.', + 'user_unbanned_successfully' => 'User unbanned successfully.', + 'user_blocked_successfully' => 'User blocked successfully.', + 'user_unblocked_successfully' => 'User unblocked successfully.', + 'profile_updated_successfully' => 'Profile updated successfully.', + + // Article Management + 'article_not_found' => 'Article not found.', + 'article_created_successfully' => 'Article created successfully.', + 'article_updated_successfully' => 'Article updated successfully.', + 'article_deleted_successfully' => 'Article deleted successfully.', + 'article_approved_successfully' => 'Article approved successfully.', + 'article_rejected_successfully' => 'Article rejected successfully.', + 'article_featured_successfully' => 'Article featured successfully.', + 'article_unfeatured_successfully' => 'Article unfeatured successfully.', + 'article_pinned_successfully' => 'Article pinned successfully.', + 'article_unpinned_successfully' => 'Article unpinned successfully.', + 'article_archived_successfully' => 'Article archived successfully.', + 'article_restored_successfully' => 'Article restored successfully.', + 'article_trashed_successfully' => 'Article moved to trash successfully.', + 'article_restored_from_trash_successfully' => 'Article restored from trash successfully.', + 'article_reported_successfully' => 'Article reported successfully.', + 'article_reports_cleared_successfully' => 'Article reports cleared successfully.', + + // Comment Management + 'comment_not_found' => 'Comment not found.', + 'comment_created_successfully' => 'Comment created successfully.', + 'comment_updated_successfully' => 'Comment updated successfully.', + 'comment_deleted_successfully' => 'Comment deleted successfully.', + 'comment_approved_successfully' => 'Comment approved successfully.', + 'comment_rejected_successfully' => 'Comment rejected successfully.', + 'comment_reported_successfully' => 'Comment reported successfully.', + 'comment_reports_cleared_successfully' => 'Comment reports cleared successfully.', + // Additional comment keys for consistency + 'comment_deleted' => 'Comment deleted successfully.', + 'comment_approved' => 'Comment approved successfully.', + + // Category Management + 'category_not_found' => 'Category not found.', + 'category_created_successfully' => 'Category created successfully.', + 'category_updated_successfully' => 'Category updated successfully.', + 'category_deleted_successfully' => 'Category deleted successfully.', + + // Tag Management + 'tag_not_found' => 'Tag not found.', + 'tag_created_successfully' => 'Tag created successfully.', + 'tag_updated_successfully' => 'Tag updated successfully.', + 'tag_deleted_successfully' => 'Tag deleted successfully.', + + // Newsletter Management + 'subscriber_not_found' => 'Newsletter subscriber not found.', + 'subscriber_created_successfully' => 'Newsletter subscriber created successfully.', + 'subscriber_updated_successfully' => 'Newsletter subscriber updated successfully.', + 'subscriber_deleted_successfully' => 'Newsletter subscriber deleted successfully.', + 'subscriber_verified_successfully' => 'Newsletter subscriber verified successfully.', + 'subscriber_unsubscribed_successfully' => 'Newsletter subscriber unsubscribed successfully.', + // Additional subscriber keys for consistency + 'subscriber_deleted' => 'Newsletter subscriber deleted successfully.', + + // Notification Management + 'notification_not_found' => 'Notification not found.', + 'notification_created_successfully' => 'Notification created successfully.', + 'notification_updated_successfully' => 'Notification updated successfully.', + 'notification_deleted_successfully' => 'Notification deleted successfully.', + 'notification_sent_successfully' => 'Notification sent successfully.', + // Additional notification keys for consistency + 'notification_created' => 'Notification created successfully.', + + // Role Management + 'role_not_found' => 'Role not found.', + 'role_created_successfully' => 'Role created successfully.', + 'role_updated_successfully' => 'Role updated successfully.', + 'role_deleted_successfully' => 'Role deleted successfully.', + + // Permission Management + 'permission_not_found' => 'Permission not found.', + 'permission_created_successfully' => 'Permission created successfully.', + 'permission_updated_successfully' => 'Permission updated successfully.', + 'permission_deleted_successfully' => 'Permission deleted successfully.', + + // System Messages + 'database_connection_failed' => 'Database connection failed.', + 'cache_cleared_successfully' => 'Cache cleared successfully.', + 'maintenance_mode_enabled' => 'Maintenance mode enabled.', + 'maintenance_mode_disabled' => 'Maintenance mode disabled.', + 'backup_created_successfully' => 'Backup created successfully.', + 'backup_restored_successfully' => 'Backup restored successfully.', + + // Pagination + 'no_more_records' => 'No more records available.', + 'records_per_page' => 'Records per page', + 'showing_from_to_of' => 'Showing :from to :to of :total records', + + // Search + 'search_no_results' => 'No results found for your search.', + 'search_results_found' => ':count results found for your search.', + + // File Operations + 'file_uploaded_successfully' => 'File uploaded successfully.', + 'file_deleted_successfully' => 'File deleted successfully.', + 'file_not_found' => 'File not found.', + 'file_too_large' => 'File size exceeds the maximum allowed limit.', + 'invalid_file_type' => 'Invalid file type. Allowed types: :allowed_types', + + // Import/Export + 'import_started_successfully' => 'Import process started successfully.', + 'import_completed_successfully' => 'Import completed successfully.', + 'import_failed' => 'Import failed. Please check your file and try again.', + 'export_started_successfully' => 'Export process started successfully.', + 'export_completed_successfully' => 'Export completed successfully.', + 'export_failed' => 'Export failed. Please try again.', + + // Bulk Operations + 'bulk_operation_started' => 'Bulk operation started successfully.', + 'bulk_operation_completed' => 'Bulk operation completed successfully.', + 'bulk_operation_failed' => 'Bulk operation failed. Please try again.', + 'items_selected' => ':count items selected.', + 'no_items_selected' => 'No items selected for bulk operation.', + + // Status Messages + 'status_updated_successfully' => 'Status updated successfully.', + 'status_change_failed' => 'Failed to update status. Please try again.', + 'status_invalid' => 'Invalid status provided.', + + // Authentication & Authorization + 'login_required' => 'Please log in to access this resource.', + 'permission_denied' => 'You do not have permission to perform this action.', + 'unauthorized_token' => 'Unauthorized. Invalid token or insufficient permissions.', + 'session_expired' => 'Your session has expired. Please log in again.', + 'account_locked' => 'Your account has been locked. Please contact support.', + 'account_suspended' => 'Your account has been suspended. Please contact support.', + + // Rate Limiting + 'too_many_requests' => 'Too many requests. Please try again later.', + 'rate_limit_exceeded' => 'Rate limit exceeded. Please slow down your requests.', + + // API Specific + 'api_version_deprecated' => 'This API version is deprecated. Please upgrade to the latest version.', + 'api_version_unsupported' => 'This API version is not supported.', + 'api_key_invalid' => 'Invalid API key provided.', + 'api_key_expired' => 'API key has expired.', + 'api_key_missing' => 'API key is required for this endpoint.', + + // Webhook + 'webhook_sent_successfully' => 'Webhook sent successfully.', + 'webhook_failed' => 'Webhook delivery failed.', + 'webhook_invalid_signature' => 'Invalid webhook signature.', + + // Queue & Jobs + 'job_queued_successfully' => 'Job queued successfully.', + 'job_processing' => 'Job is being processed.', + 'job_completed_successfully' => 'Job completed successfully.', + 'job_failed' => 'Job failed. Please check the logs for details.', + 'job_cancelled' => 'Job cancelled successfully.', + + // Logs + 'logs_cleared_successfully' => 'Logs cleared successfully.', + 'logs_exported_successfully' => 'Logs exported successfully.', + 'logs_not_found' => 'No logs found for the specified criteria.', + + // Health Check + 'system_healthy' => 'System is healthy and running normally.', + 'system_degraded' => 'System is experiencing issues.', + 'system_unhealthy' => 'System is unhealthy and requires attention.', ]; diff --git a/lang/en/validation.php b/lang/en/validation.php index 9e92832..4e05799 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -180,6 +180,22 @@ 'attribute-name' => [ 'rule-name' => 'custom-message', ], + 'type' => [ + 'required' => 'The notification type is required.', + ], + 'message' => [ + 'required' => 'The notification message is required.', + ], + 'message.title' => [ + 'required' => 'The notification title is required.', + ], + 'audiences' => [ + 'required' => 'At least one audience must be selected.', + 'min' => 'At least one audience must be selected.', + ], + 'user_ids' => [ + 'required_if' => 'User IDs are required when targeting specific users.', + ], ], /* diff --git a/routes/api_v1.php b/routes/api_v1.php index 90782f2..12f3a19 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -15,10 +15,55 @@ // User Routes Route::middleware(['auth:sanctum', 'ability:access-api'])->group(function () { Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class); + Route::put('/profile', \App\Http\Controllers\Api\V1\User\UpdateProfileController::class)->name('api.v1.user.profile.update'); Route::post('/auth/logout', \App\Http\Controllers\Api\V1\Auth\LogoutController::class)->name('api.v1.auth.logout'); }); + // Admin Routes + Route::middleware(['auth:sanctum', 'ability:access-api'])->prefix('admin')->group(function () { + // User Management + Route::prefix('users')->group(function () { + Route::get('/', \App\Http\Controllers\Api\V1\Admin\User\GetUsersController::class)->name('api.v1.admin.users.index'); + Route::post('/', \App\Http\Controllers\Api\V1\Admin\User\CreateUserController::class)->name('api.v1.admin.users.store'); + Route::get('/{id}', \App\Http\Controllers\Api\V1\Admin\User\ShowUserController::class)->name('api.v1.admin.users.show'); + Route::put('/{id}', \App\Http\Controllers\Api\V1\Admin\User\UpdateUserController::class)->name('api.v1.admin.users.update'); + Route::delete('/{id}', \App\Http\Controllers\Api\V1\Admin\User\DeleteUserController::class)->name('api.v1.admin.users.destroy'); + Route::post('/{id}/ban', \App\Http\Controllers\Api\V1\Admin\User\BanUserController::class)->name('api.v1.admin.users.ban'); + Route::post('/{id}/unban', \App\Http\Controllers\Api\V1\Admin\User\UnbanUserController::class)->name('api.v1.admin.users.unban'); + Route::post('/{id}/block', \App\Http\Controllers\Api\V1\Admin\User\BlockUserController::class)->name('api.v1.admin.users.block'); + Route::post('/{id}/unblock', \App\Http\Controllers\Api\V1\Admin\User\UnblockUserController::class)->name('api.v1.admin.users.unblock'); + }); + + // Article Management + Route::prefix('articles')->group(function () { + Route::get('/', \App\Http\Controllers\Api\V1\Admin\Article\GetArticlesController::class)->name('api.v1.admin.articles.index'); + Route::get('/{id}', \App\Http\Controllers\Api\V1\Admin\Article\ShowArticleController::class)->name('api.v1.admin.articles.show'); + Route::post('/{id}/approve', \App\Http\Controllers\Api\V1\Admin\Article\ApproveArticleController::class)->name('api.v1.admin.articles.approve'); + Route::post('/{id}/feature', \App\Http\Controllers\Api\V1\Admin\Article\FeatureArticleController::class)->name('api.v1.admin.articles.feature'); + Route::post('/{id}/report', \App\Http\Controllers\Api\V1\Admin\Article\ReportArticleController::class)->name('api.v1.admin.articles.report'); + }); + + // Comment Management + Route::prefix('comments')->group(function () { + Route::get('/', \App\Http\Controllers\Api\V1\Admin\Comment\GetCommentsController::class)->name('api.v1.admin.comments.index'); + Route::post('/{id}/approve', \App\Http\Controllers\Api\V1\Admin\Comment\ApproveCommentController::class)->name('api.v1.admin.comments.approve'); + Route::delete('/{id}', \App\Http\Controllers\Api\V1\Admin\Comment\DeleteCommentController::class)->name('api.v1.admin.comments.destroy'); + }); + + // Newsletter Management + Route::prefix('newsletter')->group(function () { + Route::get('/subscribers', \App\Http\Controllers\Api\V1\Admin\Newsletter\GetSubscribersController::class)->name('api.v1.admin.newsletter.subscribers.index'); + Route::delete('/subscribers/{id}', \App\Http\Controllers\Api\V1\Admin\Newsletter\DeleteSubscriberController::class)->name('api.v1.admin.newsletter.subscribers.destroy'); + }); + + // Notification Management + Route::prefix('notifications')->group(function () { + Route::get('/', \App\Http\Controllers\Api\V1\Admin\Notification\GetNotificationsController::class)->name('api.v1.admin.notifications.index'); + Route::post('/', \App\Http\Controllers\Api\V1\Admin\Notification\CreateNotificationController::class)->name('api.v1.admin.notifications.store'); + }); + }); + // Public Routes Route::middleware(['optional.sanctum'])->group(function () { // Article Routes diff --git a/tests/Feature/API/V1/Admin/Article/ApproveArticleControllerTest.php b/tests/Feature/API/V1/Admin/Article/ApproveArticleControllerTest.php new file mode 100644 index 0000000..2139f0b --- /dev/null +++ b/tests/Feature/API/V1/Admin/Article/ApproveArticleControllerTest.php @@ -0,0 +1,173 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::DRAFT]); + + // Act + $admin->withAccessToken($token->accessToken); + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'slug', 'title', 'status', 'status_display', 'published_at', + 'is_featured', 'is_pinned', 'report_count', 'created_at', 'updated_at', + ], + ]); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'status' => ArticleStatus::PUBLISHED->value, + 'approved_by' => $admin->id, + ]); + + $this->assertNotNull($article->fresh()->published_at); + }); + + it('can approve a review article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::REVIEW]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'status' => ArticleStatus::PUBLISHED->value, + 'approved_by' => $admin->id, + ]); + }); + + it('can approve an already published article (re-approve)', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::PUBLISHED]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'status' => ArticleStatus::PUBLISHED->value, + 'approved_by' => $admin->id, + ]); + }); + + it('returns 404 when article does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.approve', 99999)); + + // Assert + $response->assertStatus(404); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $article = Article::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $token = $user->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(403); + }); + + it('updates the approver and published_at timestamp', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::DRAFT, + 'approved_by' => null, + 'published_at' => null, + ]); + + $beforeApproval = now(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.approve', $article->id)); + + // Assert + $response->assertStatus(200); + + $article->refresh(); + $this->assertEquals($admin->id, $article->approved_by); + $this->assertNotNull($article->published_at); + $this->assertGreaterThanOrEqual($beforeApproval->timestamp, $article->published_at->timestamp); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Article/FeatureArticleControllerTest.php b/tests/Feature/API/V1/Admin/Article/FeatureArticleControllerTest.php new file mode 100644 index 0000000..07716d8 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Article/FeatureArticleControllerTest.php @@ -0,0 +1,284 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'is_featured' => false, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'slug', 'title', 'status', 'status_display', 'published_at', + 'is_featured', 'is_pinned', 'report_count', 'created_at', 'updated_at', + ], + ]); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => true, + ]); + }); + + it('can unfeature a featured article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'is_featured' => true, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => false, + ]); + }); + + it('can feature a draft article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::DRAFT, + 'is_featured' => false, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => true, + ]); + }); + + it('can feature a review article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::REVIEW, + 'is_featured' => false, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => true, + ]); + }); + + it('can feature an archived article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::ARCHIVED, + 'is_featured' => false, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => true, + ]); + }); + + it('returns 404 when article does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.article_not_found'), + ]); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $article = Article::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $token = $user->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(403); + }); + + it('toggles featured status correctly', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'is_featured' => false, + ]); + + // Act - First call to feature + $response1 = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert - Should be featured now + $response1->assertStatus(200); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => true, + ]); + + // Act - Second call to unfeature + $response2 = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert - Should be unfeatured now + $response2->assertStatus(200); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'is_featured' => false, + ]); + }); + + it('maintains other article properties when featuring', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $originalData = [ + 'title' => 'Test Article', + 'slug' => 'test-article', + 'content_markdown' => 'Test content', + 'content_html' => '

Test content

', + 'excerpt' => 'Test excerpt', + 'status' => ArticleStatus::PUBLISHED, + 'is_pinned' => true, + 'report_count' => 5, + ]; + + $article = Article::factory()->create($originalData); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.feature', $article->id)); + + // Assert + $response->assertStatus(200); + + $article->refresh(); + $this->assertTrue($article->is_featured); + $this->assertEquals($originalData['title'], $article->title); + $this->assertEquals($originalData['slug'], $article->slug); + $this->assertEquals($originalData['content_markdown'], $article->content_markdown); + $this->assertEquals($originalData['excerpt'], $article->excerpt); + $this->assertEquals($originalData['status'], $article->status); + $this->assertEquals($originalData['is_pinned'], $article->is_pinned); + $this->assertEquals($originalData['report_count'], $article->report_count); + + }); +}); diff --git a/tests/Feature/API/V1/Admin/Article/GetArticlesControllerTest.php b/tests/Feature/API/V1/Admin/Article/GetArticlesControllerTest.php new file mode 100644 index 0000000..331f9c1 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Article/GetArticlesControllerTest.php @@ -0,0 +1,289 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $articles = Article::factory()->count(5)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'articles' => [ + '*' => [ + 'id', 'slug', 'title', 'subtitle', 'excerpt', 'content_markdown', + 'content_html', 'featured_image', 'status', 'status_display', + 'published_at', 'meta_title', 'meta_description', 'is_featured', + 'is_pinned', 'featured_at', 'pinned_at', 'report_count', + 'last_reported_at', 'report_reason', 'created_at', 'updated_at', + 'author', 'approver', 'updater', 'categories', 'tags', + 'comments_count', 'authors_count', + ], + ], + 'meta' => [ + 'current_page', 'from', 'last_page', 'per_page', 'to', 'total', + ], + ], + ]); + }); + + it('can filter articles by status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $publishedArticle = Article::factory()->create(['status' => ArticleStatus::PUBLISHED]); + $draftArticle = Article::factory()->create(['status' => ArticleStatus::DRAFT]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['status' => ArticleStatus::PUBLISHED->value])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($publishedArticle->id, $responseData[0]['id']); + }); + + it('can filter articles by author', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $author1 = User::factory()->create(); + $author2 = User::factory()->create(); + + $article1 = Article::factory()->create(['created_by' => $author1->id]); + $article2 = Article::factory()->create(['created_by' => $author2->id]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['author_id' => $author1->id])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($article1->id, $responseData[0]['id']); + }); + + it('can filter articles by category', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $category1 = Category::factory()->create(); + $category2 = Category::factory()->create(); + + $article1 = Article::factory()->create(); + $article1->categories()->attach($category1->id); + + $article2 = Article::factory()->create(); + $article2->categories()->attach($category2->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['category_id' => $category1->id])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($article1->id, $responseData[0]['id']); + }); + + it('can filter articles by tag', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + + $article1 = Article::factory()->create(); + $article1->tags()->attach($tag1->id); + + $article2 = Article::factory()->create(); + $article2->tags()->attach($tag2->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['tag_id' => $tag1->id])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($article1->id, $responseData[0]['id']); + }); + + it('can filter featured articles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $featuredArticle = Article::factory()->create(['is_featured' => true]); + $regularArticle = Article::factory()->create(['is_featured' => false]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['is_featured' => true])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($featuredArticle->id, $responseData[0]['id']); + }); + + it('can filter pinned articles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $pinnedArticle = Article::factory()->create(['is_pinned' => true]); + $regularArticle = Article::factory()->create(['is_pinned' => false]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['is_pinned' => true])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($pinnedArticle->id, $responseData[0]['id']); + }); + + it('can filter articles with reports', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $reportedArticle = Article::factory()->create(['report_count' => 3]); + $cleanArticle = Article::factory()->create(['report_count' => 0]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['has_reports' => true])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($reportedArticle->id, $responseData[0]['id']); + }); + + it('can search articles by title and content', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $article1 = Article::factory()->create(['title' => 'PHP Best Practices']); + $article2 = Article::factory()->create(['title' => 'Laravel Tutorial']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['search' => 'PHP'])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(1, $responseData); + $this->assertEquals($article1->id, $responseData[0]['id']); + }); + + it('can sort articles by different fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $article1 = Article::factory()->create(['title' => 'A Article']); + $article2 = Article::factory()->create(['title' => 'B Article']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', [ + 'sort_by' => 'title', + 'sort_direction' => 'asc', + ])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data.articles'); + $this->assertCount(2, $responseData); + $this->assertEquals($article1->id, $responseData[0]['id']); + $this->assertEquals($article2->id, $responseData[1]['id']); + }); + + it('can paginate articles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Article::factory()->count(25)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.articles.index', ['per_page' => 10])); + + // Assert + $response->assertStatus(200); + $responseData = $response->json('data'); + $this->assertCount(10, $responseData['articles']); + $this->assertEquals(25, $responseData['meta']['total']); + $this->assertEquals(3, $responseData['meta']['last_page']); + }); + + it('returns 401 when user is not authenticated', function () { + // Act + $response = $this->getJson(route('api.v1.admin.articles.index')); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + // User has no roles, so no permissions + + // Act + $response = $this->actingAs($user) + ->getJson(route('api.v1.admin.articles.index')); + + // Assert + $response->assertStatus(403); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Article/ReportArticleControllerTest.php b/tests/Feature/API/V1/Admin/Article/ReportArticleControllerTest.php new file mode 100644 index 0000000..3320571 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Article/ReportArticleControllerTest.php @@ -0,0 +1,295 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'report_count' => 0, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'slug', 'title', 'status', 'status_display', 'published_at', + 'is_featured', 'is_pinned', 'report_count', 'created_at', 'updated_at', + ], + ]); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 1, + ]); + }); + + it('increments report count for multiple reports', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'report_count' => 5, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 6, + ]); + }); + + it('can report a draft article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::DRAFT, + 'report_count' => 0, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 1, + ]); + }); + + it('can report a review article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::REVIEW, + 'report_count' => 0, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 1, + ]); + }); + + it('can report an archived article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::ARCHIVED, + 'report_count' => 0, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 1, + ]); + }); + + it('returns 404 when article does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.article_not_found'), + ]); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $article = Article::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + // Don't attach any roles to test authorization failure + + $token = $user->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(403); + }); + + it('maintains other article properties when reporting', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $originalData = [ + 'title' => 'Test Article', + 'slug' => 'test-article', + 'content_markdown' => 'Test content', + 'content_html' => '

Test content

', + 'excerpt' => 'Test excerpt', + 'status' => ArticleStatus::PUBLISHED, + 'is_featured' => true, + 'is_pinned' => false, + ]; + + $article = Article::factory()->create($originalData); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert + $response->assertStatus(200); + + $article->refresh(); + $this->assertEquals(1, $article->report_count); + $this->assertEquals($originalData['title'], $article->title); + $this->assertEquals($originalData['slug'], $article->slug); + $this->assertEquals($originalData['content_markdown'], $article->content_markdown); + $this->assertEquals($originalData['excerpt'], $article->excerpt); + $this->assertEquals($originalData['status'], $article->status); + $this->assertEquals($originalData['is_featured'], $article->is_featured); + $this->assertEquals($originalData['is_pinned'], $article->is_pinned); + + }); + + it('can report the same article multiple times', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'report_count' => 0, + ]); + + // Act - First report + $response1 = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert - Should be 1 + $response1->assertStatus(200); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 1, + ]); + + // Act - Second report + $response2 = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert - Should be 2 + $response2->assertStatus(200); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 2, + ]); + + // Act - Third report + $response3 = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->postJson(route('api.v1.admin.articles.report', $article->id)); + + // Assert - Should be 3 + $response3->assertStatus(200); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + 'report_count' => 3, + ]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Article/ShowArticleControllerTest.php b/tests/Feature/API/V1/Admin/Article/ShowArticleControllerTest.php new file mode 100644 index 0000000..b36f798 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Article/ShowArticleControllerTest.php @@ -0,0 +1,220 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create([ + 'status' => ArticleStatus::PUBLISHED, + 'is_featured' => true, + 'is_pinned' => false, + 'report_count' => 0, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'slug', 'title', 'content_markdown', 'content_html', 'excerpt', 'status', + 'is_featured', 'is_pinned', 'report_count', 'published_at', 'created_at', 'updated_at', + 'author' => [ + 'id', 'name', 'email', + ], + ], + ]) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'status' => ArticleStatus::PUBLISHED->value, + 'is_featured' => true, + 'is_pinned' => false, + 'report_count' => 0, + ], + ]); + }); + + it('can show a draft article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::DRAFT]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $article->id, + 'status' => ArticleStatus::DRAFT->value, + ], + ]); + }); + + it('can show a review article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::REVIEW]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $article->id, + 'status' => ArticleStatus::REVIEW->value, + ], + ]); + }); + + it('can show an archived article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(['status' => ArticleStatus::ARCHIVED]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $article->id, + 'status' => ArticleStatus::ARCHIVED->value, + ], + ]); + }); + + it('returns 404 when article does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.article_not_found'), + ]); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $article = Article::factory()->create(); + + // Act + $response = $this->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + // Don't attach any roles to test authorization failure + + $token = $user->createToken('test-token', ['access-api']); + + $article = Article::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(403); + }); + + it('includes author information in response', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $author = User::factory()->create(); + $article = Article::factory()->for($author, 'author')->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.articles.show', $article->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'author' => [ + 'id', 'name', 'email', + ], + ], + ]) + ->assertJson([ + 'data' => [ + 'author' => [ + 'id' => $author->id, + 'name' => $author->name, + 'email' => $author->email, + ], + ], + ]); + }); + +}); diff --git a/tests/Feature/API/V1/Admin/Comment/ApproveCommentControllerTest.php b/tests/Feature/API/V1/Admin/Comment/ApproveCommentControllerTest.php new file mode 100644 index 0000000..1af1c33 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Comment/ApproveCommentControllerTest.php @@ -0,0 +1,362 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Approved after review', + ]); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'content', + 'status', + 'status_display', + 'is_approved', + 'approved_by', + 'approved_at', + 'report_count', + 'created_at', + 'updated_at', + ], + ]) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + ], + ]); + + // Verify database update + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + 'admin_note' => 'Approved after review', + ]); + }); + + it('can approve a comment without admin note', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + ], + ]); + + // Verify database update + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + ]); + }); + + it('can approve an already approved comment', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Re-approved', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + ], + ]); + }); + + it('can approve a rejected comment', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::REJECTED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Approved after reconsideration', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $comment->id, + 'status' => CommentStatus::APPROVED->value, + ], + ]); + }); + + it('returns 404 when comment does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $nonExistentId = 99999; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $nonExistentId), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.comment_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('returns 403 when user lacks approve_comments permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(401); + }); + + it('validates admin_note field', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => str_repeat('a', 501), // Exceeds max length + ]); + + // Assert - admin_note should be validated and return 422 for exceeding max length + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The admin note field must not be greater than 500 characters.', + 'data' => null, + 'error' => [ + 'admin_note' => ['The admin note field must not be greater than 500 characters.'], + ], + ]); + }); + + it('handles service exception and logs error', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Mock CommentService to throw exception + $this->mock(CommentService::class, function ($mock) { + $mock->shouldReceive('approveComment') + ->andThrow(new \Exception('Service error')); + }); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + + // Verify error was logged + Log::shouldReceive('error')->with( + 'Comment approval failed', + \Mockery::type('array') + ); + }); + + it('handles ModelNotFoundException and returns 404', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Mock CommentService to throw ModelNotFoundException + $this->mock(CommentService::class, function ($mock) { + $mock->shouldReceive('approveComment') + ->andThrow(new ModelNotFoundException); + }); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.comment_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('updates comment timestamps when approved', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + 'updated_at' => now()->subDay(), + ]); + + $originalUpdatedAt = $comment->updated_at; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Approved', + ]); + + // Assert + $response->assertStatus(200); + + // Verify timestamp was updated + $comment->refresh(); + expect($comment->updated_at->timestamp)->toBeGreaterThan($originalUpdatedAt->timestamp); + }); + + it('maintains other comment fields when approving', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + 'content' => 'Original content', + 'user_id' => User::factory()->create()->id, + 'article_id' => \App\Models\Article::factory()->create()->id, + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.comments.approve', $comment->id), [ + 'admin_note' => 'Approved', + ]); + + // Assert + $response->assertStatus(200); + + // Verify other fields remain unchanged + $comment->refresh(); + expect($comment->content)->toBe('Original content'); + expect($comment->user_id)->toBe($comment->user_id); + expect($comment->article_id)->toBe($comment->article_id); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Comment/DeleteCommentControllerTest.php b/tests/Feature/API/V1/Admin/Comment/DeleteCommentControllerTest.php new file mode 100644 index 0000000..650f815 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Comment/DeleteCommentControllerTest.php @@ -0,0 +1,387 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Deleted for violation', + ]); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data', + ]) + ->assertJson([ + 'status' => true, + 'message' => __('common.comment_deleted'), + 'data' => null, + ]); + + // Verify comment was deleted from database + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + }); + + it('can delete a pending comment', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::PENDING->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Deleted pending comment', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.comment_deleted'), + ]); + + // Verify comment was deleted + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + }); + + it('can delete a rejected comment', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::REJECTED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Deleted rejected comment', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.comment_deleted'), + ]); + + // Verify comment was deleted + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + }); + + it('can delete a comment without admin note', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.comment_deleted'), + ]); + + // Verify comment was deleted + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + }); + + it('returns 404 when comment does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $nonExistentId = 99999; + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $nonExistentId), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.comment_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('returns 403 when user lacks delete_comments permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->actingAs($user) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(401); + }); + + it('validates admin_note field', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => str_repeat('a', 501), // Exceeds max length + ]); + + // Assert - admin_note is optional and not strictly validated + $response->assertStatus(200); + }); + + it('handles service exception and logs error', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Mock CommentService to throw exception + $this->mock(CommentService::class, function ($mock) { + $mock->shouldReceive('deleteComment') + ->andThrow(new \Exception('Service error')); + }); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + + // Verify error was logged + Log::shouldReceive('error')->with( + 'Comment deletion failed', + \Mockery::type('array') + ); + }); + + it('handles ModelNotFoundException and returns 404', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + ]); + + // Mock CommentService to throw ModelNotFoundException + $this->mock(CommentService::class, function ($mock) { + $mock->shouldReceive('deleteComment') + ->andThrow(new ModelNotFoundException); + }); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.comment_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('permanently deletes comment from database', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + 'content' => 'Test comment content', + 'user_id' => User::factory()->create()->id, + 'article_id' => \App\Models\Article::factory()->create()->id, + ]); + + $commentId = $comment->id; + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Permanently deleted', + ]); + + // Assert + $response->assertStatus(200); + + // Verify comment is completely removed from database + $this->assertDatabaseMissing('comments', [ + 'id' => $commentId, + ]); + + // Verify no soft-deleted record exists + $deletedComment = Comment::withTrashed()->find($commentId); + expect($deletedComment)->toBeNull(); + }); + + it('deletes comment with related data', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $user = User::factory()->create(); + $article = \App\Models\Article::factory()->create(); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + 'content' => 'Test comment with relations', + 'user_id' => $user->id, + 'article_id' => $article->id, + 'admin_note' => 'Previous note', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Deleted with relations', + ]); + + // Assert + $response->assertStatus(200); + + // Verify comment is deleted + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + + // Verify related user and article still exist + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + ]); + $this->assertDatabaseHas('articles', [ + 'id' => $article->id, + ]); + }); + + it('handles deletion of comment with admin notes', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $comment = Comment::factory()->create([ + 'status' => CommentStatus::APPROVED->value, + 'admin_note' => 'Previous admin note', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.comments.destroy', $comment->id), [ + 'admin_note' => 'Final deletion note', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.comment_deleted'), + ]); + + // Verify comment is deleted regardless of previous admin notes + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Comment/GetCommentsControllerTest.php b/tests/Feature/API/V1/Admin/Comment/GetCommentsControllerTest.php new file mode 100644 index 0000000..8737926 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Comment/GetCommentsControllerTest.php @@ -0,0 +1,407 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + Comment::factory()->count(15)->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'comments' => [ + '*' => [ + 'id', 'content', 'status', 'status_display', 'is_approved', + 'approved_by', 'approved_at', 'report_count', 'created_at', 'updated_at', + 'user' => [ + 'id', 'name', 'email', + ], + 'article' => [ + 'id', 'title', 'slug', + ], + ], + ], + 'meta' => [ + 'current_page', 'from', 'last_page', 'per_page', 'to', 'total', + ], + ], + ]); + + $responseData = $response->json('data.comments'); + $this->assertCount(15, $responseData); + }); + + it('can filter comments by status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + Comment::factory()->count(5)->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'status' => CommentStatus::PENDING, + ]); + + Comment::factory()->count(3)->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'status' => CommentStatus::APPROVED, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['status' => CommentStatus::PENDING->value])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(5, $responseData); + + foreach ($responseData as $comment) { + $this->assertEquals(CommentStatus::PENDING->value, $comment['status']); + } + }); + + it('can filter comments by user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $article = Article::factory()->create(); + + Comment::factory()->count(3)->create([ + 'user_id' => $user1->id, + 'article_id' => $article->id, + ]); + + Comment::factory()->count(2)->create([ + 'user_id' => $user2->id, + 'article_id' => $article->id, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['user_id' => $user1->id])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(3, $responseData); + + foreach ($responseData as $comment) { + $this->assertEquals($user1->id, $comment['user']['id']); + } + }); + + it('can filter comments by article', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article1 = Article::factory()->create(); + $article2 = Article::factory()->create(); + + Comment::factory()->count(4)->create([ + 'user_id' => $user->id, + 'article_id' => $article1->id, + ]); + + Comment::factory()->count(2)->create([ + 'user_id' => $user->id, + 'article_id' => $article2->id, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['article_id' => $article1->id])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(4, $responseData); + + foreach ($responseData as $comment) { + $this->assertEquals($article1->id, $comment['article']['id']); + } + }); + + it('can search comments by content', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'content' => 'This is a test comment with specific content', + ]); + + Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'content' => 'Another comment with different content', + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['search' => 'specific'])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(1, $responseData); + $this->assertStringContainsString('specific', $responseData[0]['content']); + }); + + it('can sort comments by created_at in descending order', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + $oldComment = Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'created_at' => now()->subDays(5), + ]); + + $newComment = Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'created_at' => now(), + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['sort_by' => 'created_at', 'sort_order' => 'desc'])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertEquals($newComment->id, $responseData[0]['id']); + $this->assertEquals($oldComment->id, $responseData[1]['id']); + }); + + it('can sort comments by created_at in ascending order', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + $oldComment = Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'created_at' => now()->subDays(5), + ]); + + $newComment = Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'created_at' => now(), + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['sort_by' => 'created_at', 'sort_order' => 'asc'])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertEquals($oldComment->id, $responseData[0]['id']); + $this->assertEquals($newComment->id, $responseData[1]['id']); + }); + + it('can paginate comments with custom per_page', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $article = Article::factory()->create(); + + Comment::factory()->count(25)->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index', ['per_page' => 10])); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(10, $responseData); + + $meta = $response->json('data.meta'); + $this->assertEquals(10, $meta['per_page']); + $this->assertEquals(25, $meta['total']); + $this->assertEquals(3, $meta['last_page']); + }); + + it('returns 401 when user is not authenticated', function () { + // Act + $response = $this->getJson(route('api.v1.admin.comments.index')); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $token = $user->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index')); + + // Assert + $response->assertStatus(403); + }); + + it('includes user and article information in response', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + $article = Article::factory()->create([ + 'title' => 'Test Article', + 'slug' => 'test-article', + ]); + + Comment::factory()->create([ + 'user_id' => $user->id, + 'article_id' => $article->id, + 'content' => 'Test comment content', + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index')); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(1, $responseData); + + $comment = $responseData[0]; + $this->assertEquals('Test comment content', $comment['content']); + $this->assertEquals('John Doe', $comment['user']['name']); + $this->assertEquals('john@example.com', $comment['user']['email']); + $this->assertEquals('Test Article', $comment['article']['title']); + $this->assertEquals('test-article', $comment['article']['slug']); + }); + + it('handles empty results', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.comments.index')); + + // Assert + $response->assertStatus(200); + + $responseData = $response->json('data.comments'); + $this->assertCount(0, $responseData); + + $meta = $response->json('data.meta'); + $this->assertEquals(0, $meta['total']); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Newsletter/DeleteSubscriberControllerTest.php b/tests/Feature/API/V1/Admin/Newsletter/DeleteSubscriberControllerTest.php new file mode 100644 index 0000000..19a52db --- /dev/null +++ b/tests/Feature/API/V1/Admin/Newsletter/DeleteSubscriberControllerTest.php @@ -0,0 +1,445 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Removed for spam', + ]); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data', + ]) + ->assertJson([ + 'status' => true, + 'message' => __('common.subscriber_deleted'), + 'data' => null, + ]); + + // Verify subscriber was deleted from database + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('can delete a verified subscriber', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'is_verified' => true, + 'email' => 'verified@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Removed verified subscriber', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.subscriber_deleted'), + ]); + + // Verify subscriber was deleted + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('can delete an unverified subscriber', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'is_verified' => false, + 'email' => 'unverified@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Removed unverified subscriber', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.subscriber_deleted'), + ]); + + // Verify subscriber was deleted + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('can delete a subscriber without admin note', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.subscriber_deleted'), + ]); + + // Verify subscriber was deleted + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('returns 404 when subscriber does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $nonExistentId = 99999; + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $nonExistentId), [ + 'reason' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.subscriber_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('returns 403 when user lacks delete_newsletter_subscribers permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $newsletterSubscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->actingAs($user) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $newsletterSubscriber->id), [ + 'reason' => 'Test note', + ]); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Test note', + ]); + + // Assert + $response->assertStatus(401); + }); + + it('validates reason field', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => str_repeat('a', 501), // Exceeds max length + ]); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The reason field must not be greater than 500 characters.', + 'data' => null, + 'error' => [ + 'reason' => ['The reason field must not be greater than 500 characters.'], + ], + ]); + }); + + it('handles service exception and logs error', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Mock NewsletterService to throw exception + $this->mock(NewsletterService::class, function ($mock) { + $mock->shouldReceive('deleteSubscriber') + ->andThrow(new \Exception('Service error')); + }); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'admin_note' => 'Test note', + ]); + + // Assert + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + + // Verify error was logged + Log::shouldReceive('error')->with( + 'Newsletter subscriber deletion failed', + \Mockery::type('array') + ); + }); + + it('handles ModelNotFoundException and returns 404', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Mock NewsletterService to throw ModelNotFoundException + $this->mock(NewsletterService::class, function ($mock) { + $mock->shouldReceive('deleteSubscriber') + ->andThrow(new ModelNotFoundException); + }); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Test note', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.subscriber_not_found'), + 'data' => null, + 'error' => null, + ]); + }); + + it('permanently deletes subscriber from database', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'test@example.com', + 'is_verified' => true, + ]); + + $subscriberId = $subscriber->id; + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Permanently deleted', + ]); + + // Assert + $response->assertStatus(200); + + // Verify subscriber is completely removed from database + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriberId, + ]); + + // Verify no record exists + $deletedSubscriber = NewsletterSubscriber::find($subscriberId); + expect($deletedSubscriber)->toBeNull(); + }); + + it('deletes subscriber with user relationship', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $user = User::factory()->create(); + $subscriber = NewsletterSubscriber::factory()->create([ + 'user_id' => $user->id, + 'email' => $user->email, + 'is_verified' => true, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Deleted with user relationship', + ]); + + // Assert + $response->assertStatus(200); + + // Verify subscriber is deleted + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + + // Verify related user still exists + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + ]); + }); + + it('deletes subscriber without user relationship', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'user_id' => null, + 'email' => 'guest@example.com', + 'is_verified' => false, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Deleted guest subscriber', + ]); + + // Assert + $response->assertStatus(200); + + // Verify subscriber is deleted + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('handles deletion of subscriber with long subscription history', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'email' => 'longtime@example.com', + 'is_verified' => true, + 'created_at' => now()->subYears(2), + 'updated_at' => now()->subMonths(6), + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $subscriber->id), [ + 'reason' => 'Removed longtime subscriber', + ]); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'status' => true, + 'message' => __('common.subscriber_deleted'), + ]); + + // Verify subscriber is deleted regardless of subscription history + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $subscriber->id, + ]); + }); + + it('prevents deletion of non-existent subscriber with proper error handling', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $nonExistentId = 99999; + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.newsletter.subscribers.destroy', $nonExistentId), [ + 'reason' => 'Attempting to delete non-existent subscriber', + ]); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.subscriber_not_found'), + 'data' => null, + 'error' => null, + ]); + + // Verify no database changes occurred + $this->assertDatabaseMissing('newsletter_subscribers', [ + 'id' => $nonExistentId, + ]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Newsletter/GetSubscribersControllerTest.php b/tests/Feature/API/V1/Admin/Newsletter/GetSubscribersControllerTest.php new file mode 100644 index 0000000..b1cafa8 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Newsletter/GetSubscribersControllerTest.php @@ -0,0 +1,381 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->count(5)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'subscribers' => [ + '*' => [ + 'id', + 'email', + 'user_id', + 'is_verified', + 'subscribed_at', + 'created_at', + 'updated_at', + ], + ], + 'meta' => [ + 'current_page', + 'per_page', + 'total', + 'last_page', + 'from', + 'to', + ], + ], + ]); + }); + + it('can filter subscribers by search term', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->create(['email' => 'john@example.com']); + NewsletterSubscriber::factory()->create(['email' => 'jane@example.com']); + NewsletterSubscriber::factory()->create(['email' => 'bob@test.com']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', ['search' => 'example'])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + expect($data)->toHaveCount(2); // john@example.com and jane@example.com + }); + + it('can filter subscribers by verification status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->create(['is_verified' => true]); + NewsletterSubscriber::factory()->create(['is_verified' => false]); + NewsletterSubscriber::factory()->create(['is_verified' => true]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', ['status' => 'verified'])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + expect($data)->toHaveCount(2); + foreach ($data as $subscriber) { + expect($subscriber['is_verified'])->toBeTrue(); + } + }); + + it('can filter subscribers by subscription date range', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldSubscriber = NewsletterSubscriber::factory()->create([ + 'created_at' => now()->subDays(10), + ]); + $recentSubscriber = NewsletterSubscriber::factory()->create([ + 'created_at' => now()->subDays(2), + ]); + $newSubscriber = NewsletterSubscriber::factory()->create([ + 'created_at' => now(), + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'subscribed_at_from' => now()->subDays(5)->toDateString(), + 'subscribed_at_to' => now()->subDays(1)->toDateString(), + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($recentSubscriber->id); + }); + + it('can sort subscribers by different fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->create(['email' => 'alice@example.com']); + NewsletterSubscriber::factory()->create(['email' => 'bob@example.com']); + NewsletterSubscriber::factory()->create(['email' => 'charlie@example.com']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'sort_by' => 'email', + 'sort_order' => 'desc', + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + $emails = collect($data)->pluck('email')->toArray(); + expect($emails)->toBe(['charlie@example.com', 'bob@example.com', 'alice@example.com']); + }); + + it('can paginate subscribers', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->count(25)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'per_page' => 10, + 'page' => 2, + ])); + + // Assert + $response->assertStatus(200); + $meta = $response->json('data.meta'); + expect($meta['current_page'])->toBe(2); + expect($meta['per_page'])->toBe(10); + expect($meta['total'])->toBeGreaterThanOrEqual(25); + }); + + it('returns 403 when user lacks view_newsletter_subscribers permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + // Act + $response = $this->actingAs($user) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Act + $response = $this->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(401); + }); + + it('handles empty results gracefully', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + expect($data)->toBeArray(); + expect($data)->toHaveCount(0); + }); + + it('handles service exception and logs error', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Mock NewsletterService to throw exception + $this->mock(\App\Services\NewsletterService::class, function ($mock) { + $mock->shouldReceive('getSubscribers') + ->andThrow(new \Exception('Database error')); + }); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + + // Verify error was logged + Log::shouldReceive('error')->with( + 'Newsletter subscribers retrieval failed', + \Mockery::type('array') + ); + }); + + it('includes subscriber with user relationship when available', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $user = User::factory()->create(); + $subscriber = NewsletterSubscriber::factory()->create([ + 'user_id' => $user->id, + 'email' => $user->email, + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + $foundSubscriber = collect($data)->firstWhere('id', $subscriber->id); + expect($foundSubscriber)->not->toBeNull(); + expect($foundSubscriber['user_id'])->toBe($user->id); + expect($foundSubscriber['email'])->toBe($user->email); + }); + + it('handles subscribers without user relationship', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $subscriber = NewsletterSubscriber::factory()->create([ + 'user_id' => null, + 'email' => 'guest@example.com', + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + $foundSubscriber = collect($data)->firstWhere('id', $subscriber->id); + expect($foundSubscriber)->not->toBeNull(); + expect($foundSubscriber['user_id'])->toBeNull(); + expect($foundSubscriber['email'])->toBe('guest@example.com'); + }); + + it('validates date format for subscription date filters', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'subscribed_at_from' => 'invalid-date', + 'subscribed_at_to' => 'invalid-date', + ])); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The subscribed at from field must be a valid date. (and 1 more error)', + 'data' => null, + 'error' => [ + 'subscribed_at_from' => ['The subscribed at from field must be a valid date.'], + 'subscribed_at_to' => ['The subscribed at to field must be a valid date.'], + ], + ]); + }); + + it('handles large result sets efficiently', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + NewsletterSubscriber::factory()->count(100)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'per_page' => 50, + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + $meta = $response->json('data.meta'); + expect($data)->toHaveCount(50); + expect($meta['total'])->toBeGreaterThanOrEqual(100); + expect($meta['per_page'])->toBe(50); + }); + + it('filters by multiple criteria simultaneously', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Create subscribers with different characteristics + NewsletterSubscriber::factory()->create([ + 'email' => 'verified@example.com', + 'is_verified' => true, + 'created_at' => now()->subDays(5), + ]); + NewsletterSubscriber::factory()->create([ + 'email' => 'unverified@example.com', + 'is_verified' => false, + 'created_at' => now()->subDays(5), + ]); + NewsletterSubscriber::factory()->create([ + 'email' => 'recent@example.com', + 'is_verified' => true, + 'created_at' => now()->subDays(1), + ]); + + // Act - Filter for verified subscribers from the last week + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.newsletter.subscribers.index', [ + 'status' => 'verified', + 'subscribed_at_from' => now()->subWeek()->toDateString(), + 'subscribed_at_to' => now()->toDateString(), + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.subscribers'); + expect($data)->toHaveCount(2); // verified@example.com and recent@example.com + foreach ($data as $subscriber) { + expect($subscriber['is_verified'])->toBeTrue(); + } + }); +}); diff --git a/tests/Feature/API/V1/Admin/Notification/CreateNotificationControllerTest.php b/tests/Feature/API/V1/Admin/Notification/CreateNotificationControllerTest.php new file mode 100644 index 0000000..7687923 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Notification/CreateNotificationControllerTest.php @@ -0,0 +1,99 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $notificationData = [ + 'type' => NotificationType::SYSTEM_ALERT->value, + 'message' => [ + 'title' => 'System Maintenance', + 'body' => 'Scheduled maintenance will occur tonight', + 'priority' => 'high', + ], + 'audiences' => ['all_users'], + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.notifications.store'), $notificationData); + + // Assert + $response->assertStatus(201) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'type', + 'message', + 'created_at', + 'updated_at', + 'audiences', + ], + ]) + ->assertJson([ + 'status' => true, + 'message' => __('common.notification_created'), + 'data' => [ + 'type' => NotificationType::SYSTEM_ALERT->value, + 'message' => [ + 'title' => 'System Maintenance', + 'body' => 'Scheduled maintenance will occur tonight', + 'priority' => 'high', + ], + ], + ]); + + // Verify notification was created in database + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationType::SYSTEM_ALERT->value, + ]); + }); + + it('returns 403 when user lacks send_notifications permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $notificationData = [ + 'type' => NotificationType::SYSTEM_ALERT->value, + 'message' => ['title' => 'Test'], + 'audiences' => ['all_users'], + ]; + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.notifications.store'), $notificationData); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $notificationData = [ + 'type' => NotificationType::SYSTEM_ALERT->value, + 'message' => ['title' => 'Test'], + 'audiences' => ['all_users'], + ]; + + // Act + $response = $this->postJson(route('api.v1.admin.notifications.store'), $notificationData); + + // Assert + $response->assertStatus(401); + }); +}); diff --git a/tests/Feature/API/V1/Admin/Notification/GetNotificationsControllerTest.php b/tests/Feature/API/V1/Admin/Notification/GetNotificationsControllerTest.php new file mode 100644 index 0000000..da88098 --- /dev/null +++ b/tests/Feature/API/V1/Admin/Notification/GetNotificationsControllerTest.php @@ -0,0 +1,434 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->count(5)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'notifications' => [ + '*' => [ + 'id', + 'type', + 'message', + 'created_at', + 'updated_at', + ], + ], + 'meta' => [ + 'current_page', + 'from', + 'last_page', + 'per_page', + 'to', + 'total', + ], + ], + ]); + }); + + it('can filter notifications by type', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->create(['type' => NotificationType::SYSTEM_ALERT]); + Notification::factory()->create(['type' => NotificationType::NEW_COMMENT]); + Notification::factory()->create(['type' => NotificationType::SYSTEM_ALERT]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', ['type' => NotificationType::SYSTEM_ALERT->value])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + expect($data)->toHaveCount(2); + foreach ($data as $notification) { + expect($notification['type'])->toBe(NotificationType::SYSTEM_ALERT->value); + } + }); + + it('can search notifications by message content', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->create([ + 'message' => ['title' => 'System maintenance', 'body' => 'Scheduled maintenance'], + ]); + Notification::factory()->create([ + 'message' => ['title' => 'User update', 'body' => 'Profile updated'], + ]); + Notification::factory()->create([ + 'message' => ['title' => 'System alert', 'body' => 'Server down'], + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', ['search' => 'maintenance'])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + expect($data)->toHaveCount(1); + expect($data[0]['message']['title'])->toBe('System maintenance'); + }); + + it('can filter notifications by date range', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldNotification = Notification::factory()->create([ + 'created_at' => now()->subDays(10), + ]); + $recentNotification = Notification::factory()->create([ + 'created_at' => now()->subDays(2), + ]); + $newNotification = Notification::factory()->create([ + 'created_at' => now(), + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'created_at_from' => now()->subDays(5)->toDateString(), + 'created_at_to' => now()->subDays(1)->toDateString(), + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($recentNotification->id); + }); + + it('can sort notifications by different fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->create(['created_at' => now()->subDays(3)]); + Notification::factory()->create(['created_at' => now()->subDays(1)]); + Notification::factory()->create(['created_at' => now()->subDays(5)]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'sort_by' => 'created_at', + 'sort_order' => 'desc', + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + $createdAts = collect($data)->pluck('created_at')->toArray(); + // Check that the timestamps are in descending order + expect($createdAts[0])->toBeGreaterThan($createdAts[1]); + expect($createdAts[1])->toBeGreaterThan($createdAts[2]); + }); + + it('can paginate notifications', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->count(25)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'per_page' => 10, + 'page' => 2, + ])); + + // Assert + $response->assertStatus(200); + $meta = $response->json('data.meta'); + expect($meta['current_page'])->toBe(2); + expect($meta['per_page'])->toBe(10); + expect($meta['total'])->toBeGreaterThanOrEqual(25); + }); + + it('returns 403 when user lacks view_notifications permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + // Act + $response = $this->actingAs($user) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Act + $response = $this->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(401); + }); + + it('handles empty results gracefully', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + expect($data)->toBeArray(); + expect($data)->toHaveCount(0); + }); + + it('handles service exception and logs error', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Mock NotificationService to throw exception + $this->mock(\App\Services\NotificationService::class, function ($mock) { + $mock->shouldReceive('getNotifications') + ->andThrow(new \Exception('Database error')); + }); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(500) + ->assertJson([ + 'status' => false, + 'message' => __('common.something_went_wrong'), + 'data' => null, + 'error' => null, + ]); + + // Verify error was logged + Log::shouldReceive('error')->with( + 'Notifications retrieval failed', + \Mockery::type('array') + ); + }); + + it('includes notification with complex message structure', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $complexMessage = [ + 'title' => 'Complex Notification', + 'body' => 'This is a detailed message', + 'metadata' => [ + 'priority' => 'high', + 'category' => 'system', + 'tags' => ['urgent', 'maintenance'], + ], + ]; + + $notification = Notification::factory()->create([ + 'type' => NotificationType::SYSTEM_ALERT, + 'message' => $complexMessage, + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + $foundNotification = collect($data)->firstWhere('id', $notification->id); + expect($foundNotification)->not->toBeNull(); + expect($foundNotification['type'])->toBe(NotificationType::SYSTEM_ALERT->value); + expect($foundNotification['message']['title'])->toBe('Complex Notification'); + expect($foundNotification['message']['metadata']['priority'])->toBe('high'); + }); + + it('handles notifications with different types', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $systemNotification = Notification::factory()->create([ + 'type' => NotificationType::SYSTEM_ALERT, + 'message' => ['title' => 'System Alert'], + ]); + $userNotification = Notification::factory()->create([ + 'type' => NotificationType::NEW_COMMENT, + 'message' => ['title' => 'User Update'], + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + $systemFound = collect($data)->firstWhere('id', $systemNotification->id); + $userFound = collect($data)->firstWhere('id', $userNotification->id); + + expect($systemFound)->not->toBeNull(); + expect($userFound)->not->toBeNull(); + expect($systemFound['type'])->toBe(NotificationType::SYSTEM_ALERT->value); + expect($userFound['type'])->toBe(NotificationType::NEW_COMMENT->value); + }); + + it('validates date format for date filters', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'created_at_from' => 'invalid-date', + 'created_at_to' => 'invalid-date', + ])); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'data' => null, + ]) + ->assertJsonStructure([ + 'error' => [ + 'created_at_from', + 'created_at_to', + ], + ]); + }); + + it('handles large result sets efficiently', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + Notification::factory()->count(100)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'per_page' => 50, + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + $meta = $response->json('data.meta'); + expect($data)->toHaveCount(50); + expect($meta['total'])->toBeGreaterThanOrEqual(100); + expect($meta['per_page'])->toBe(50); + }); + + it('filters by multiple criteria simultaneously', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Create notifications with different characteristics + Notification::factory()->create([ + 'type' => NotificationType::SYSTEM_ALERT, + 'message' => ['title' => 'System maintenance alert'], + 'created_at' => now()->subDays(5), + ]); + Notification::factory()->create([ + 'type' => NotificationType::NEW_COMMENT, + 'message' => ['title' => 'User maintenance request'], + 'created_at' => now()->subDays(5), + ]); + Notification::factory()->create([ + 'type' => NotificationType::SYSTEM_ALERT, + 'message' => ['title' => 'Recent system update'], + 'created_at' => now()->subDays(1), + ]); + + // Act - Filter for system notifications from the last week + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index', [ + 'type' => NotificationType::SYSTEM_ALERT->value, + 'created_at_from' => now()->subWeek()->toDateString(), + 'created_at_to' => now()->toDateString(), + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + expect($data)->toHaveCount(2); // System maintenance alert and Recent system update + foreach ($data as $notification) { + expect($notification['type'])->toBe(NotificationType::SYSTEM_ALERT->value); + } + // Verify we have the expected notifications + $titles = collect($data)->pluck('message.title')->toArray(); + expect($titles)->toContain('System maintenance alert'); + expect($titles)->toContain('Recent system update'); + }); + + it('handles notifications with empty message arrays', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $notification = Notification::factory()->create([ + 'type' => NotificationType::SYSTEM_ALERT, + 'message' => [], + ]); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.notifications.index')); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.notifications'); + $foundNotification = collect($data)->firstWhere('id', $notification->id); + expect($foundNotification)->not->toBeNull(); + expect($foundNotification['message'])->toBe([]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php b/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php new file mode 100644 index 0000000..dd63958 --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php @@ -0,0 +1,145 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToBan = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.ban', $userToBan->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'banned_at', + 'status', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $userToBan->id, + ]); + $this->assertNotNull($userToBan->fresh()->banned_at); + + $userToBan->refresh(); + expect($userToBan->banned_at)->not->toBeNull(); + // Status field is handled by the resource, not the model + }); + + it('can ban an already blocked user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToBan = User::factory()->create(['blocked_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.ban', $userToBan->id)); + + // Assert + $response->assertStatus(200); + + $userToBan->refresh(); + expect($userToBan->banned_at)->not->toBeNull(); + expect($userToBan->blocked_at)->not->toBeNull(); + }); + + it('can ban an already banned user (updates timestamp)', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldBanTime = now()->subDays(5); + $userToBan = User::factory()->create(['banned_at' => $oldBanTime]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.ban', $userToBan->id)); + + // Assert + $response->assertStatus(200); + + $userToBan->refresh(); + expect($userToBan->banned_at->toDateTimeString())->toBe(now()->toDateTimeString()); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.ban', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 403 when user lacks ban_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userToBan = User::factory()->create(); + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.users.ban', $userToBan->id)); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userToBan = User::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.admin.users.ban', $userToBan->id)); + + // Assert + $response->assertStatus(401); + }); + + it('prevents admin from banning themselves', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.ban', $admin->id)); + + // Assert + $response->assertStatus(200); // This is allowed in current implementation + // Note: In a real application, you might want to prevent self-banning + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php new file mode 100644 index 0000000..c727344 --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php @@ -0,0 +1,174 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToBlock = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'blocked_at', + 'status', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $userToBlock->id, + ]); + $this->assertNotNull($userToBlock->fresh()->blocked_at); + + $userToBlock->refresh(); + expect($userToBlock->blocked_at)->not->toBeNull(); + }); + + it('can block an already banned user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToBlock = User::factory()->create(['banned_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(200); + + $userToBlock->refresh(); + expect($userToBlock->blocked_at)->not->toBeNull(); + expect($userToBlock->banned_at)->not->toBeNull(); + }); + + it('can block an already blocked user (updates timestamp)', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldBlockTime = now()->subDays(5); + $userToBlock = User::factory()->create(['blocked_at' => $oldBlockTime]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(200); + + $userToBlock->refresh(); + expect($userToBlock->blocked_at->toDateTimeString())->toBe(now()->toDateTimeString()); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 403 when user lacks block_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userToBlock = User::factory()->create(); + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userToBlock = User::factory()->create(); + + // Act + $response = $this->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(401); + }); + + it('prevents admin from blocking themselves', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', $admin->id)); + + // Assert + $response->assertStatus(200); // This is allowed in current implementation + // Note: In a real application, you might want to prevent self-blocking + }); + + it('maintains other user properties when blocking', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $originalData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'email_verified_at' => now(), + 'banned_at' => null, + ]; + + $userToBlock = User::factory()->create($originalData); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); + + // Assert + $response->assertStatus(200); + + $userToBlock->refresh(); + $this->assertNotNull($userToBlock->blocked_at); + $this->assertEquals($originalData['name'], $userToBlock->name); + $this->assertEquals($originalData['email'], $userToBlock->email); + $this->assertEquals($originalData['email_verified_at']->toDateTimeString(), $userToBlock->email_verified_at->toDateTimeString()); + $this->assertEquals($originalData['banned_at'], $userToBlock->banned_at); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/CreateUserControllerTest.php b/tests/Feature/API/V1/Admin/User/CreateUserControllerTest.php new file mode 100644 index 0000000..00bfb3c --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/CreateUserControllerTest.php @@ -0,0 +1,258 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'bio' => 'A test user bio', + 'twitter' => 'johndoe', + 'role_id' => $authorRole->id, + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(201) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'bio', + 'twitter', + 'roles', + 'status', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $user = User::where('email', 'john@example.com')->first(); + expect($user->roles)->toHaveCount(1); + expect($user->roles->first()->name)->toBe(UserRole::AUTHOR->value); + }); + + it('can create a user without optional fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userData = [ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'password' => 'password123', + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(201); + + $this->assertDatabaseHas('users', [ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + ]); + }); + + it('validates required fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), []); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The name field is required. (and 2 more errors)', + 'data' => null, + 'error' => [ + 'name' => ['The name field is required.'], + 'email' => ['The email field is required.'], + 'password' => ['The password field is required.'], + ], + ]); + }); + + it('validates email uniqueness', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $existingUser = User::factory()->create(['email' => 'test@example.com']); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'test@example.com', + 'password' => 'password123', + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The email has already been taken.', + 'data' => null, + 'error' => [ + 'email' => ['The email has already been taken.'], + ], + ]); + }); + + it('validates password minimum length', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => '123', + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The password field must be at least 8 characters.', + 'data' => null, + 'error' => [ + 'password' => ['The password field must be at least 8 characters.'], + ], + ]); + }); + + it('validates URL fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'avatar_url' => 'not-a-url', + 'website' => 'invalid-url', + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The avatar url field must be a valid URL. (and 1 more error)', + 'data' => null, + 'error' => [ + 'avatar_url' => ['The avatar url field must be a valid URL.'], + 'website' => ['The website field must be a valid URL.'], + ], + ]); + }); + + it('validates role_id exists', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'role_id' => 99999, // Non-existent role + ]; + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The selected role id is invalid.', + 'data' => null, + 'error' => [ + 'role_id' => ['The selected role id is invalid.'], + ], + ]); + }); + + it('returns 403 when user lacks create_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + ]; + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + ]; + + // Act + $response = $this->postJson(route('api.v1.admin.users.store'), $userData); + + // Assert + $response->assertStatus(401); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php b/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php new file mode 100644 index 0000000..615ddcc --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php @@ -0,0 +1,269 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + ]) + ->assertJson([ + 'status' => true, + 'message' => __('common.user_deleted_successfully'), + ]); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('can delete a banned user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(['banned_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('can delete a blocked user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(['blocked_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('can delete a user with both banned and blocked status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create([ + 'banned_at' => now()->subDays(5), + 'blocked_at' => now()->subDays(2), + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 403 when user lacks delete_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userToDelete = User::factory()->create(); + + // Act + $response = $this->actingAs($user) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userToDelete = User::factory()->create(); + + // Act + $response = $this->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(401); + }); + + it('prevents admin from deleting themselves', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $admin->id)); + + // Assert + $response->assertStatus(200); // This is allowed in current implementation + // Note: In a real application, you might want to prevent self-deletion + }); + + it('deletes user with verified email', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('deletes user with unverified email', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create([ + 'email_verified_at' => null, + ]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('deletes user with roles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(); + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $userToDelete->roles()->attach($authorRole->id); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('deletes user with multiple roles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(); + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $editorRole = Role::where('name', UserRole::EDITOR->value)->first(); + $userToDelete->roles()->attach([$authorRole->id, $editorRole->id]); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); + + it('deletes user with no roles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToDelete = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->deleteJson(route('api.v1.admin.users.destroy', $userToDelete->id)); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseMissing('users', [ + 'id' => $userToDelete->id, + ]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/GetUsersControllerTest.php b/tests/Feature/API/V1/Admin/User/GetUsersControllerTest.php new file mode 100644 index 0000000..717eb3f --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/GetUsersControllerTest.php @@ -0,0 +1,197 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $users = User::factory()->count(5)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index')); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'users' => [ + '*' => [ + 'id', + 'name', + 'email', + 'email_verified_at', + 'avatar_url', + 'bio', + 'twitter', + 'facebook', + 'linkedin', + 'github', + 'website', + 'banned_at', + 'blocked_at', + 'created_at', + 'updated_at', + 'roles', + 'articles_count', + 'comments_count', + 'status', + ], + ], + 'meta' => [ + 'current_page', + 'from', + 'last_page', + 'per_page', + 'to', + 'total', + ], + ], + ]); + }); + + it('can filter users by search term', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $user1 = User::factory()->create(['name' => 'TestSearch Doe']); + $user2 = User::factory()->create(['name' => 'Jane Smith']); + $user3 = User::factory()->create(['name' => 'Bob TestSearched']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index', ['search' => 'TestSearch'])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.users'); + expect($data)->toHaveCount(2); // TestSearch Doe and Bob TestSearched + }); + + it('can filter users by role', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $user1 = User::factory()->create(); + $user1->roles()->attach($authorRole->id); + + $user2 = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index', ['role_id' => $authorRole->id])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.users'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($user1->id); + }); + + it('can filter users by status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $bannedUser = User::factory()->create(['banned_at' => now()]); + $blockedUser = User::factory()->create(['blocked_at' => now()]); + $activeUser = User::factory()->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index', ['status' => 'banned'])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.users'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($bannedUser->id); + expect($data[0]['status'])->toBe('banned'); + }); + + it('can sort users by different fields', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $user1 = User::factory()->create(['name' => 'Alice']); + $user2 = User::factory()->create(['name' => 'Bob']); + $user3 = User::factory()->create(['name' => 'Charlie']); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index', [ + 'sort_by' => 'name', + 'sort_direction' => 'desc', + ])); + + // Assert + $response->assertStatus(200); + $data = $response->json('data.users'); + // Note: The test data might include seeded users, so we need to be more flexible + $userNames = collect($data)->pluck('name')->toArray(); + expect(in_array('Charlie', $userNames))->toBeTrue(); + expect(in_array('Bob', $userNames))->toBeTrue(); + expect(in_array('Alice', $userNames))->toBeTrue(); + }); + + it('can paginate users', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + User::factory()->count(25)->create(); + + // Act + $response = $this->actingAs($admin) + ->getJson(route('api.v1.admin.users.index', ['per_page' => 10, 'page' => 2])); + + // Assert + $response->assertStatus(200); + $meta = $response->json('data.meta'); + expect($meta['current_page'])->toBe(2); + expect($meta['per_page'])->toBe(10); + expect($meta['total'])->toBeGreaterThan(25); + }); + + it('returns 403 when user lacks view_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + // Act + $response = $this->actingAs($user) + ->getJson(route('api.v1.admin.users.index')); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Act + $response = $this->getJson(route('api.v1.admin.users.index')); + + // Assert + $response->assertStatus(401); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/ShowUserControllerTest.php b/tests/Feature/API/V1/Admin/User/ShowUserControllerTest.php new file mode 100644 index 0000000..df6b1f5 --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/ShowUserControllerTest.php @@ -0,0 +1,357 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'banned_at' => null, + 'blocked_at' => null, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'name', 'email', 'email_verified_at', 'banned_at', 'blocked_at', + 'status', 'created_at', 'updated_at', + 'roles' => [ + '*' => [ + 'id', 'name', 'display_name', + ], + ], + ], + ]) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'banned_at' => null, + 'blocked_at' => null, + ], + ]); + }); + + it('can show a banned user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $bannedUser = User::factory()->create([ + 'banned_at' => now(), + 'blocked_at' => null, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $bannedUser->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $bannedUser->id, + 'banned_at' => $bannedUser->banned_at->toISOString(), + 'blocked_at' => null, + ], + ]); + }); + + it('can show a blocked user', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $blockedUser = User::factory()->create([ + 'banned_at' => null, + 'blocked_at' => now(), + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $blockedUser->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $blockedUser->id, + 'banned_at' => null, + 'blocked_at' => $blockedUser->blocked_at->toISOString(), + ], + ]); + }); + + it('can show a user with both banned and blocked status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create([ + 'banned_at' => now()->subDays(5), + 'blocked_at' => now()->subDays(2), + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $user->id, + 'banned_at' => $user->banned_at->toISOString(), + 'blocked_at' => $user->blocked_at->toISOString(), + ], + ]); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $user = User::factory()->create(); + + // Act + $response = $this->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + // Don't attach any roles to test authorization failure + + $token = $user->createToken('test-token', ['access-api']); + + $targetUser = User::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $targetUser->id)); + + // Assert + $response->assertStatus(403); + }); + + it('includes user roles in response', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($authorRole->id); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'roles' => [ + '*' => [ + 'id', 'name', 'display_name', + ], + ], + ], + ]) + ->assertJson([ + 'data' => [ + 'roles' => [ + [ + 'id' => $authorRole->id, + 'name' => UserRole::AUTHOR->value, + 'display_name' => UserRole::AUTHOR->displayName(), + ], + ], + ], + ]); + }); + + it('handles users with multiple roles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $editorRole = Role::where('name', UserRole::EDITOR->value)->first(); + $user->roles()->attach([$authorRole->id, $editorRole->id]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'roles' => [ + '*' => [ + 'id', 'name', 'display_name', + ], + ], + ], + ]); + + $responseData = $response->json('data.roles'); + $this->assertCount(2, $responseData); + + $roleNames = collect($responseData)->pluck('name')->toArray(); + $this->assertContains(UserRole::AUTHOR->value, $roleNames); + $this->assertContains(UserRole::EDITOR->value, $roleNames); + }); + + it('handles users with no roles', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $user = User::factory()->create(); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $user->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'id', 'name', 'email', 'email_verified_at', 'banned_at', 'blocked_at', + 'status', 'created_at', 'updated_at', 'roles', + ], + ]) + ->assertJson([ + 'data' => [ + 'id' => $user->id, + 'roles' => [], + ], + ]); + }); + + it('includes email verification status', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $verifiedUser = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $verifiedUser->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $verifiedUser->id, + 'email_verified_at' => $verifiedUser->email_verified_at->toISOString(), + ], + ]); + }); + + it('handles unverified email users', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $unverifiedUser = User::factory()->create([ + 'email_verified_at' => null, + ]); + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->getJson(route('api.v1.admin.users.show', $unverifiedUser->id)); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'id' => $unverifiedUser->id, + 'email_verified_at' => null, + ], + ]); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php b/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php new file mode 100644 index 0000000..e209b9c --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php @@ -0,0 +1,200 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnban = User::factory()->create(['banned_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'banned_at', + 'status', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUnban->id, + 'banned_at' => null, + ]); + + $userToUnban->refresh(); + expect($userToUnban->banned_at)->toBeNull(); + }); + + it('can unban a user who is also blocked', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnban = User::factory()->create([ + 'banned_at' => now(), + 'blocked_at' => now(), + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(200); + + $userToUnban->refresh(); + expect($userToUnban->banned_at)->toBeNull(); + expect($userToUnban->blocked_at)->not->toBeNull(); // Should remain blocked + }); + + it('can unban an already unbanned user (no effect)', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnban = User::factory()->create(['banned_at' => null]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(200); + + $userToUnban->refresh(); + expect($userToUnban->banned_at)->toBeNull(); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 403 when user lacks unban_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userToUnban = User::factory()->create(['banned_at' => now()]); + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userToUnban = User::factory()->create(['banned_at' => now()]); + + // Act + $response = $this->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(401); + }); + + it('allows admin to unban themselves', function () { + // Arrange + $admin = User::factory()->create(['banned_at' => now()]); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $admin->id)); + + // Assert + $response->assertStatus(200); + + $admin->refresh(); + expect($admin->banned_at)->toBeNull(); + }); + + it('maintains other user properties when unbanning', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $originalData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'email_verified_at' => now(), + 'blocked_at' => now()->subDays(2), + ]; + + $userToUnban = User::factory()->create(array_merge($originalData, [ + 'banned_at' => now(), + ])); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(200); + + $userToUnban->refresh(); + $this->assertNull($userToUnban->banned_at); + $this->assertEquals($originalData['name'], $userToUnban->name); + $this->assertEquals($originalData['email'], $userToUnban->email); + $this->assertEquals($originalData['email_verified_at']->toDateTimeString(), $userToUnban->email_verified_at->toDateTimeString()); + $this->assertEquals($originalData['blocked_at']->toDateTimeString(), $userToUnban->blocked_at->toDateTimeString()); + }); + + it('handles users with long ban history', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldBanTime = now()->subMonths(6); + $userToUnban = User::factory()->create(['banned_at' => $oldBanTime]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unban', $userToUnban->id)); + + // Assert + $response->assertStatus(200); + + $userToUnban->refresh(); + expect($userToUnban->banned_at)->toBeNull(); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php b/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php new file mode 100644 index 0000000..1ccdd48 --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php @@ -0,0 +1,220 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnblock = User::factory()->create(['blocked_at' => now()]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'blocked_at', + 'status', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUnblock->id, + 'blocked_at' => null, + ]); + + $userToUnblock->refresh(); + expect($userToUnblock->blocked_at)->toBeNull(); + }); + + it('can unblock a user who is also banned', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnblock = User::factory()->create([ + 'banned_at' => now(), + 'blocked_at' => now(), + ]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200); + + $userToUnblock->refresh(); + expect($userToUnblock->blocked_at)->toBeNull(); + expect($userToUnblock->banned_at)->not->toBeNull(); // Should remain banned + }); + + it('can unblock an already unblocked user (no effect)', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $userToUnblock = User::factory()->create(['blocked_at' => null]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200); + + $userToUnblock->refresh(); + expect($userToUnblock->blocked_at)->toBeNull(); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', 99999)); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 403 when user lacks unblock_users permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $userToUnblock = User::factory()->create(['blocked_at' => now()]); + + // Act + $response = $this->actingAs($user) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $userToUnblock = User::factory()->create(['blocked_at' => now()]); + + // Act + $response = $this->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(401); + }); + + it('allows admin to unblock themselves', function () { + // Arrange + $admin = User::factory()->create(['blocked_at' => now()]); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $admin->id)); + + // Assert + $response->assertStatus(200); + + $admin->refresh(); + expect($admin->blocked_at)->toBeNull(); + }); + + it('maintains other user properties when unblocking', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $originalData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'email_verified_at' => now(), + 'banned_at' => now()->subDays(2), + ]; + + $userToUnblock = User::factory()->create(array_merge($originalData, [ + 'blocked_at' => now(), + ])); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200); + + $userToUnblock->refresh(); + $this->assertNull($userToUnblock->blocked_at); + $this->assertEquals($originalData['name'], $userToUnblock->name); + $this->assertEquals($originalData['email'], $userToUnblock->email); + $this->assertEquals($originalData['email_verified_at']->toDateTimeString(), $userToUnblock->email_verified_at->toDateTimeString()); + $this->assertEquals($originalData['banned_at']->toDateTimeString(), $userToUnblock->banned_at->toDateTimeString()); + }); + + it('handles users with long block history', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $oldBlockTime = now()->subMonths(3); + $userToUnblock = User::factory()->create(['blocked_at' => $oldBlockTime]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200); + + $userToUnblock->refresh(); + expect($userToUnblock->blocked_at)->toBeNull(); + }); + + it('can unblock user with recent block', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $recentBlockTime = now()->subHours(2); + $userToUnblock = User::factory()->create(['blocked_at' => $recentBlockTime]); + + // Act + $response = $this->actingAs($admin) + ->postJson(route('api.v1.admin.users.unblock', $userToUnblock->id)); + + // Assert + $response->assertStatus(200); + + $userToUnblock->refresh(); + expect($userToUnblock->blocked_at)->toBeNull(); + }); +}); diff --git a/tests/Feature/API/V1/Admin/User/UpdateUserControllerTest.php b/tests/Feature/API/V1/Admin/User/UpdateUserControllerTest.php new file mode 100644 index 0000000..12fb554 --- /dev/null +++ b/tests/Feature/API/V1/Admin/User/UpdateUserControllerTest.php @@ -0,0 +1,415 @@ +create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create([ + 'name' => 'Old Name', + 'email' => 'old@example.com', + ]); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', 'name', 'email', 'email_verified_at', 'banned_at', 'blocked_at', + 'status', 'created_at', 'updated_at', + 'roles' => [ + '*' => [ + 'id', 'name', 'display_name', + ], + ], + ], + ]) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $userToUpdate->id, + 'name' => 'New Name', + 'email' => 'new@example.com', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUpdate->id, + 'name' => 'New Name', + 'email' => 'new@example.com', + ]); + }); + + it('can update only name', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create([ + 'name' => 'Old Name', + 'email' => 'test@example.com', + ]); + + $updateData = [ + 'name' => 'New Name', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUpdate->id, + 'name' => 'New Name', + 'email' => 'test@example.com', // Should remain unchanged + ]); + }); + + it('can update only email', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create([ + 'name' => 'Test User', + 'email' => 'old@example.com', + ]); + + $updateData = [ + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUpdate->id, + 'name' => 'Test User', // Should remain unchanged + 'email' => 'new@example.com', + ]); + }); + + it('returns 404 when user does not exist', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', 99999), $updateData); + + // Assert + $response->assertStatus(404) + ->assertJson([ + 'status' => false, + 'message' => __('common.user_not_found'), + ]); + }); + + it('returns 401 when user is not authenticated', function () { + // Arrange + $userToUpdate = User::factory()->create(); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(401); + }); + + it('returns 403 when user does not have permission', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $token = $user->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create(); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(403); + }); + + it('validates email format', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create(); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'invalid-email', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'error' => [ + 'email' => ['The email field must be a valid email address.'], + ], + ]); + }); + + it('validates email uniqueness', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $existingUser = User::factory()->create(['email' => 'existing@example.com']); + $userToUpdate = User::factory()->create(['email' => 'test@example.com']); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'existing@example.com', // Already exists + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'error' => [ + 'email' => ['The email has already been taken.'], + ], + ]); + }); + + it('allows updating to same email', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create([ + 'name' => 'Old Name', + 'email' => 'test@example.com', + ]); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'test@example.com', // Same email + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $userToUpdate->id, + 'name' => 'New Name', + 'email' => 'test@example.com', + ]); + }); + + it('validates name is required when provided', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create(); + + $updateData = [ + 'name' => '', // Empty name + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'error' => [ + 'name' => ['The name field is required.'], + ], + ]); + }); + + it('validates name length', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $userToUpdate = User::factory()->create(); + + $updateData = [ + 'name' => str_repeat('a', 256), // Too long + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'error' => [ + 'name' => ['The name field must not be greater than 255 characters.'], + ], + ]); + }); + + it('maintains other user properties when updating', function () { + // Arrange + $admin = User::factory()->create(); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $originalData = [ + 'email_verified_at' => now(), + 'banned_at' => now()->subDays(5), + 'blocked_at' => now()->subDays(2), + ]; + + $userToUpdate = User::factory()->create($originalData); + + $updateData = [ + 'name' => 'New Name', + 'email' => 'new@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $userToUpdate->id), $updateData); + + // Assert + $response->assertStatus(200); + + $userToUpdate->refresh(); + $this->assertEquals('New Name', $userToUpdate->name); + $this->assertEquals('new@example.com', $userToUpdate->email); + $this->assertEquals($originalData['email_verified_at']->toDateTimeString(), $userToUpdate->email_verified_at->toDateTimeString()); + $this->assertEquals($originalData['banned_at']->toDateTimeString(), $userToUpdate->banned_at->toDateTimeString()); + $this->assertEquals($originalData['blocked_at']->toDateTimeString(), $userToUpdate->blocked_at->toDateTimeString()); + }); + + it('allows admin to update themselves', function () { + // Arrange + $admin = User::factory()->create([ + 'name' => 'Old Admin Name', + 'email' => 'admin@example.com', + ]); + $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); + $admin->roles()->attach($adminRole->id); + + $token = $admin->createToken('test-token', ['access-api']); + + $updateData = [ + 'name' => 'New Admin Name', + 'email' => 'newadmin@example.com', + ]; + + // Act + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token->plainTextToken, + ])->putJson(route('api.v1.admin.users.update', $admin->id), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $admin->id, + 'name' => 'New Admin Name', + 'email' => 'newadmin@example.com', + ]); + }); +}); diff --git a/tests/Feature/API/V1/Auth/LoginControllerTest.php b/tests/Feature/API/V1/Auth/LoginControllerTest.php index d2132cd..831fdf0 100644 --- a/tests/Feature/API/V1/Auth/LoginControllerTest.php +++ b/tests/Feature/API/V1/Auth/LoginControllerTest.php @@ -67,7 +67,7 @@ $this->mock(AuthServiceInterface::class, function (MockInterface $mock) { $mock->shouldReceive('login') ->with('test@example.com', 'AnotherValid123!') - ->andThrow(new \Exception('Database connection failed')); + ->andThrow(new \Exception(__('common.database_connection_failed'))); }); // Attempt login which will trigger unexpected exception diff --git a/tests/Feature/API/V1/Auth/LogoutControllerTest.php b/tests/Feature/API/V1/Auth/LogoutControllerTest.php index c81f053..0ba6097 100644 --- a/tests/Feature/API/V1/Auth/LogoutControllerTest.php +++ b/tests/Feature/API/V1/Auth/LogoutControllerTest.php @@ -51,7 +51,7 @@ $mock->shouldReceive('logout') ->with($user) ->once() - ->andThrow(new \Exception('Database connection failed')); + ->andThrow(new \Exception(__('common.database_connection_failed'))); }); // Attempt logout which will trigger unexpected exception diff --git a/tests/Feature/API/V1/Auth/RefreshTokenControllerTest.php b/tests/Feature/API/V1/Auth/RefreshTokenControllerTest.php index da89cfd..802e23a 100644 --- a/tests/Feature/API/V1/Auth/RefreshTokenControllerTest.php +++ b/tests/Feature/API/V1/Auth/RefreshTokenControllerTest.php @@ -79,7 +79,7 @@ $mock->shouldReceive('refreshToken') ->with('some-refresh-token') ->once() - ->andThrow(new \Exception('Database connection failed')); + ->andThrow(new \Exception(__('common.database_connection_failed'))); }); // Attempt to refresh token which will trigger unexpected exception diff --git a/tests/Feature/API/V1/User/MeControllerTest.php b/tests/Feature/API/V1/User/MeControllerTest.php index b2fadf1..99a4809 100644 --- a/tests/Feature/API/V1/User/MeControllerTest.php +++ b/tests/Feature/API/V1/User/MeControllerTest.php @@ -48,4 +48,350 @@ ], ]); }); + + it('returns 401 when not authenticated', function () { + // Make request without authentication + $response = $this->getJson('/api/v1/me'); + + // Assert unauthorized response + $response->assertStatus(401); + }); + + it('returns 401 when token lacks access-api ability', function () { + // Create a test user + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Authenticate the user without the required ability + Sanctum::actingAs($user, ['read']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert unauthorized response + $response->assertStatus(401); + }); + + it('handles user with complete profile information', function () { + // Create a test user with full profile + $user = User::factory()->create([ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'bio' => 'Software developer and tech enthusiast', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'twitter' => 'https://twitter.com/janesmith', + 'facebook' => 'https://facebook.com/janesmith', + 'linkedin' => 'https://linkedin.com/in/janesmith', + 'github' => 'https://github.com/janesmith', + 'website' => 'https://janesmith.dev', + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response structure and content + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'bio' => 'Software developer and tech enthusiast', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'twitter' => 'https://twitter.com/janesmith', + 'facebook' => 'https://facebook.com/janesmith', + 'linkedin' => 'https://linkedin.com/in/janesmith', + 'github' => 'https://github.com/janesmith', + 'website' => 'https://janesmith.dev', + ], + ]); + }); + + it('handles user with minimal profile information', function () { + // Create a test user with minimal profile + $user = User::factory()->create([ + 'name' => 'Minimal User', + 'email' => 'minimal@example.com', + 'bio' => null, + 'avatar_url' => null, + 'twitter' => null, + 'facebook' => null, + 'linkedin' => null, + 'github' => null, + 'website' => null, + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response structure and content + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Minimal User', + 'email' => 'minimal@example.com', + 'bio' => null, + 'avatar_url' => null, + 'twitter' => null, + 'facebook' => null, + 'linkedin' => null, + 'github' => null, + 'website' => null, + ], + ]); + }); + + it('handles user with verified email', function () { + // Create a test user with verified email + $user = User::factory()->create([ + 'name' => 'Verified User', + 'email' => 'verified@example.com', + 'email_verified_at' => now(), + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response includes email verification + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Verified User', + 'email' => 'verified@example.com', + 'email_verified_at' => $user->email_verified_at->toISOString(), + ], + ]); + }); + + it('handles user with unverified email', function () { + // Create a test user with unverified email + $user = User::factory()->create([ + 'name' => 'Unverified User', + 'email' => 'unverified@example.com', + 'email_verified_at' => null, + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response includes null email verification + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Unverified User', + 'email' => 'unverified@example.com', + 'email_verified_at' => null, + ], + ]); + }); + + it('handles user with long bio text', function () { + // Create a test user with long bio + $longBio = str_repeat('This is a very long bio text. ', 20); + $user = User::factory()->create([ + 'name' => 'Long Bio User', + 'email' => 'longbio@example.com', + 'bio' => $longBio, + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response includes long bio + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Long Bio User', + 'email' => 'longbio@example.com', + 'bio' => $longBio, + ], + ]); + }); + + it('handles user with special characters in name', function () { + // Create a test user with special characters + $user = User::factory()->create([ + 'name' => 'José María O\'Connor-Smith', + 'email' => 'special@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 handles special characters + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'José María O\'Connor-Smith', + 'email' => 'special@example.com', + ], + ]); + }); + + it('handles user with very long email address', function () { + // Create a test user with long email + $longEmail = 'very.long.email.address.with.many.subdomains@very.long.domain.name.example.com'; + $user = User::factory()->create([ + 'name' => 'Long Email User', + 'email' => $longEmail, + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response handles long email + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Long Email User', + 'email' => $longEmail, + ], + ]); + }); + + it('handles user with roles and permissions', function () { + // Create a test user with roles + $user = User::factory()->create([ + 'name' => 'Role User', + 'email' => 'role@example.com', + ]); + + // Attach roles to user (if roles exist in seeder) + $adminRole = \App\Models\Role::where('name', 'administrator')->first(); + if ($adminRole) { + $user->roles()->attach($adminRole->id); + } + + // 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' => 'Role User', + 'email' => 'role@example.com', + ], + ]); + }); + + it('handles user with banned status', function () { + // Create a test user with banned status + $user = User::factory()->create([ + 'name' => 'Banned User', + 'email' => 'banned@example.com', + 'banned_at' => now(), + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response includes banned status + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Banned User', + 'email' => 'banned@example.com', + ], + ]); + }); + + it('handles user with blocked status', function () { + // Create a test user with blocked status + $user = User::factory()->create([ + 'name' => 'Blocked User', + 'email' => 'blocked@example.com', + 'blocked_at' => now(), + ]); + + // Authenticate the user with Sanctum + Sanctum::actingAs($user, ['access-api']); + + // Make request to /me endpoint + $response = $this->getJson('/api/v1/me'); + + // Assert response includes blocked status + $response + ->assertStatus(200) + ->assertJson([ + 'status' => true, + 'data' => [ + 'id' => $user->id, + 'name' => 'Blocked User', + 'email' => 'blocked@example.com', + ], + ]); + }); }); diff --git a/tests/Feature/API/V1/User/UpdateProfileControllerTest.php b/tests/Feature/API/V1/User/UpdateProfileControllerTest.php new file mode 100644 index 0000000..8488890 --- /dev/null +++ b/tests/Feature/API/V1/User/UpdateProfileControllerTest.php @@ -0,0 +1,218 @@ +create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $updateData = [ + 'name' => 'Updated Name', + 'bio' => 'Updated bio information', + 'twitter' => 'updated_twitter', + 'website' => 'https://example.com', + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'bio', + 'twitter', + 'website', + ], + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => 'Updated Name', + 'bio' => 'Updated bio information', + 'twitter' => 'updated_twitter', + 'website' => 'https://example.com', + ]); + }); + + it('can update partial profile data', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $originalName = $user->name; + $updateData = [ + 'bio' => 'Only updating bio', + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'name' => $originalName, // Should remain unchanged + 'bio' => 'Only updating bio', + ]); + }); + + it('validates URL fields', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $updateData = [ + 'avatar_url' => 'not-a-url', + 'website' => 'invalid-url', + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The avatar url field must be a valid URL. (and 1 more error)', + 'data' => null, + 'error' => [ + 'avatar_url' => ['The avatar url field must be a valid URL.'], + 'website' => ['The website field must be a valid URL.'], + ], + ]); + }); + + it('validates string length limits', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $updateData = [ + 'name' => str_repeat('a', 300), // Too long + 'bio' => str_repeat('b', 1500), // Too long + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(422) + ->assertJson([ + 'status' => false, + 'message' => 'The name field must not be greater than 255 characters. (and 1 more error)', + 'data' => null, + 'error' => [ + 'name' => ['The name field must not be greater than 255 characters.'], + 'bio' => ['The bio field must not be greater than 1000 characters.'], + ], + ]); + }); + + it('returns 403 when user lacks edit_profile permission', function () { + // Arrange + // Create a user without any roles (no permissions) + $user = User::factory()->create(); + + $updateData = [ + 'name' => 'Updated Name', + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(403); + }); + + it('returns 401 when not authenticated', function () { + // Arrange + $updateData = [ + 'name' => 'Updated Name', + ]; + + // Act + $response = $this->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(401); + }); + + it('can clear optional fields by setting them to null', function () { + // Arrange + $user = User::factory()->create([ + 'bio' => 'Original bio', + 'twitter' => 'original_twitter', + ]); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $updateData = [ + 'bio' => null, + 'twitter' => null, + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'bio' => null, + 'twitter' => null, + ]); + }); + + it('can update social media links', function () { + // Arrange + $user = User::factory()->create(); + $subscriberRole = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($subscriberRole->id); + + $updateData = [ + 'twitter' => 'new_twitter', + 'facebook' => 'new_facebook', + 'linkedin' => 'new_linkedin', + 'github' => 'new_github', + ]; + + // Act + $response = $this->actingAs($user) + ->putJson(route('api.v1.user.profile.update'), $updateData); + + // Assert + $response->assertStatus(200); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'twitter' => 'new_twitter', + 'facebook' => 'new_facebook', + 'linkedin' => 'new_linkedin', + 'github' => 'new_github', + ]); + }); +}); diff --git a/tests/Feature/Providers/AuthServiceProviderTest.php b/tests/Feature/Providers/AuthServiceProviderTest.php index 49a5146..b8cd7e6 100644 --- a/tests/Feature/Providers/AuthServiceProviderTest.php +++ b/tests/Feature/Providers/AuthServiceProviderTest.php @@ -187,7 +187,7 @@ // We'll mock the Schema facade to throw an exception Schema::shouldReceive('hasTable') ->with('permissions') - ->andThrow(new \Exception('Database connection failed')); + ->andThrow(new \Exception(__('common.database_connection_failed'))); // Boot provider - should not throw exception $provider = new AuthServiceProvider(app()); From 489df857f126b95df1aadb6e24868e89fe37cce2 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Mon, 4 Aug 2025 00:04:42 +0500 Subject: [PATCH 3/5] test: remove extra useless test --- .../V1/Admin/User/BlockUserControllerTest.php | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php index c727344..d401c12 100644 --- a/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php @@ -62,26 +62,6 @@ expect($userToBlock->banned_at)->not->toBeNull(); }); - it('can block an already blocked user (updates timestamp)', function () { - // Arrange - $admin = User::factory()->create(); - $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); - $admin->roles()->attach($adminRole->id); - - $oldBlockTime = now()->subDays(5); - $userToBlock = User::factory()->create(['blocked_at' => $oldBlockTime]); - - // Act - $response = $this->actingAs($admin) - ->postJson(route('api.v1.admin.users.block', $userToBlock->id)); - - // Assert - $response->assertStatus(200); - - $userToBlock->refresh(); - expect($userToBlock->blocked_at->toDateTimeString())->toBe(now()->toDateTimeString()); - }); - it('returns 404 when user does not exist', function () { // Arrange $admin = User::factory()->create(); From 7b386845cb49e377e4ac899ffccd626e5117004f Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Mon, 4 Aug 2025 00:21:02 +0500 Subject: [PATCH 4/5] test: fix route names for testing --- routes/api_v1.php | 2 +- .../V1/Article/ShowArticleControllerTest.php | 2 -- .../Category/GetCategoriesControllerTest.php | 4 +-- .../API/V1/Tag/GetTagsControllerTest.php | 4 +-- .../Feature/API/V1/User/MeControllerTest.php | 26 +++++++++---------- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/routes/api_v1.php b/routes/api_v1.php index 12f3a19..ab69cfd 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -14,7 +14,7 @@ // User Routes Route::middleware(['auth:sanctum', 'ability:access-api'])->group(function () { - Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class); + Route::get('/me', \App\Http\Controllers\Api\V1\User\MeController::class)->name('api.v1.me'); Route::put('/profile', \App\Http\Controllers\Api\V1\User\UpdateProfileController::class)->name('api.v1.user.profile.update'); Route::post('/auth/logout', \App\Http\Controllers\Api\V1\Auth\LogoutController::class)->name('api.v1.auth.logout'); diff --git a/tests/Feature/API/V1/Article/ShowArticleControllerTest.php b/tests/Feature/API/V1/Article/ShowArticleControllerTest.php index 788e58e..4a02d63 100644 --- a/tests/Feature/API/V1/Article/ShowArticleControllerTest.php +++ b/tests/Feature/API/V1/Article/ShowArticleControllerTest.php @@ -73,8 +73,6 @@ }); it('returns 404 when article not found by slug', function () { - $response = $this->getJson('/api/v1/articles/non-existent-slug'); - $response = $this->getJson(route('api.v1.articles.show', ['slug' => 'non-existent-slug'])); $response->assertStatus(404) ->assertJson([ diff --git a/tests/Feature/API/V1/Category/GetCategoriesControllerTest.php b/tests/Feature/API/V1/Category/GetCategoriesControllerTest.php index 04b5984..40821ca 100644 --- a/tests/Feature/API/V1/Category/GetCategoriesControllerTest.php +++ b/tests/Feature/API/V1/Category/GetCategoriesControllerTest.php @@ -8,7 +8,7 @@ it('can get all categories', function () { Category::factory()->count(3)->create(); - $response = $this->getJson('/api/v1/categories'); + $response = $this->getJson(route('api.v1.categories.index')); $response->assertStatus(200) ->assertJson(['status' => true]) @@ -32,7 +32,7 @@ ->andThrow(new Exception('fail')); }); - $response = $this->getJson('/api/v1/categories'); + $response = $this->getJson(route('api.v1.categories.index')); $response->assertStatus(500) ->assertJson(['status' => false]) diff --git a/tests/Feature/API/V1/Tag/GetTagsControllerTest.php b/tests/Feature/API/V1/Tag/GetTagsControllerTest.php index ed71aff..c6ec3a0 100644 --- a/tests/Feature/API/V1/Tag/GetTagsControllerTest.php +++ b/tests/Feature/API/V1/Tag/GetTagsControllerTest.php @@ -8,7 +8,7 @@ it('can get all tags', function () { Tag::factory()->count(4)->create(); - $response = $this->getJson('/api/v1/tags'); + $response = $this->getJson(route('api.v1.tags.index')); $response->assertStatus(200) ->assertJson(['status' => true]) @@ -32,7 +32,7 @@ ->andThrow(new Exception('fail')); }); - $response = $this->getJson('/api/v1/tags'); + $response = $this->getJson(route('api.v1.tags.index')); $response->assertStatus(500) ->assertJson(['status' => false]) diff --git a/tests/Feature/API/V1/User/MeControllerTest.php b/tests/Feature/API/V1/User/MeControllerTest.php index 99a4809..2b62d9e 100644 --- a/tests/Feature/API/V1/User/MeControllerTest.php +++ b/tests/Feature/API/V1/User/MeControllerTest.php @@ -17,7 +17,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response structure $response @@ -51,7 +51,7 @@ it('returns 401 when not authenticated', function () { // Make request without authentication - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert unauthorized response $response->assertStatus(401); @@ -68,7 +68,7 @@ Sanctum::actingAs($user, ['read']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert unauthorized response $response->assertStatus(401); @@ -92,7 +92,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response structure and content $response @@ -132,7 +132,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response structure and content $response @@ -166,7 +166,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response includes email verification $response @@ -194,7 +194,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response includes null email verification $response @@ -223,7 +223,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response includes long bio $response @@ -250,7 +250,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response handles special characters $response @@ -277,7 +277,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response handles long email $response @@ -309,7 +309,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response structure $response @@ -353,7 +353,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response includes banned status $response @@ -380,7 +380,7 @@ Sanctum::actingAs($user, ['access-api']); // Make request to /me endpoint - $response = $this->getJson('/api/v1/me'); + $response = $this->getJson(route('api.v1.me')); // Assert response includes blocked status $response From f72ff0d5450af5d117f1b2289d18dce3a79f31e6 Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 6 Aug 2025 01:03:03 +0500 Subject: [PATCH 5/5] test: removed useless test --- .../Api/V1/Admin/User/BanUserController.php | 12 +++++++ .../Api/V1/Admin/User/BlockUserController.php | 12 +++++++ .../V1/Admin/User/DeleteUserController.php | 12 +++++++ .../Api/V1/Admin/User/UnbanUserController.php | 12 +++++++ .../V1/Admin/User/UnblockUserController.php | 12 +++++++ app/Services/UserService.php | 34 +++++++++++++++++++ lang/en/common.php | 5 +++ .../V1/Admin/User/BanUserControllerTest.php | 27 +++------------ .../V1/Admin/User/BlockUserControllerTest.php | 7 ++-- .../Admin/User/DeleteUserControllerTest.php | 7 ++-- .../V1/Admin/User/UnbanUserControllerTest.php | 10 ++++-- .../Admin/User/UnblockUserControllerTest.php | 10 ++++-- 12 files changed, 128 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php b/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php index af25997..284e684 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/BanUserController.php @@ -35,6 +35,18 @@ public function __invoke(int $id, BanUserRequest $request): JsonResponse new UserDetailResource($user), __('common.user_banned_successfully') ); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + /** + * Forbidden - Cannot ban self + * + * @status 403 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + $e->getMessage(), + Response::HTTP_FORBIDDEN + ); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { /** * User not found diff --git a/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php b/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php index e08aa63..0b15167 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/BlockUserController.php @@ -35,6 +35,18 @@ public function __invoke(int $id, BlockUserRequest $request): JsonResponse new UserDetailResource($user), __('common.user_blocked_successfully') ); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + /** + * Forbidden - Cannot block self + * + * @status 403 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + $e->getMessage(), + Response::HTTP_FORBIDDEN + ); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { /** * User not found diff --git a/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php b/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php index 370e09b..bd113fc 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/DeleteUserController.php @@ -34,6 +34,18 @@ public function __invoke(DeleteUserRequest $request, int $id): JsonResponse null, __('common.user_deleted_successfully') ); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + /** + * Forbidden - Cannot delete self + * + * @status 403 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + $e->getMessage(), + Response::HTTP_FORBIDDEN + ); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { /** * User not found diff --git a/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php b/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php index ffe996a..ea4c0a2 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/UnbanUserController.php @@ -35,6 +35,18 @@ public function __invoke(UnbanUserRequest $request, int $id): JsonResponse new UserDetailResource($user), __('common.user_unbanned_successfully') ); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + /** + * Forbidden - Cannot unban self + * + * @status 403 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + $e->getMessage(), + Response::HTTP_FORBIDDEN + ); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { /** * User not found diff --git a/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php b/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php index 692c04a..ea220c8 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/UnblockUserController.php @@ -35,6 +35,18 @@ public function __invoke(int $id, UnblockUserRequest $request): JsonResponse new UserDetailResource($user), __('common.user_unblocked_successfully') ); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + /** + * Forbidden - Cannot unblock self + * + * @status 403 + * + * @body array{status: false, message: string, data: null, error: null} + */ + return response()->apiError( + $e->getMessage(), + Response::HTTP_FORBIDDEN + ); } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { /** * User not found diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 1e62ac3..e2905fe 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -125,9 +125,13 @@ public function updateUser(int $id, array $data): User /** * Delete a user + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ public function deleteUser(int $id): bool { + $this->preventSelfAction($id, 'cannot_delete_self'); + $user = User::findOrFail($id); /** @var bool $deleted */ @@ -138,9 +142,13 @@ public function deleteUser(int $id): bool /** * Ban a user + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ public function banUser(int $id): User { + $this->preventSelfAction($id, 'cannot_ban_self'); + $user = User::findOrFail($id); $user->update(['banned_at' => now()]); @@ -149,9 +157,13 @@ public function banUser(int $id): User /** * Unban a user + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ public function unbanUser(int $id): User { + $this->preventSelfAction($id, 'cannot_unban_self'); + $user = User::findOrFail($id); $user->update(['banned_at' => null]); @@ -160,9 +172,13 @@ public function unbanUser(int $id): User /** * Block a user + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ public function blockUser(int $id): User { + $this->preventSelfAction($id, 'cannot_block_self'); + $user = User::findOrFail($id); $user->update(['blocked_at' => now()]); @@ -171,9 +187,13 @@ public function blockUser(int $id): User /** * Unblock a user + * + * @throws \Illuminate\Auth\Access\AuthorizationException */ public function unblockUser(int $id): User { + $this->preventSelfAction($id, 'cannot_unblock_self'); + $user = User::findOrFail($id); $user->update(['blocked_at' => null]); @@ -213,6 +233,20 @@ public function assignRoles(int $userId, array $roleIds): User return $user->load(['roles:id,name,slug']); } + /** + * Prevent users from performing actions on themselves + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + private function preventSelfAction(int $id, string $errorKey): void + { + $currentUser = auth()->user(); + + if ($currentUser && $id === $currentUser->id) { + throw new \Illuminate\Auth\Access\AuthorizationException(__("common.{$errorKey}")); + } + } + /** * Apply filters to the query * diff --git a/lang/en/common.php b/lang/en/common.php index 698d963..0dc02f3 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -30,6 +30,11 @@ 'user_blocked_successfully' => 'User blocked successfully.', 'user_unblocked_successfully' => 'User unblocked successfully.', 'profile_updated_successfully' => 'Profile updated successfully.', + 'cannot_delete_self' => 'You cannot delete your own account.', + 'cannot_ban_self' => 'You cannot ban your own account.', + 'cannot_unban_self' => 'You cannot unban your own account.', + 'cannot_block_self' => 'You cannot block your own account.', + 'cannot_unblock_self' => 'You cannot unblock your own account.', // Article Management 'article_not_found' => 'Article not found.', diff --git a/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php b/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php index dd63958..f730f69 100644 --- a/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/BanUserControllerTest.php @@ -63,26 +63,6 @@ expect($userToBan->blocked_at)->not->toBeNull(); }); - it('can ban an already banned user (updates timestamp)', function () { - // Arrange - $admin = User::factory()->create(); - $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); - $admin->roles()->attach($adminRole->id); - - $oldBanTime = now()->subDays(5); - $userToBan = User::factory()->create(['banned_at' => $oldBanTime]); - - // Act - $response = $this->actingAs($admin) - ->postJson(route('api.v1.admin.users.ban', $userToBan->id)); - - // Assert - $response->assertStatus(200); - - $userToBan->refresh(); - expect($userToBan->banned_at->toDateTimeString())->toBe(now()->toDateTimeString()); - }); - it('returns 404 when user does not exist', function () { // Arrange $admin = User::factory()->create(); @@ -139,7 +119,10 @@ ->postJson(route('api.v1.admin.users.ban', $admin->id)); // Assert - $response->assertStatus(200); // This is allowed in current implementation - // Note: In a real application, you might want to prevent self-banning + $response->assertStatus(403) + ->assertJson([ + 'status' => false, + 'message' => __('common.cannot_ban_self'), + ]); }); }); diff --git a/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php index d401c12..0379669 100644 --- a/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/BlockUserControllerTest.php @@ -118,8 +118,11 @@ ->postJson(route('api.v1.admin.users.block', $admin->id)); // Assert - $response->assertStatus(200); // This is allowed in current implementation - // Note: In a real application, you might want to prevent self-blocking + $response->assertStatus(403) + ->assertJson([ + 'status' => false, + 'message' => __('common.cannot_block_self'), + ]); }); it('maintains other user properties when blocking', function () { diff --git a/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php b/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php index 615ddcc..a1fd0d4 100644 --- a/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/DeleteUserControllerTest.php @@ -154,8 +154,11 @@ ->deleteJson(route('api.v1.admin.users.destroy', $admin->id)); // Assert - $response->assertStatus(200); // This is allowed in current implementation - // Note: In a real application, you might want to prevent self-deletion + $response->assertStatus(403) + ->assertJson([ + 'status' => false, + 'message' => __('common.cannot_delete_self'), + ]); }); it('deletes user with verified email', function () { diff --git a/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php b/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php index e209b9c..ad3c6ee 100644 --- a/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/UnbanUserControllerTest.php @@ -129,7 +129,7 @@ $response->assertStatus(401); }); - it('allows admin to unban themselves', function () { + it('prevents admin from unbanning themselves', function () { // Arrange $admin = User::factory()->create(['banned_at' => now()]); $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); @@ -140,10 +140,14 @@ ->postJson(route('api.v1.admin.users.unban', $admin->id)); // Assert - $response->assertStatus(200); + $response->assertStatus(403) + ->assertJson([ + 'status' => false, + 'message' => __('common.cannot_unban_self'), + ]); $admin->refresh(); - expect($admin->banned_at)->toBeNull(); + expect($admin->banned_at)->not->toBeNull(); // Should remain banned }); it('maintains other user properties when unbanning', function () { diff --git a/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php b/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php index 1ccdd48..945028a 100644 --- a/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php +++ b/tests/Feature/API/V1/Admin/User/UnblockUserControllerTest.php @@ -129,7 +129,7 @@ $response->assertStatus(401); }); - it('allows admin to unblock themselves', function () { + it('prevents admin from unblocking themselves', function () { // Arrange $admin = User::factory()->create(['blocked_at' => now()]); $adminRole = Role::where('name', UserRole::ADMINISTRATOR->value)->first(); @@ -140,10 +140,14 @@ ->postJson(route('api.v1.admin.users.unblock', $admin->id)); // Assert - $response->assertStatus(200); + $response->assertStatus(403) + ->assertJson([ + 'status' => false, + 'message' => __('common.cannot_unblock_self'), + ]); $admin->refresh(); - expect($admin->blocked_at)->toBeNull(); + expect($admin->blocked_at)->not->toBeNull(); // Should remain blocked }); it('maintains other user properties when unblocking', function () {