From 4d75d974b84154887483fcde29860e628e4f2f0f Mon Sep 17 00:00:00 2001 From: mubbasher-ahmed Date: Wed, 6 Aug 2025 03:10:57 +0500 Subject: [PATCH] perf: roles permissions cache --- .../Commands/ClearRolePermissionCache.php | 83 ++++ app/Constants/CacheKeys.php | 36 ++ app/Http/Resources/V1/Auth/UserResource.php | 6 + app/Models/Role.php | 39 ++ app/Models/User.php | 136 +++++- app/Services/UserService.php | 64 ++- app/Traits/HasCachedRolesAndPermissions.php | 167 ++++++++ ...25_07_04_205615_create_role_user_table.php | 4 + ...04_205616_create_permission_role_table.php | 4 + .../Console/ClearRolePermissionCacheTest.php | 112 +++++ tests/Feature/Models/UserCachingTest.php | 400 ++++++++++++++++++ tests/Feature/Services/UserServiceTest.php | 120 ++++++ 12 files changed, 1162 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/ClearRolePermissionCache.php create mode 100644 app/Constants/CacheKeys.php create mode 100644 app/Traits/HasCachedRolesAndPermissions.php create mode 100644 tests/Feature/Console/ClearRolePermissionCacheTest.php create mode 100644 tests/Feature/Models/UserCachingTest.php create mode 100644 tests/Feature/Services/UserServiceTest.php diff --git a/app/Console/Commands/ClearRolePermissionCache.php b/app/Console/Commands/ClearRolePermissionCache.php new file mode 100644 index 0000000..5dd5af3 --- /dev/null +++ b/app/Console/Commands/ClearRolePermissionCache.php @@ -0,0 +1,83 @@ +option('user-id'); + $clearAll = $this->option('all'); + + if ($userId) { + $user = User::find($userId); + if (! $user) { + $this->error("User with ID {$userId} not found."); + + return 1; + } + + $user->clearCache(); + $this->info("Cache cleared for user: {$user->name} (ID: {$userId})"); + } elseif ($clearAll) { + $this->clearAllUserCaches(); + $this->info('All user caches cleared by incrementing cache version.'); + } else { + $this->clearGlobalCaches(); + $this->info('Global role and permission caches cleared successfully.'); + } + + return 0; + } + + /** + * Clear all user caches by incrementing version + */ + private function clearAllUserCaches(): void + { + /** @var int $currentVersion */ + $currentVersion = Cache::get('user_cache_version', 1); + $newVersion = $currentVersion + 1; + + Cache::put('user_cache_version', $newVersion, CacheKeys::CACHE_TTL); + + $this->info("Cache version incremented from {$currentVersion} to {$newVersion}"); + $this->info('All user caches are now invalidated.'); + } + + /** + * Clear global role and permission caches + */ + private function clearGlobalCaches(): void + { + // Clear global caches + Cache::forget(CacheKeys::ALL_ROLES_CACHE_KEY); + Cache::forget(CacheKeys::ALL_PERMISSIONS_CACHE_KEY); + + $this->info('Global caches cleared successfully.'); + } +} diff --git a/app/Constants/CacheKeys.php b/app/Constants/CacheKeys.php new file mode 100644 index 0000000..0c55036 --- /dev/null +++ b/app/Constants/CacheKeys.php @@ -0,0 +1,36 @@ + $this->resource->website, 'roles' => $this->whenLoaded('roles', function () { return $this->resource->roles->pluck('slug'); + }, function () { + // Fallback to cached roles if not loaded + return $this->resource->getCachedRoles(); }), 'permissions' => $this->whenLoaded('roles', function () { $permissionSlugs = []; @@ -47,6 +50,9 @@ public function toArray(Request $request): array } return array_values(array_unique($permissionSlugs)); + }, function () { + // Fallback to cached permissions if not loaded + return $this->resource->getCachedPermissions(); }), $this->mergeWhen( array_key_exists('access_token', $this->resource->getAttributes()), diff --git a/app/Models/Role.php b/app/Models/Role.php index 0ba1ee0..3a4865c 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Constants\CacheKeys; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -27,6 +28,44 @@ final class Role extends Model protected $guarded = []; + /** + * Boot the model and register event listeners + */ + protected static function boot(): void + { + parent::boot(); + + // Clear user caches when role permissions are updated + self::updated(function (Role $role) { + $role->clearUserCaches(); + }); + + // Clear user caches when role is deleted + self::deleted(function (Role $role) { + $role->clearUserCaches(); + }); + } + + /** + * Clear caches for all users who have this role + */ + private function clearUserCaches(): void + { + // Instead of clearing individual user caches, increment the cache version + // This will invalidate all user caches at once + $this->incrementCacheVersion(); + } + + /** + * Increment cache version to invalidate all user caches + */ + private function incrementCacheVersion(): void + { + /** @var int $currentVersion */ + $currentVersion = \Illuminate\Support\Facades\Cache::get('user_cache_version', 1); + \Illuminate\Support\Facades\Cache::put('user_cache_version', $currentVersion + 1, CacheKeys::CACHE_TTL); + } + /** * Get the attributes that should be cast. * diff --git a/app/Models/User.php b/app/Models/User.php index 82044fc..fd97d37 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Traits\HasCachedRolesAndPermissions; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -44,7 +45,7 @@ */ class User extends Authenticatable { - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasCachedRolesAndPermissions, HasFactory, Notifiable; /** * The attributes that are mass assignable. @@ -76,6 +77,24 @@ class User extends Authenticatable 'remember_token', ]; + /** + * Boot the model and register event listeners + */ + protected static function boot(): void + { + parent::boot(); + + // Clear cache when user is updated + static::updated(function (User $user) { + $user->clearCache(); + }); + + // Clear cache when user is deleted + static::deleted(function (User $user) { + $user->clearCache(); + }); + } + /** * Get the attributes that should be cast. * @@ -112,13 +131,53 @@ public function hasRole(string $role): bool return $this->roles->pluck('name')->contains($role); } + /** + * Check if the user has any of the given roles. + * + * @param array $roles + */ + public function hasAnyRole(array $roles): bool + { + $userRoles = $this->roles->pluck('name')->toArray(); + + return ! empty(array_intersect($roles, $userRoles)); + } + + /** + * Check if the user has all of the given roles. + * + * @param array $roles + */ + public function hasAllRoles(array $roles): bool + { + $userRoles = $this->roles->pluck('name')->toArray(); + + return empty(array_diff($roles, $userRoles)); + } + /** * Check if the user has a given permission via their roles. */ public function hasPermission(string $permission): bool { try { - // Load roles with permissions to avoid N+1 queries + // Try to get from cache first (most efficient) + if ($this->hasCachedPermission($permission)) { + return true; + } + + // If cache miss, check if roles are already loaded + if ($this->relationLoaded('roles')) { + foreach ($this->roles as $role) { + if ($role->relationLoaded('permissions')) { + if ($role->permissions->contains('name', $permission)) { + return true; + } + } + } + } + + // Fallback to database query with eager loading $this->load('roles.permissions'); foreach ($this->roles as $role) { @@ -142,6 +201,79 @@ public function hasPermission(string $permission): bool } } + /** + * Check if the user has any of the given permissions. + * + * @param array $permissions + */ + public function hasAnyPermission(array $permissions): bool + { + try { + // Try to get from cache first + if ($this->hasAnyCachedPermission($permissions)) { + return true; + } + + // Fallback to database query + $this->load('roles.permissions'); + + foreach ($this->roles as $role) { + foreach ($role->permissions as $permission) { + if (in_array($permission->name, $permissions, true)) { + return true; + } + } + } + + return false; + } catch (\Throwable $e) { + \Log::error('hasAnyPermission error', [ + 'user_id' => $this->id, + 'permissions' => $permissions, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Check if the user has all of the given permissions. + * + * @param array $permissions + */ + public function hasAllPermissions(array $permissions): bool + { + try { + // Try to get from cache first + if ($this->hasAllCachedPermissions($permissions)) { + return true; + } + + // Fallback to database query + $this->load('roles.permissions'); + + $userPermissions = []; + foreach ($this->roles as $role) { + foreach ($role->permissions as $permission) { + $userPermissions[] = $permission->name; + } + } + + $userPermissions = array_unique($userPermissions); + + return empty(array_diff($permissions, $userPermissions)); + } catch (\Throwable $e) { + \Log::error('hasAllPermissions error', [ + 'user_id' => $this->id, + 'permissions' => $permissions, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + /** * Get the articles created by the user. * diff --git a/app/Services/UserService.php b/app/Services/UserService.php index e2905fe..03429c9 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -4,11 +4,13 @@ namespace App\Services; +use App\Constants\CacheKeys; use App\Models\Permission; use App\Models\Role; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; final class UserService @@ -41,14 +43,20 @@ public function getUsers(array $params): LengthAwarePaginator } /** - * Get a single user by ID + * Get a single user by ID with cached roles and permissions */ public function getUserById(int $id): User { - return User::query() + $user = User::query() ->with(['roles:id,name,slug']) ->withCount(['articles', 'comments']) ->findOrFail($id); + + // Pre-warm cache for this user + $user->getCachedRoles(); + $user->getCachedPermissions(); + + return $user; } /** @@ -201,27 +209,37 @@ public function unblockUser(int $id): User } /** - * Get all roles + * Get all roles with cached permissions * * @return \Illuminate\Database\Eloquent\Collection */ public function getAllRoles(): \Illuminate\Database\Eloquent\Collection { - return Role::query()->with(['permissions:id,name,slug'])->get(); + /** @var \Illuminate\Database\Eloquent\Collection $result */ + $result = Cache::remember(CacheKeys::ALL_ROLES_CACHE_KEY, CacheKeys::CACHE_TTL, function () { + return Role::query()->with(['permissions:id,name,slug'])->get(); + }); + + return $result; } /** - * Get all permissions + * Get all permissions with caching * * @return \Illuminate\Database\Eloquent\Collection */ public function getAllPermissions(): \Illuminate\Database\Eloquent\Collection { - return Permission::query()->get(); + /** @var \Illuminate\Database\Eloquent\Collection $result */ + $result = Cache::remember(CacheKeys::ALL_PERMISSIONS_CACHE_KEY, CacheKeys::CACHE_TTL, function () { + return Permission::query()->get(); + }); + + return $result; } /** - * Assign roles to user + * Assign roles to user and clear cache * * @param array $roleIds */ @@ -230,9 +248,41 @@ public function assignRoles(int $userId, array $roleIds): User $user = User::findOrFail($userId); $user->roles()->sync($roleIds); + // Clear user-specific caches + $user->clearCache(); + return $user->load(['roles:id,name,slug']); } + /** + * Get users with pre-warmed caches + * + * @param array $params + * @return LengthAwarePaginator + */ + public function getUsersWithWarmedCaches(array $params): LengthAwarePaginator + { + $paginator = $this->getUsers($params); + + // Pre-warm cache for users in the current page + foreach ($paginator->items() as $user) { + $user->getCachedRoles(); + $user->getCachedPermissions(); + } + + return $paginator; + } + + /** + * Increment cache version (for testing purposes) + */ + public function incrementCacheVersion(): void + { + /** @var int $currentVersion */ + $currentVersion = Cache::get('user_cache_version', 1); + Cache::put('user_cache_version', $currentVersion + 1, CacheKeys::CACHE_TTL); + } + /** * Prevent users from performing actions on themselves * diff --git a/app/Traits/HasCachedRolesAndPermissions.php b/app/Traits/HasCachedRolesAndPermissions.php new file mode 100644 index 0000000..a81b8f9 --- /dev/null +++ b/app/Traits/HasCachedRolesAndPermissions.php @@ -0,0 +1,167 @@ + + */ + public function getCachedPermissions(): array + { + $cacheVersion = $this->getCacheVersion(); + $cacheKey = CacheKeys::userPermissions($this->id).'_v'.$cacheVersion; + + /** @var array $result */ + $result = Cache::remember($cacheKey, CacheKeys::CACHE_TTL, function () { + $this->load('roles.permissions'); + + return $this->extractPermissionsFromRoles(); + }); + + return $result; + } + + /** + * Get cached roles for the user + * + * @return array + */ + public function getCachedRoles(): array + { + $cacheVersion = $this->getCacheVersion(); + $cacheKey = CacheKeys::userRoles($this->id).'_v'.$cacheVersion; + + /** @var array $result */ + $result = Cache::remember($cacheKey, CacheKeys::CACHE_TTL, function () { + return $this->roles->pluck('name')->toArray(); + }); + + return $result; + } + + /** + * Check if the user has a given permission using cache + */ + public function hasCachedPermission(string $permission): bool + { + $permissions = $this->getCachedPermissions(); + + return in_array($permission, $permissions, true); + } + + /** + * Check if the user has any of the given permissions using cache + * + * @param array $permissions + */ + public function hasAnyCachedPermission(array $permissions): bool + { + $userPermissions = $this->getCachedPermissions(); + + return ! empty(array_intersect($permissions, $userPermissions)); + } + + /** + * Check if the user has all of the given permissions using cache + * + * @param array $permissions + */ + public function hasAllCachedPermissions(array $permissions): bool + { + $userPermissions = $this->getCachedPermissions(); + + return empty(array_diff($permissions, $userPermissions)); + } + + /** + * Check if the user has a given role using cache + */ + public function hasCachedRole(string $role): bool + { + $roles = $this->getCachedRoles(); + + return in_array($role, $roles, true); + } + + /** + * Check if the user has any of the given roles using cache + * + * @param array $roles + */ + public function hasAnyCachedRole(array $roles): bool + { + $userRoles = $this->getCachedRoles(); + + return ! empty(array_intersect($roles, $userRoles)); + } + + /** + * Check if the user has all of the given roles using cache + * + * @param array $roles + */ + public function hasAllCachedRoles(array $roles): bool + { + $userRoles = $this->getCachedRoles(); + + return empty(array_diff($roles, $userRoles)); + } + + /** + * Clear user's cached permissions and roles + */ + public function clearCache(): void + { + // Clear individual user cache (for specific user updates) + $cacheVersion = $this->getCacheVersion(); + Cache::forget(CacheKeys::userPermissions($this->id).'_v'.$cacheVersion); + Cache::forget(CacheKeys::userRoles($this->id).'_v'.$cacheVersion); + } + + /** + * Refresh user's cached permissions and roles + */ + public function refreshCache(): void + { + $this->clearCache(); + $this->getCachedPermissions(); + $this->getCachedRoles(); + } + + /** + * Get current cache version + */ + private function getCacheVersion(): int + { + /** @var int $result */ + $result = Cache::get('user_cache_version', 1); + + return $result; + } + + /** + * Extract permissions from user's roles + * + * @return array + */ + private function extractPermissionsFromRoles(): array + { + $permissions = []; + + foreach ($this->roles as $role) { + foreach ($role->permissions as $permission) { + $permissions[] = $permission->name; + } + } + + return array_unique($permissions); + } +} diff --git a/database/migrations/2025_07_04_205615_create_role_user_table.php b/database/migrations/2025_07_04_205615_create_role_user_table.php index 47c31b8..fde64b1 100644 --- a/database/migrations/2025_07_04_205615_create_role_user_table.php +++ b/database/migrations/2025_07_04_205615_create_role_user_table.php @@ -15,6 +15,10 @@ public function up(): void $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->primary(['role_id', 'user_id']); + + // Add indexes for better query performance + $table->index('user_id'); + $table->index('role_id'); }); } diff --git a/database/migrations/2025_07_04_205616_create_permission_role_table.php b/database/migrations/2025_07_04_205616_create_permission_role_table.php index 8b7c5f2..45f2af9 100644 --- a/database/migrations/2025_07_04_205616_create_permission_role_table.php +++ b/database/migrations/2025_07_04_205616_create_permission_role_table.php @@ -15,6 +15,10 @@ public function up(): void $table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade'); $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); $table->primary(['permission_id', 'role_id']); + + // Add indexes for better query performance + $table->index('role_id'); + $table->index('permission_id'); }); } diff --git a/tests/Feature/Console/ClearRolePermissionCacheTest.php b/tests/Feature/Console/ClearRolePermissionCacheTest.php new file mode 100644 index 0000000..96cdb39 --- /dev/null +++ b/tests/Feature/Console/ClearRolePermissionCacheTest.php @@ -0,0 +1,112 @@ +create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache some data + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act + $this->artisan('cache:clear-roles-permissions', ['--user-id' => $user->id]) + ->expectsOutput("Cache cleared for user: {$user->name} (ID: {$user->id})") + ->assertExitCode(0); + + // Assert + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeFalse(); + }); + + it('handles non-existent user gracefully', function () { + // Act + $this->artisan('cache:clear-roles-permissions', ['--user-id' => 99999]) + ->expectsOutput('User with ID 99999 not found.') + ->assertExitCode(1); + }); + + it('clears all user caches by incrementing version', function () { + // Arrange + $users = User::factory()->count(3)->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + + foreach ($users as $user) { + $user->roles()->attach($role->id); + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + } + + // Act + $this->artisan('cache:clear-roles-permissions', ['--all' => true]) + ->expectsOutput('All user caches cleared by incrementing cache version.') + ->assertExitCode(0); + + // Assert - New cache should be created with version 2 + $users[0]->getCachedRoles(); + expect(Cache::has('user_roles_'.$users[0]->id.'_v2'))->toBeTrue(); + }); + + it('clears global caches when no options provided', function () { + // Arrange - Set up some global caches + Cache::put(CacheKeys::ALL_ROLES_CACHE_KEY, ['test'], CacheKeys::CACHE_TTL); + Cache::put(CacheKeys::ALL_PERMISSIONS_CACHE_KEY, ['test'], CacheKeys::CACHE_TTL); + + expect(Cache::has(CacheKeys::ALL_ROLES_CACHE_KEY))->toBeTrue(); + expect(Cache::has(CacheKeys::ALL_PERMISSIONS_CACHE_KEY))->toBeTrue(); + + // Act + $this->artisan('cache:clear-roles-permissions') + ->expectsOutput('Global role and permission caches cleared successfully.') + ->assertExitCode(0); + + // Assert + expect(Cache::has(CacheKeys::ALL_ROLES_CACHE_KEY))->toBeFalse(); + expect(Cache::has(CacheKeys::ALL_PERMISSIONS_CACHE_KEY))->toBeFalse(); + }); + + it('increments cache version correctly', function () { + // Arrange + $initialVersion = Cache::get('user_cache_version', 1); + expect($initialVersion)->toBe(1); + + // Act + $this->artisan('cache:clear-roles-permissions', ['--all' => true]) + ->expectsOutput('Cache version incremented from 1 to 2') + ->expectsOutput('All user caches are now invalidated.') + ->assertExitCode(0); + + // Assert + $newVersion = Cache::get('user_cache_version'); + expect($newVersion)->toBe(2); + }); + + it('handles multiple version increments', function () { + // Arrange + Cache::put('user_cache_version', 5, CacheKeys::CACHE_TTL); + + // Act + $this->artisan('cache:clear-roles-permissions', ['--all' => true]) + ->expectsOutput('Cache version incremented from 5 to 6') + ->expectsOutput('All user caches are now invalidated.') + ->assertExitCode(0); + + // Assert + $newVersion = Cache::get('user_cache_version'); + expect($newVersion)->toBe(6); + }); +}); diff --git a/tests/Feature/Models/UserCachingTest.php b/tests/Feature/Models/UserCachingTest.php new file mode 100644 index 0000000..78df303 --- /dev/null +++ b/tests/Feature/Models/UserCachingTest.php @@ -0,0 +1,400 @@ +create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Act + $cachedRoles = $user->getCachedRoles(); + + // Assert + expect($cachedRoles)->toBe([UserRole::AUTHOR->value]); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + }); + + it('caches user permissions correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $permission = Permission::where('name', 'publish_posts')->first(); + + // Check if permission is already attached to avoid duplicates + if (! $role->permissions()->where('permission_id', $permission->id)->exists()) { + $role->permissions()->attach($permission->id); + } + $user->roles()->attach($role->id); + + // Act + $cachedPermissions = $user->getCachedPermissions(); + + // Assert + expect($cachedPermissions)->toContain('publish_posts'); + expect(Cache::has('user_permissions_'.$user->id.'_v1'))->toBeTrue(); + }); + + it('uses cached data for permission checks', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $permission = Permission::where('name', 'publish_posts')->first(); + + // Check if permission is already attached to avoid duplicates + if (! $role->permissions()->where('permission_id', $permission->id)->exists()) { + $role->permissions()->attach($permission->id); + } + $user->roles()->attach($role->id); + + // Act - First call should cache + $hasPermission = $user->hasCachedPermission('publish_posts'); + + // Assert + expect($hasPermission)->toBeTrue(); + expect(Cache::has('user_permissions_'.$user->id.'_v1'))->toBeTrue(); + }); + + it('clears cache when user is updated', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache some data + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act + $user->update(['name' => 'Updated Name']); + + // Assert - Cache should be cleared + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeFalse(); + }); + + it('invalidates all caches when role permissions change', function () { + // Arrange + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + + $user1->roles()->attach($role->id); + $user2->roles()->attach($role->id); + + // Cache data for both users + $user1->getCachedRoles(); + $user2->getCachedRoles(); + + expect(Cache::has('user_roles_'.$user1->id.'_v1'))->toBeTrue(); + expect(Cache::has('user_roles_'.$user2->id.'_v1'))->toBeTrue(); + + // Act - Update role (this should increment cache version) + $role->update(['name' => 'Updated Author']); + + // Assert - New cache should be created with new version + $user1->getCachedRoles(); + expect(Cache::has('user_roles_'.$user1->id.'_v2'))->toBeTrue(); + }); + + it('handles multiple permissions correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $permission1 = Permission::where('name', 'publish_posts')->first(); + $permission2 = Permission::where('name', 'edit_posts')->first(); + + // Check if permissions are already attached to avoid duplicates + if (! $role->permissions()->where('permission_id', $permission1->id)->exists()) { + $role->permissions()->attach($permission1->id); + } + if (! $role->permissions()->where('permission_id', $permission2->id)->exists()) { + $role->permissions()->attach($permission2->id); + } + $user->roles()->attach($role->id); + + // Act + $hasAny = $user->hasAnyCachedPermission(['publish_posts', 'delete_posts']); + $hasAll = $user->hasAllCachedPermissions(['publish_posts', 'edit_posts']); + + // Assert + expect($hasAny)->toBeTrue(); // Has publish_posts + expect($hasAll)->toBeTrue(); // Has both permissions + }); + + it('handles multiple roles correctly', function () { + // Arrange + $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 + $hasAny = $user->hasAnyCachedRole([UserRole::AUTHOR->value, UserRole::ADMINISTRATOR->value]); + $hasAll = $user->hasAllCachedRoles([UserRole::AUTHOR->value, UserRole::EDITOR->value]); + + // Assert + expect($hasAny)->toBeTrue(); // Has author role + expect($hasAll)->toBeTrue(); // Has both roles + }); + + it('handles users with no roles', function () { + // Arrange + $user = User::factory()->create(); + + // Act + $cachedRoles = $user->getCachedRoles(); + $cachedPermissions = $user->getCachedPermissions(); + + // Assert + expect($cachedRoles)->toBe([]); + expect($cachedPermissions)->toBe([]); + expect($user->hasCachedRole('admin'))->toBeFalse(); + expect($user->hasCachedPermission('edit_posts'))->toBeFalse(); + }); + + it('handles users with no permissions', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::SUBSCRIBER->value)->first(); + $user->roles()->attach($role->id); + + // Act + $cachedPermissions = $user->getCachedPermissions(); + + // Assert - Subscriber role should have some permissions from seeder + expect($cachedPermissions)->not->toBeEmpty(); + expect($user->hasCachedPermission('edit_posts'))->toBeFalse(); + }); + + it('refreshes cache correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache initial data + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act + $user->refreshCache(); + + // Assert - Cache should be rebuilt + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + expect($user->getCachedRoles())->toBe([UserRole::AUTHOR->value]); + }); + + it('handles cache versioning correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache with version 1 + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act - Increment cache version + Cache::put('user_cache_version', 2, CacheKeys::CACHE_TTL); + + // Assert - New cache should be created with version 2 + $user->getCachedRoles(); // This should create v2 cache + expect(Cache::has('user_roles_'.$user->id.'_v2'))->toBeTrue(); + }); + + it('handles permission changes through role updates', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $permission = Permission::where('name', 'publish_posts')->first(); + + // Check if permission is already attached to avoid duplicates + if (! $role->permissions()->where('permission_id', $permission->id)->exists()) { + $role->permissions()->attach($permission->id); + } + $user->roles()->attach($role->id); + + // Cache initial permissions + $user->getCachedPermissions(); + expect(Cache::has('user_permissions_'.$user->id.'_v1'))->toBeTrue(); + + // Act - Update role permissions + $role->update(['name' => 'Updated Author']); + + // Assert - New cache should be created with updated data + $user->getCachedPermissions(); + expect(Cache::has('user_permissions_'.$user->id.'_v2'))->toBeTrue(); + }); + + it('handles role deletion correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache initial data + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act - Delete role + $role->delete(); + + // Assert - Cache should be invalidated (new version created) + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v2'))->toBeTrue(); + }); + + it('handles user deletion correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Cache initial data + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act - Delete user + $user->delete(); + + // Assert - Cache should be cleared + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeFalse(); + }); + + it('handles multiple users with same role efficiently', function () { + // Arrange + $users = User::factory()->count(5)->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + + foreach ($users as $user) { + $user->roles()->attach($role->id); + } + + // Cache data for all users + foreach ($users as $user) { + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + } + + // Act - Update role (should invalidate all user caches efficiently) + $role->update(['name' => 'Updated Author']); + + // Assert - New caches should be created with new version + $users[0]->getCachedRoles(); + expect(Cache::has('user_roles_'.$users[0]->id.'_v2'))->toBeTrue(); + }); + + it('handles complex permission scenarios', function () { + // Arrange + $user = User::factory()->create(); + $authorRole = Role::where('name', UserRole::AUTHOR->value)->first(); + $editorRole = Role::where('name', UserRole::EDITOR->value)->first(); + + $publishPermission = Permission::where('name', 'publish_posts')->first(); + $editPermission = Permission::where('name', 'edit_posts')->first(); + $deletePermission = Permission::where('name', 'delete_posts')->first(); + + // Check if permissions are already attached to avoid duplicates + if (! $authorRole->permissions()->where('permission_id', $publishPermission->id)->exists()) { + $authorRole->permissions()->attach($publishPermission->id); + } + if (! $authorRole->permissions()->where('permission_id', $editPermission->id)->exists()) { + $authorRole->permissions()->attach($editPermission->id); + } + if (! $editorRole->permissions()->where('permission_id', $editPermission->id)->exists()) { + $editorRole->permissions()->attach($editPermission->id); + } + if (! $editorRole->permissions()->where('permission_id', $deletePermission->id)->exists()) { + $editorRole->permissions()->attach($deletePermission->id); + } + + $user->roles()->attach([$authorRole->id, $editorRole->id]); + + // Act + $cachedPermissions = $user->getCachedPermissions(); + + // Assert - Should have unique permissions from both roles + expect($cachedPermissions)->toContain('publish_posts'); + expect($cachedPermissions)->toContain('edit_posts'); + expect($cachedPermissions)->toContain('delete_posts'); + expect(count($cachedPermissions))->toBeGreaterThanOrEqual(3); // At least 3 unique permissions + }); + + it('handles empty permission arrays correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Act + $hasAny = $user->hasAnyCachedPermission([]); + $hasAll = $user->hasAllCachedPermissions([]); + + // Assert + expect($hasAny)->toBeFalse(); + expect($hasAll)->toBeTrue(); // Empty array means user has all required permissions + }); + + it('handles empty role arrays correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Act + $hasAny = $user->hasAnyCachedRole([]); + $hasAll = $user->hasAllCachedRoles([]); + + // Assert + expect($hasAny)->toBeFalse(); + expect($hasAll)->toBeTrue(); // Empty array means user has all required roles + }); + + it('handles non-existent permissions correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Act + $hasPermission = $user->hasCachedPermission('non_existent_permission'); + $hasAny = $user->hasAnyCachedPermission(['non_existent_permission', 'another_fake']); + $hasAll = $user->hasAllCachedPermissions(['non_existent_permission']); + + // Assert + expect($hasPermission)->toBeFalse(); + expect($hasAny)->toBeFalse(); + expect($hasAll)->toBeFalse(); + }); + + it('handles non-existent roles correctly', function () { + // Arrange + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $user->roles()->attach($role->id); + + // Act + $hasRole = $user->hasCachedRole('non_existent_role'); + $hasAny = $user->hasAnyCachedRole(['non_existent_role', 'another_fake']); + $hasAll = $user->hasAllCachedRoles(['non_existent_role']); + + // Assert + expect($hasRole)->toBeFalse(); + expect($hasAny)->toBeFalse(); + expect($hasAll)->toBeFalse(); + }); +}); diff --git a/tests/Feature/Services/UserServiceTest.php b/tests/Feature/Services/UserServiceTest.php new file mode 100644 index 0000000..452a944 --- /dev/null +++ b/tests/Feature/Services/UserServiceTest.php @@ -0,0 +1,120 @@ +create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + $permission = Permission::where('name', 'publish_posts')->first(); + + // Check if permission is already attached to avoid duplicates + if (! $role->permissions()->where('permission_id', $permission->id)->exists()) { + $role->permissions()->attach($permission->id); + } + $user->roles()->attach($role->id); + + // Act + $retrievedUser = $userService->getUserById($user->id); + + // Assert + expect($retrievedUser->id)->toBe($user->id); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + expect(Cache::has('user_permissions_'.$user->id.'_v1'))->toBeTrue(); + }); + + it('caches all roles with permissions', function () { + // Arrange + $userService = new UserService; + + // Act + $roles = $userService->getAllRoles(); + + // Assert + expect($roles)->not->toBeEmpty(); + expect(Cache::has(CacheKeys::ALL_ROLES_CACHE_KEY))->toBeTrue(); + }); + + it('caches all permissions', function () { + // Arrange + $userService = new UserService; + + // Act + $permissions = $userService->getAllPermissions(); + + // Assert + expect($permissions)->not->toBeEmpty(); + expect(Cache::has(CacheKeys::ALL_PERMISSIONS_CACHE_KEY))->toBeTrue(); + }); + + it('clears user cache when assigning roles', function () { + // Arrange + $userService = new UserService; + $user = User::factory()->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + + // Cache some data first + $user->getCachedRoles(); + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + + // Act + $updatedUser = $userService->assignRoles($user->id, [$role->id]); + + // Assert + expect($updatedUser->id)->toBe($user->id); + expect($updatedUser->roles)->toHaveCount(1); + }); + + it('pre-warms caches for users in pagination', function () { + // Arrange + $userService = new UserService; + $users = User::factory()->count(3)->create(); + $role = Role::where('name', UserRole::AUTHOR->value)->first(); + + foreach ($users as $user) { + $user->roles()->attach($role->id); + } + + // Act + $paginator = $userService->getUsersWithWarmedCaches(['per_page' => 2]); + + // Assert + expect($paginator->total())->toBeGreaterThanOrEqual(3); // At least 3 users (including seeded ones) + expect($paginator->items())->toHaveCount(2); // First page + + // Check that caches are warmed for first page users + foreach ($paginator->items() as $user) { + expect(Cache::has('user_roles_'.$user->id.'_v1'))->toBeTrue(); + expect(Cache::has('user_permissions_'.$user->id.'_v1'))->toBeTrue(); + } + }); + + it('increments cache version correctly', function () { + // Arrange + $userService = new UserService; + $initialVersion = Cache::get('user_cache_version', 1); + expect($initialVersion)->toBe(1); + + // Act + $userService->incrementCacheVersion(); + + // Assert + $newVersion = Cache::get('user_cache_version'); + expect($newVersion)->toBe(2); + }); +});