From b008ad51064a438818dce2998708c5cee1c57471 Mon Sep 17 00:00:00 2001 From: omair saleh Date: Sun, 25 Sep 2022 03:46:14 +0800 Subject: [PATCH] Create User Zone --- composer.json | 6 +- config/app.php | 7 +- config/auth.php | 9 +- .../2014_10_12_000000_create_users_table.php | 5 +- src/Auth/Application/JWTAuth.php | 47 +++ .../Providers/AuthServiceProvider.php | 5 +- src/Auth/Domain/AuthInterface.php | 21 ++ src/Auth/Infrastructure/.gitkeep.php | 1 + src/Auth/Presentation/API/.gitkeep | 0 src/Auth/Presentation/CLI/.gitkeep | 0 src/Auth/Presentation/HTTP/AuthController.php | 103 ++++++ src/Auth/Presentation/HTTP/routes.php | 14 + src/Common/Domain/AggregateRoot.php | 7 + src/Common/Domain/CommandInterface.php | 13 + .../Exceptions/EntityNotFoundException.php | 11 + .../IncorrectEmailFormatException.php | 11 + .../Exceptions/MaximumValueException.php | 11 + .../Domain/Exceptions/RequiredException.php | 11 + .../Exceptions/UnauthorizedUserException.php | 11 + src/Common/Domain/QueryInterface.php | 13 + .../Laravel/Middleware/JwtMiddleware.php | 42 +++ .../Laravel/Providers/AuthServiceProvider.php | 8 + .../Providers/RouteServiceProvider.php | 17 +- src/Zone/User/Application/DTO/.gitkeep | 0 .../Exceptions/EmailAlreadyUsedException.php | 11 + src/Zone/User/Application/Jobs/.gitkeep | 0 .../User/Application/Mappers/UserMapper.php | 68 ++++ .../Providers/UserServiceProvider.php | 22 ++ .../Repositories/Eloquent/UserRepository.php | 59 +++ .../Repositories/Local/AvatarRepository.php | 46 +++ .../UseCases/Commands/DestroyUserCommand.php | 27 ++ .../Commands/GetRandomAvatarCommand.php | 21 ++ .../UseCases/Commands/StoreUserCommand.php | 45 +++ .../UseCases/Commands/UpdateUserCommand.php | 43 +++ .../UseCases/Queries/FindAllUsersQuery.php | 25 ++ .../UseCases/Queries/FindUserByEmailQuery.php | 27 ++ .../UseCases/Queries/FindUserByIdQuery.php | 28 ++ .../Exceptions/PasswordTooShortException.php | 11 + .../PasswordsDoNotMatchException.php | 11 + .../User/Domain/Factories/UserFactory.php | 36 ++ src/Zone/User/Domain/Model/User.php | 44 +++ .../User/Domain/Model/ValueObjects/Avatar.php | 58 +++ .../User/Domain/Model/ValueObjects/Email.php | 36 ++ .../User/Domain/Model/ValueObjects/Name.php | 32 ++ .../Domain/Model/ValueObjects/Password.php | 41 +++ src/Zone/User/Domain/Policies/UserPolicy.php | 39 ++ .../AvatarRepositoryInterface.php | 15 + .../Repositories/UserRepositoryInterface.php | 24 ++ src/Zone/User/Domain/Services/.gitkeep | 0 .../EloquentModels/Casts/PasswordCast.php | 25 ++ .../EloquentModels/UserEloquentModel.php | 83 +++++ src/Zone/User/Presentation/API/.gitkeep | 0 src/Zone/User/Presentation/CLI/.gitkeep | 0 .../HTTP/GetRandomAvatarController.php | 20 ++ .../User/Presentation/HTTP/UserController.php | 75 ++++ src/Zone/User/Presentation/HTTP/routes.php | 17 + tests/Feature/UserTest.php | 337 ++++++++++++++++++ tests/Integration/DoodleAPITest.php | 19 + tests/Support/WithLogin.php | 51 +++ 59 files changed, 1757 insertions(+), 12 deletions(-) create mode 100644 src/Auth/Application/JWTAuth.php create mode 100644 src/Auth/Domain/AuthInterface.php create mode 100644 src/Auth/Infrastructure/.gitkeep.php create mode 100644 src/Auth/Presentation/API/.gitkeep create mode 100644 src/Auth/Presentation/CLI/.gitkeep create mode 100644 src/Auth/Presentation/HTTP/AuthController.php create mode 100644 src/Auth/Presentation/HTTP/routes.php create mode 100644 src/Common/Domain/AggregateRoot.php create mode 100644 src/Common/Domain/CommandInterface.php create mode 100644 src/Common/Domain/Exceptions/EntityNotFoundException.php create mode 100644 src/Common/Domain/Exceptions/IncorrectEmailFormatException.php create mode 100644 src/Common/Domain/Exceptions/MaximumValueException.php create mode 100644 src/Common/Domain/Exceptions/RequiredException.php create mode 100644 src/Common/Domain/Exceptions/UnauthorizedUserException.php create mode 100644 src/Common/Domain/QueryInterface.php create mode 100644 src/Common/Infrastructure/Laravel/Middleware/JwtMiddleware.php create mode 100644 src/Zone/User/Application/DTO/.gitkeep create mode 100644 src/Zone/User/Application/Exceptions/EmailAlreadyUsedException.php create mode 100644 src/Zone/User/Application/Jobs/.gitkeep create mode 100644 src/Zone/User/Application/Mappers/UserMapper.php create mode 100644 src/Zone/User/Application/Providers/UserServiceProvider.php create mode 100644 src/Zone/User/Application/Repositories/Eloquent/UserRepository.php create mode 100644 src/Zone/User/Application/Repositories/Local/AvatarRepository.php create mode 100644 src/Zone/User/Application/UseCases/Commands/DestroyUserCommand.php create mode 100644 src/Zone/User/Application/UseCases/Commands/GetRandomAvatarCommand.php create mode 100644 src/Zone/User/Application/UseCases/Commands/StoreUserCommand.php create mode 100644 src/Zone/User/Application/UseCases/Commands/UpdateUserCommand.php create mode 100644 src/Zone/User/Application/UseCases/Queries/FindAllUsersQuery.php create mode 100644 src/Zone/User/Application/UseCases/Queries/FindUserByEmailQuery.php create mode 100644 src/Zone/User/Application/UseCases/Queries/FindUserByIdQuery.php create mode 100644 src/Zone/User/Domain/Exceptions/PasswordTooShortException.php create mode 100644 src/Zone/User/Domain/Exceptions/PasswordsDoNotMatchException.php create mode 100644 src/Zone/User/Domain/Factories/UserFactory.php create mode 100644 src/Zone/User/Domain/Model/User.php create mode 100644 src/Zone/User/Domain/Model/ValueObjects/Avatar.php create mode 100644 src/Zone/User/Domain/Model/ValueObjects/Email.php create mode 100644 src/Zone/User/Domain/Model/ValueObjects/Name.php create mode 100644 src/Zone/User/Domain/Model/ValueObjects/Password.php create mode 100644 src/Zone/User/Domain/Policies/UserPolicy.php create mode 100644 src/Zone/User/Domain/Repositories/AvatarRepositoryInterface.php create mode 100644 src/Zone/User/Domain/Repositories/UserRepositoryInterface.php create mode 100644 src/Zone/User/Domain/Services/.gitkeep create mode 100644 src/Zone/User/Infrastructure/EloquentModels/Casts/PasswordCast.php create mode 100644 src/Zone/User/Infrastructure/EloquentModels/UserEloquentModel.php create mode 100644 src/Zone/User/Presentation/API/.gitkeep create mode 100644 src/Zone/User/Presentation/CLI/.gitkeep create mode 100644 src/Zone/User/Presentation/HTTP/GetRandomAvatarController.php create mode 100644 src/Zone/User/Presentation/HTTP/UserController.php create mode 100644 src/Zone/User/Presentation/HTTP/routes.php create mode 100644 tests/Feature/UserTest.php create mode 100644 tests/Integration/DoodleAPITest.php create mode 100644 tests/Support/WithLogin.php diff --git a/composer.json b/composer.json index f2d9ed6..d6ab997 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^9.19", "laravel/sanctum": "^3.0", - "laravel/tinker": "^2.7" + "laravel/tinker": "^2.7", + "tymon/jwt-auth": "^1.0" }, "require-dev": { "fakerphp/faker": "^1.9.1", @@ -21,6 +22,9 @@ "spatie/laravel-ignition": "^1.0" }, "autoload": { + "files": [ + "src/Common/Infrastructure/helpers.php" + ], "psr-4": { "Src\\": "src/", "Database\\Factories\\": "database/factories/", diff --git a/config/app.php b/config/app.php index 5b0e36a..2fdca33 100644 --- a/config/app.php +++ b/config/app.php @@ -182,6 +182,11 @@ return [ Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, + /* + * Domain Service Providers... + */ + \Src\Zone\User\Application\Providers\UserServiceProvider::class, + /* * Package Service Providers... */ @@ -190,7 +195,7 @@ return [ * Application Service Providers... */ \Src\Common\Infrastructure\Laravel\Providers\AppServiceProvider::class, -// \Src\Auth\Application\Providers\AuthServiceProvider::class, + \Src\Auth\Application\Providers\AuthServiceProvider::class, // Src\Common\Infrastructure\Laravel\Providers\BroadcastServiceProvider::class, \Src\Common\Infrastructure\Laravel\Providers\EventServiceProvider::class, \Src\Common\Infrastructure\Laravel\Providers\RouteServiceProvider::class, diff --git a/config/auth.php b/config/auth.php index d8c6cee..a405be7 100644 --- a/config/auth.php +++ b/config/auth.php @@ -14,7 +14,7 @@ return [ */ 'defaults' => [ - 'guard' => 'web', + 'guard' => 'internal-api', 'passwords' => 'users', ], @@ -40,6 +40,11 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'internal-api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + 'hash' => false, + ] ], /* @@ -62,7 +67,7 @@ return [ 'providers' => [ 'users' => [ 'driver' => 'eloquent', - 'model' => App\Models\User::class, + 'model' => \Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel::class, ], // 'users' => [ diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index cf6b776..7ccffba 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -16,9 +16,12 @@ return new class extends Migration Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); + $table->string('avatar')->nullable(); $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->timestamp('email_verified_at')->nullable(); + $table->boolean('is_admin')->default(0); + $table->boolean('is_active')->default(1); $table->rememberToken(); $table->timestamps(); }); diff --git a/src/Auth/Application/JWTAuth.php b/src/Auth/Application/JWTAuth.php new file mode 100644 index 0000000..b07ff43 --- /dev/null +++ b/src/Auth/Application/JWTAuth.php @@ -0,0 +1,47 @@ +where('email', $credentials['email'])->first(); + if (!$user || !$user->is_active) { + throw new AuthenticationException(); + } elseif (!$token = auth()->attempt($credentials)) { + throw new AuthenticationException(); + } + return $token; + } + + public function logout(): void + { + auth()->logout(); + } + + public function me(): User + { + return UserMapper::fromAuth(auth()->user()); + } + + public function refresh(): string + { + try { + return TymonJWTAuth::parseToken()->refresh(); + } catch (JWTException $e) { + Log::error($e->getMessage()); + throw new AuthenticationException($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/src/Auth/Application/Providers/AuthServiceProvider.php b/src/Auth/Application/Providers/AuthServiceProvider.php index ad0fe0f..baaf554 100644 --- a/src/Auth/Application/Providers/AuthServiceProvider.php +++ b/src/Auth/Application/Providers/AuthServiceProvider.php @@ -29,7 +29,10 @@ class AuthServiceProvider extends ServiceProvider public function register() { - // + $this->app->bind( + \Src\Auth\Domain\AuthInterface::class, + \Src\Auth\Application\JWTAuth::class + ); } } diff --git a/src/Auth/Domain/AuthInterface.php b/src/Auth/Domain/AuthInterface.php new file mode 100644 index 0000000..d3c8947 --- /dev/null +++ b/src/Auth/Domain/AuthInterface.php @@ -0,0 +1,21 @@ +auth = $auth; + } + + /** + * Get a JWT via given credentials. + * + * @param Request $request + * @return JsonResponse + */ + public function login(Request $request): JsonResponse + { + try { + $email = $request->get('email'); + $password = $request->get('password'); + $credentials = ['email' => strtolower($email), 'password' => $password]; + $validator = Validator::make($credentials, [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + $token = $this->auth->login($credentials); + return $this->respondWithToken($token); + } catch (ValidationException $validationException) { + return response()->json($validationException->errors(), Response::HTTP_BAD_REQUEST); + } catch (AuthenticationException) { + return response()->json(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED ); + } + } + + /** + * Get the authenticated UserEloquentModel. + * + * @return JsonResponse + */ + public function me(): JsonResponse + { + return response()->json($this->auth->me()->toArray()); + } + + /** + * Log the user out (Invalidate the token). + * + * @return JsonResponse + */ + public function logout(): JsonResponse + { + $this->auth->logout(); + return response()->json(['message' => 'Successfully logged out']); + } + + /** + * Refresh a token. + * + * @return JsonResponse + */ + public function refresh(): JsonResponse + { + try { + $token = $this->auth->refresh(); + } catch (AuthenticationException $e) { + return response()->json(['status' => $e->getMessage()], Response::HTTP_FORBIDDEN); + } + + return $this->respondWithToken($token); + } + + /** + * Get the token array structure. + * + * @param string $token + * + * @return JsonResponse + */ + protected function respondWithToken(string $token): JsonResponse + { + return response()->json([ + 'accessToken' => $token, + 'token_type' => 'bearer', + 'expires_in' => config('jwt.ttl') * 1, + ]); + } +} diff --git a/src/Auth/Presentation/HTTP/routes.php b/src/Auth/Presentation/HTTP/routes.php new file mode 100644 index 0000000..2371a3b --- /dev/null +++ b/src/Auth/Presentation/HTTP/routes.php @@ -0,0 +1,14 @@ + 'auth' +], function () { + Route::post('login', [AuthController::class, 'login']); + Route::post('logout', [AuthController::class, 'logout']); + Route::post('refresh', [AuthController::class, 'refresh']); + Route::get('me', [AuthController::class, 'me']); +}); diff --git a/src/Common/Domain/AggregateRoot.php b/src/Common/Domain/AggregateRoot.php new file mode 100644 index 0000000..898fc2e --- /dev/null +++ b/src/Common/Domain/AggregateRoot.php @@ -0,0 +1,7 @@ + $fieldName])); + } +} \ No newline at end of file diff --git a/src/Common/Domain/Exceptions/UnauthorizedUserException.php b/src/Common/Domain/Exceptions/UnauthorizedUserException.php new file mode 100644 index 0000000..ad9fa08 --- /dev/null +++ b/src/Common/Domain/Exceptions/UnauthorizedUserException.php @@ -0,0 +1,11 @@ +decodedPath(), ['auth/login', 'auth/refresh', 'login', 'refresh']) && $request->method() == 'POST') { + return $next($request); + } + JWTAuth::parseToken()->authenticate(); + } catch (Exception $e) { + if ($e instanceof TokenInvalidException){ + return response()->json(['status' => 'Token is Invalid'], Response::HTTP_UNAUTHORIZED ); + }else if ($e instanceof TokenExpiredException){ + return response()->json(['status' => 'Token is Expired'], Response::HTTP_UNAUTHORIZED ); + }else{ + return response()->json(['status' => 'Authorization Token not found'], Response::HTTP_UNAUTHORIZED ); + } + } + return $next($request); + } +} diff --git a/src/Common/Infrastructure/Laravel/Providers/AuthServiceProvider.php b/src/Common/Infrastructure/Laravel/Providers/AuthServiceProvider.php index 6148e07..a56a338 100644 --- a/src/Common/Infrastructure/Laravel/Providers/AuthServiceProvider.php +++ b/src/Common/Infrastructure/Laravel/Providers/AuthServiceProvider.php @@ -27,4 +27,12 @@ class AuthServiceProvider extends ServiceProvider // } + + public function register() + { + $this->app->bind( + \Src\Auth\Domain\AuthInterface::class, + \Src\Auth\Application\JWTAuth::class + ); + } } diff --git a/src/Common/Infrastructure/Laravel/Providers/RouteServiceProvider.php b/src/Common/Infrastructure/Laravel/Providers/RouteServiceProvider.php index 2598060..e97cf29 100644 --- a/src/Common/Infrastructure/Laravel/Providers/RouteServiceProvider.php +++ b/src/Common/Infrastructure/Laravel/Providers/RouteServiceProvider.php @@ -29,12 +29,17 @@ class RouteServiceProvider extends ServiceProvider $this->configureRateLimiting(); $this->routes(function () { - Route::middleware('api') - ->prefix('api') - ->group(base_path('routes/api.php')); - Route::middleware('web') ->group(base_path('routes/web.php')); + + Route::middleware('api') + ->group(base_path('routes/api.php')); + + Route::middleware('internal_api') + ->group(function() { + require base_path('src/Auth/Presentation/HTTP/routes.php'); + require base_path('src/Zone/User/Presentation/HTTP/routes.php'); + }); }); } @@ -45,8 +50,8 @@ class RouteServiceProvider extends ServiceProvider */ protected function configureRateLimiting() { - RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + RateLimiter::for('internal_api', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); }); } } diff --git a/src/Zone/User/Application/DTO/.gitkeep b/src/Zone/User/Application/DTO/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Zone/User/Application/Exceptions/EmailAlreadyUsedException.php b/src/Zone/User/Application/Exceptions/EmailAlreadyUsedException.php new file mode 100644 index 0000000..1a7ea90 --- /dev/null +++ b/src/Zone/User/Application/Exceptions/EmailAlreadyUsedException.php @@ -0,0 +1,11 @@ +input('name')), + email: new Email($request->input('email')), + avatar: new Avatar($request->input('avatar')), + is_admin: $request->input('is_admin') ?? false, + is_active: $request->input('is_active') ?? true, + ); + } + + public static function fromEloquent(UserEloquentModel $userEloquent): User + { + $avatarRepository = app()->make(AvatarRepositoryInterface::class); + return new User( + id: $userEloquent->id, + name: new Name($userEloquent->name), + email: new Email($userEloquent->email), + avatar: $avatarRepository->retrieveAvatarFile(new Avatar($userEloquent->avatar)), + is_admin: $userEloquent->is_admin, + is_active: $userEloquent->is_active + ); + } + + public static function fromAuth(Authenticatable $userEloquent): User + { + $avatarRepository = app()->make(AvatarRepositoryInterface::class); + return new User( + id: $userEloquent->id, + name: new Name($userEloquent->name), + email: new Email($userEloquent->email), + avatar: $avatarRepository->retrieveAvatarFile(new Avatar($userEloquent->avatar)), + is_admin: $userEloquent->is_admin, + is_active: $userEloquent->is_active + ); + } + + public static function toEloquent(User $user): UserEloquentModel + { + $userEloquent = new UserEloquentModel(); + if ($user->id) { + $userEloquent = UserEloquentModel::query()->find($user->id); + } + $userEloquent->name = $user->name; + $userEloquent->email = $user->email; + $userEloquent->avatar = $user->avatar; + $userEloquent->is_admin = $user->is_admin; + $userEloquent->is_active = $user->is_active; + return $userEloquent; + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/Providers/UserServiceProvider.php b/src/Zone/User/Application/Providers/UserServiceProvider.php new file mode 100644 index 0000000..10a3cd3 --- /dev/null +++ b/src/Zone/User/Application/Providers/UserServiceProvider.php @@ -0,0 +1,22 @@ +app->bind( + \Src\Zone\User\Domain\Repositories\UserRepositoryInterface::class, + \Src\Zone\User\Application\Repositories\Eloquent\UserRepository::class + ); + + $this->app->bind( + \Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface::class, + \Src\Zone\User\Application\Repositories\Local\AvatarRepository::class + ); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/Repositories/Eloquent/UserRepository.php b/src/Zone/User/Application/Repositories/Eloquent/UserRepository.php new file mode 100644 index 0000000..f2083aa --- /dev/null +++ b/src/Zone/User/Application/Repositories/Eloquent/UserRepository.php @@ -0,0 +1,59 @@ +findOrFail($userId); + return UserMapper::fromEloquent($userEloquent); + } + + public function findByEmail(string $email): User + { + $userEloquent = UserEloquentModel::query()->where('email', $email)->firstOrFail(); + return UserMapper::fromEloquent($userEloquent); + } + + public function store(User $user, Password $password): User + { + $userEloquent = new UserEloquentModel(); + $userEloquent->fill(array_merge($user->toArray(), ['password' => $password->value()])); + $userEloquent->save(); + + return UserMapper::fromEloquent($userEloquent); + } + + public function update(User $user, Password $password): void + { + $userArray = $user->toArray(); + if ($password->isNotEmpty()) { + $userArray['password'] = $password->value(); + } + $userEloquent = UserEloquentModel::query()->findOrFail($user->id); + $userEloquent->fill($userArray); + $userEloquent->save(); + } + + public function delete(int $user_id): void + { + $userEloquent = UserEloquentModel::query()->findOrFail($user_id); + $userEloquent->delete(); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/Repositories/Local/AvatarRepository.php b/src/Zone/User/Application/Repositories/Local/AvatarRepository.php new file mode 100644 index 0000000..6e8dc6e --- /dev/null +++ b/src/Zone/User/Application/Repositories/Local/AvatarRepository.php @@ -0,0 +1,46 @@ +guzzle->request('GET', $url); + $mime = $doodleIpsum->getHeader('Content-Type')[0]; + $binaryImage = base64_encode($doodleIpsum->getBody()->getContents()); + return new Avatar('data:' . $mime . ';base64,' . $binaryImage); + } + + public function storeAvatarFile(Avatar $avatar, string $name): ?string + { + if ($avatar->isBinaryFile()) { + $fileData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $avatar->getPath())); + $filename = Str::snake($name) . '.jpg'; + Storage::disk('avatars')->put($filename, $fileData); + return $filename; + } + return null; + } + + public function retrieveAvatarFile(Avatar $avatar): Avatar + { + if ($avatar->fileExists()) { + $fileData = Storage::disk('avatars')->get($avatar->getPath()); + $avatar->setValue('data:image/' . $avatar->getExtension() . ';base64,' . base64_encode($fileData)); + } + return $avatar; + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Commands/DestroyUserCommand.php b/src/Zone/User/Application/UseCases/Commands/DestroyUserCommand.php new file mode 100644 index 0000000..9d1a3ef --- /dev/null +++ b/src/Zone/User/Application/UseCases/Commands/DestroyUserCommand.php @@ -0,0 +1,27 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function execute(): void + { + authorize('delete', $this->policy); + $this->repository->delete($this->id); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Commands/GetRandomAvatarCommand.php b/src/Zone/User/Application/UseCases/Commands/GetRandomAvatarCommand.php new file mode 100644 index 0000000..139807a --- /dev/null +++ b/src/Zone/User/Application/UseCases/Commands/GetRandomAvatarCommand.php @@ -0,0 +1,21 @@ +avatarRepository = app()->make(AvatarRepositoryInterface::class); + } + + public function execute(): ?string + { + return $this->avatarRepository->getRandomAvatar()->getPath(); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Commands/StoreUserCommand.php b/src/Zone/User/Application/UseCases/Commands/StoreUserCommand.php new file mode 100644 index 0000000..85004a2 --- /dev/null +++ b/src/Zone/User/Application/UseCases/Commands/StoreUserCommand.php @@ -0,0 +1,45 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->avatarRepository = app()->make(AvatarRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function execute(): User + { + authorize('store', $this->policy); + if (UserEloquentModel::query()->where('email', $this->user->email)->exists()) { + throw new EmailAlreadyUsedException(); + } + + $avatar = $this->user->avatar; + if ($avatar->isBinaryFile()) { + $filename = $this->avatarRepository->storeAvatarFile($avatar, $this->user->name); + $this->user->setAvatar($filename); + } + + return $this->repository->store($this->user, $this->password); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Commands/UpdateUserCommand.php b/src/Zone/User/Application/UseCases/Commands/UpdateUserCommand.php new file mode 100644 index 0000000..c4d42c6 --- /dev/null +++ b/src/Zone/User/Application/UseCases/Commands/UpdateUserCommand.php @@ -0,0 +1,43 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->avatarRepository = app()->make(AvatarRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function execute(): void + { + authorize('update', $this->policy, ['user' => $this->user]); +// if (UserEloquentModel::query()->where('email', $this->user->email)->exists()) { +// throw new EmailAlreadyUsedException(); +// } + + $avatar = $this->user->avatar; + if ($avatar->isBinaryFile()) { + $filename = $this->avatarRepository->storeAvatarFile($avatar, $this->user->name); + $this->user->setAvatar($filename); + } + + $this->repository->update($this->user, $this->password); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Queries/FindAllUsersQuery.php b/src/Zone/User/Application/UseCases/Queries/FindAllUsersQuery.php new file mode 100644 index 0000000..51e94a5 --- /dev/null +++ b/src/Zone/User/Application/UseCases/Queries/FindAllUsersQuery.php @@ -0,0 +1,25 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function handle(): array + { + authorize('findAll', $this->policy); + return $this->repository->findAll(); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Queries/FindUserByEmailQuery.php b/src/Zone/User/Application/UseCases/Queries/FindUserByEmailQuery.php new file mode 100644 index 0000000..c204b5d --- /dev/null +++ b/src/Zone/User/Application/UseCases/Queries/FindUserByEmailQuery.php @@ -0,0 +1,27 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function handle(): array + { + authorize('findByEmail', $this->policy); + return $this->repository->findByEmail($this->email)->toArray(); + } +} \ No newline at end of file diff --git a/src/Zone/User/Application/UseCases/Queries/FindUserByIdQuery.php b/src/Zone/User/Application/UseCases/Queries/FindUserByIdQuery.php new file mode 100644 index 0000000..a73d399 --- /dev/null +++ b/src/Zone/User/Application/UseCases/Queries/FindUserByIdQuery.php @@ -0,0 +1,28 @@ +repository = app()->make(UserRepositoryInterface::class); + $this->policy = new UserPolicy(); + } + + public function handle(): User + { + authorize('findById', $this->policy); + return $this->repository->findById($this->id); + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Exceptions/PasswordTooShortException.php b/src/Zone/User/Domain/Exceptions/PasswordTooShortException.php new file mode 100644 index 0000000..703f543 --- /dev/null +++ b/src/Zone/User/Domain/Exceptions/PasswordTooShortException.php @@ -0,0 +1,11 @@ + fake()->name(), + 'email' => fake()->safeEmail(), + 'avatar' => null, + 'is_admin' => true, + 'is_active' => true, + ]; + + $attributes = array_replace($defaults, $attributes); + + return (new User( + id: null, + name: new Name($attributes['name']), + email: new Email($attributes['email']), + avatar: new Avatar($attributes['avatar']), + is_admin: $attributes['is_admin'], + is_active: $attributes['is_active'] + )); + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Model/User.php b/src/Zone/User/Domain/Model/User.php new file mode 100644 index 0000000..904678d --- /dev/null +++ b/src/Zone/User/Domain/Model/User.php @@ -0,0 +1,44 @@ +avatar = new Avatar($avatar); + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'avatar' => $this->avatar, + 'is_admin' => $this->is_admin, + 'is_active' => $this->is_active, + ]; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Zone/User/Domain/Model/ValueObjects/Avatar.php b/src/Zone/User/Domain/Model/ValueObjects/Avatar.php new file mode 100644 index 0000000..40724dc --- /dev/null +++ b/src/Zone/User/Domain/Model/ValueObjects/Avatar.php @@ -0,0 +1,58 @@ +avatar = $avatar; + } + + public static function fromString(?string $avatar): self + { + return new self($avatar); + } + + public function setValue(?string $avatar): void + { + $this->avatar = $avatar; + } + + public function getPath(): ?string + { + return $this->avatar ?? ''; + } + + public function getExtension(): ?string + { + return pathinfo(storage_path('app/avatars/' . $this->avatar), PATHINFO_EXTENSION); + } + + public function isNull(): bool + { + return $this->avatar === null; + } + + public function isBinaryFile(): bool + { + return !$this->isNull() && str_starts_with($this->avatar, 'data:image'); + } + + public function fileExists(): bool + { + return $this->avatar && file_exists(storage_path('app/avatars/' . $this->avatar)); + } + + public function __toString(): string + { + return $this->getPath() ?? ''; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Model/ValueObjects/Email.php b/src/Zone/User/Domain/Model/ValueObjects/Email.php new file mode 100644 index 0000000..506edb6 --- /dev/null +++ b/src/Zone/User/Domain/Model/ValueObjects/Email.php @@ -0,0 +1,36 @@ +email = $email; + } + + public function __toString(): string + { + return $this->email; + } + + public function jsonSerialize(): string + { + return $this->email; + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Model/ValueObjects/Name.php b/src/Zone/User/Domain/Model/ValueObjects/Name.php new file mode 100644 index 0000000..b41ab45 --- /dev/null +++ b/src/Zone/User/Domain/Model/ValueObjects/Name.php @@ -0,0 +1,32 @@ +name = $name; + } + + public function __toString(): string + { + return $this->name; + } + + public function jsonSerialize(): string + { + return $this->name; + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Model/ValueObjects/Password.php b/src/Zone/User/Domain/Model/ValueObjects/Password.php new file mode 100644 index 0000000..ee2ac22 --- /dev/null +++ b/src/Zone/User/Domain/Model/ValueObjects/Password.php @@ -0,0 +1,41 @@ +password = $password; + } + + public static function fromString(string $password, string $confirmation): self + { + return new self($password, $confirmation); + } + + public function value(): string + { + return $this->password; + } + + public function isNotEmpty(): bool + { + return $this->password !== null; + } +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Policies/UserPolicy.php b/src/Zone/User/Domain/Policies/UserPolicy.php new file mode 100644 index 0000000..780bb85 --- /dev/null +++ b/src/Zone/User/Domain/Policies/UserPolicy.php @@ -0,0 +1,39 @@ +user()?->is_admin ?? false; + } + + public function findById(): bool + { + return auth()->user()?->is_admin ?? false; + } + + public function findByEmail(): bool + { + return auth()->user()?->is_admin ?? false; + } + + public function store(): bool + { + return auth()->user()?->is_admin ?? false; + } + + public function update(User $user): bool + { + return auth()->user()?->is_admin || auth()->user()?->id == $user->id; + } + + public function delete(): bool + { + return auth()->user()?->is_admin ?? false; + } + +} \ No newline at end of file diff --git a/src/Zone/User/Domain/Repositories/AvatarRepositoryInterface.php b/src/Zone/User/Domain/Repositories/AvatarRepositoryInterface.php new file mode 100644 index 0000000..1455bb0 --- /dev/null +++ b/src/Zone/User/Domain/Repositories/AvatarRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'avatar', + 'password', + 'is_admin', + 'is_active' + ]; + + public array $rules = [ + 'name' => 'required', + 'email' => 'required', + 'avatar' => 'nullable', + 'password' => 'confirmed|min:8|nullable', + 'is_admin' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + 'created_at', + 'updated_at', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'email_verified_at' => 'datetime', + 'is_admin' => 'boolean', + 'is_active' => 'boolean', + 'avatar' => 'string', + 'password' => PasswordCast::class + ]; + + /** + * Get the identifier that will be stored in the subject claim of the JWT. + * + * @return mixed + */ + public function getJWTIdentifier() + { + return $this->getKey(); + } + /** + * Return a key value array, containing any custom claims to be added to the JWT. + * + * @return array + */ + public function getJWTCustomClaims() + { + return []; + } +} diff --git a/src/Zone/User/Presentation/API/.gitkeep b/src/Zone/User/Presentation/API/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Zone/User/Presentation/CLI/.gitkeep b/src/Zone/User/Presentation/CLI/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Zone/User/Presentation/HTTP/GetRandomAvatarController.php b/src/Zone/User/Presentation/HTTP/GetRandomAvatarController.php new file mode 100644 index 0000000..40577fb --- /dev/null +++ b/src/Zone/User/Presentation/HTTP/GetRandomAvatarController.php @@ -0,0 +1,20 @@ +json((new GetRandomAvatarCommand())->execute()); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } +} \ No newline at end of file diff --git a/src/Zone/User/Presentation/HTTP/UserController.php b/src/Zone/User/Presentation/HTTP/UserController.php new file mode 100644 index 0000000..74e32c1 --- /dev/null +++ b/src/Zone/User/Presentation/HTTP/UserController.php @@ -0,0 +1,75 @@ +json((new FindAllUsersQuery())->handle()); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } + + public function show(int $id): JsonResponse + { + try { + return response()->json((new FindUserByIdQuery($id))->handle()); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } + + public function store(Request $request): JsonResponse + { + try { + $userData = UserMapper::fromRequest($request); + $password = new Password($request->input('password'), $request->input('password_confirmation')); + $user = (new StoreUserCommand($userData, $password))->execute(); + return response()->json($user->toArray(), Response::HTTP_CREATED); + } catch (\DomainException $domainException) { + return response()->json(['error' => $domainException->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } + + public function update(int $user_id, Request $request): JsonResponse + { + try { + $user = UserMapper::fromRequest($request, $user_id); + $password = new Password($request->input('password'), $request->input('password_confirmation')); + (new UpdateUserCommand($user, $password))->execute(); + return response()->json($user->toArray(), Response::HTTP_OK); + } catch (\DomainException $domainException) { + return response()->json(['error' => $domainException->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } + + public function destroy(int $user_id): JsonResponse + { + try { + (new DestroyUserCommand($user_id))->execute(); + return response()->json(null, Response::HTTP_NO_CONTENT); + } catch (UnauthorizedUserException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED); + } + } +} diff --git a/src/Zone/User/Presentation/HTTP/routes.php b/src/Zone/User/Presentation/HTTP/routes.php new file mode 100644 index 0000000..9dc352a --- /dev/null +++ b/src/Zone/User/Presentation/HTTP/routes.php @@ -0,0 +1,17 @@ + 'user' +], function () { + Route::get('random-avatar', GetRandomAvatarController::class); + + Route::get('index', [UserController::class, 'index']); + Route::get('{id}', [UserController::class, 'show']); + Route::post('', [UserController::class, 'store']); + Route::put('{id}', [UserController::class, 'update']); + Route::delete('{id}', [UserController::class, 'destroy']); +}); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php new file mode 100644 index 0000000..57aad9a --- /dev/null +++ b/tests/Feature/UserTest.php @@ -0,0 +1,337 @@ +user_uri = '/user'; + $this->index_uri = $this->user_uri . '/index'; + $this->random_avatar_uri = $this->user_uri . '/random-avatar'; + $this->adminToken = $this->newLoggedAdmin()['token']; + $this->userData = $this->newLoggedUser(); + $this->userToken = $this->userData['token']; + } + + /** @test */ + public function admin_can_retrieve_all_users() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->get($this->index_uri) + ->assertStatus(Response::HTTP_OK) + ->assertJsonCount($numberUsers + 2); // +2 because of the admin and user + } + + /** @test */ + public function user_cannot_retrieve_all_users() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->get($this->index_uri) + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertSee(['error' => 'The user is not authorized to access this resource']); + } + + /** @test */ + public function admin_can_get_specific_user_by_id() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->get($this->user_uri . '/' . $randomUserId) + ->assertStatus(Response::HTTP_OK) + ->assertJsonStructure(['id', 'name', 'email', 'avatar', 'is_admin', 'is_active']); + } + + /** @test */ + public function user_cannot_get_specific_user_by_id() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->get($this->user_uri . '/' . $randomUserId) + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertSee(['error' => 'The user is not authorized to access this resource']); + } + + /** @test */ + public function admin_can_create_a_user() + { + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle', + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $expectedResponse = [ + 'id' => 3, + 'name' => $requestBody['name'], + 'email' => $requestBody['email'], + 'is_admin' => false, + 'is_active' => true + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->post($this->user_uri, $requestBody) + ->assertStatus(Response::HTTP_CREATED) + ->assertJson($expectedResponse); + + // Assert cannot create user with same email + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->post($this->user_uri, $requestBody) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'El email ya está en uso']); + } + + /** @test */ + public function user_cannot_create_user() + { + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle', + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->post($this->user_uri, $requestBody) + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertSee(['error' => 'The user is not authorized to access this resource']); + } + + /** @test */ + public function cannot_create_user_with_invalid_email() + { + $password = $this->faker->password(8); + $requestBodyInvalidEmail = [ + 'name' => $this->faker->name, + 'email' => 'invalid-email', + 'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle', + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->post($this->user_uri, $requestBodyInvalidEmail) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'Must be a valid email']); + } + + /** @test */ + public function cannot_create_user_with_invalid_password() + { + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle', + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $requestBodyInvalidPassword = $requestBody; + $requestBodyInvalidPassword['password'] = '1234'; + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->post($this->user_uri, $requestBodyInvalidPassword) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'The password needs to be at least 8 characters long']); + + $requestBodyNoPasswordConfirmation = $requestBody; + unset($requestBodyNoPasswordConfirmation['password_confirmation']); + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->post($this->user_uri, $requestBodyNoPasswordConfirmation) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'Passwords do not match']); + } + + /** @test */ + public function admin_can_update_any_user() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => false, + 'is_active' => false, + 'update_avatar' => true, + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $expectedResponse = [ + 'id' => $randomUserId, + 'name' => $requestBody['name'], + 'email' => $requestBody['email'], + 'avatar' => false, + 'is_admin' => false, + 'is_active' => false + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->put($this->user_uri . '/' . $randomUserId, $requestBody) + ->assertStatus(Response::HTTP_OK) + ->assertJson($expectedResponse); + } + + /** @test */ + public function user_cannot_update_any_user_except_itself() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(4, 4 + $numberUsers); + + // Update another user + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => false, + 'is_active' => false, + 'update_avatar' => true, + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->put($this->user_uri . '/' . $randomUserId, $requestBody) + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertSee(['error' => 'The user is not authorized to access this resource']); + + // Update itself + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->userData['email'], + 'avatar' => false, + 'is_active' => true, + 'update_avatar' => false, + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $expectedResponse = [ + 'id' => $this->userData['id'], + 'name' => $requestBody['name'], + 'email' => $requestBody['email'], + 'avatar' => $requestBody['avatar'], + 'is_active' => $requestBody['is_active'] + ]; + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->put($this->user_uri . '/' . $this->userData['id'], $requestBody) + ->assertStatus(Response::HTTP_OK) + ->assertJson($expectedResponse); + } + + /** @test */ + public function cannot_update_user_with_invalid_password() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $password = $this->faker->password(8); + $requestBody = [ + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle', + 'is_active' => false, + 'update_avatar' => true, + 'password' => $password, + 'password_confirmation' => $password, + ]; + + $requestBodyInvalidPassword = $requestBody; + $requestBodyInvalidPassword['password'] = '1234'; + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->put($this->user_uri . '/' . $randomUserId, $requestBodyInvalidPassword) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'The password needs to be at least 8 characters long']); + + $requestBodyNoPasswordConfirmation = $requestBody; + unset($requestBodyNoPasswordConfirmation['password_confirmation']); + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->put($this->user_uri . '/' . $randomUserId, $requestBodyNoPasswordConfirmation) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJson(['error' => 'Passwords do not match']); + + } + + /** @test */ + public function admin_can_delete_a_user() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->delete($this->user_uri . '/' . $randomUserId) + ->assertStatus(Response::HTTP_NO_CONTENT); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->get($this->user_uri . '/' . $randomUserId) + ->assertStatus(Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function user_cannot_delete_a_user() + { + $numberUsers = $this->faker->numberBetween(1, 10); + $this->createRandomUsers($numberUsers); + $randomUserId = $this->faker->numberBetween(1, $numberUsers); + + $this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken]) + ->delete($this->user_uri . '/' . $randomUserId) + ->assertStatus(Response::HTTP_UNAUTHORIZED) + ->assertSee(['error' => 'The user is not authorized to access this resource']); + } + + /** @test */ + public function cannot_delete_user_if_does_not_exists() + { + $this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken]) + ->delete($this->user_uri . '/' . 999) + ->assertStatus(Response::HTTP_NOT_FOUND); + } + + + private function createRandomUsers($usersNumber = 1): void + { + foreach (range(1, $usersNumber) as $_) { + $user = UserFactory::new(); + $userEloquentModel = UserMapper::toEloquent($user); + $userEloquentModel->password = $this->faker->password(8); + $userEloquentModel->save(); + } + } +} diff --git a/tests/Integration/DoodleAPITest.php b/tests/Integration/DoodleAPITest.php new file mode 100644 index 0000000..f970a79 --- /dev/null +++ b/tests/Integration/DoodleAPITest.php @@ -0,0 +1,19 @@ +get($url); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('image/png', $response->getHeader('Content-Type')[0]); + } +} diff --git a/tests/Support/WithLogin.php b/tests/Support/WithLogin.php new file mode 100644 index 0000000..5c13517 --- /dev/null +++ b/tests/Support/WithLogin.php @@ -0,0 +1,51 @@ +faker->password(8); + $user = UserFactory::new($attributes); + $userEloquentModel = UserMapper::toEloquent($user); + $userEloquentModel->password = $password; + $userEloquentModel->save(); + + return [ + 'id' => $userEloquentModel->id, + 'email' => $user->email, + 'password' => $password, + ]; + } + + private function newLoggedAdmin(): array + { + $credentials = $this->validCredentials(['is_admin' => true]); + $response = $this->post('auth/login', $credentials); + return ['token' => $this->getToken($response), ...$credentials]; + } + + private function newLoggedUser(): array + { + $credentials = $this->validCredentials(['is_admin' => false]); + $response = $this->post('auth/login', $credentials); + return ['token' => $this->getToken($response), ...$credentials]; + } + + private function getToken(TestResponse $response) + { + $arResponse = json_decode($response->getContent(), true); + return $arResponse['accessToken']; + } +} \ No newline at end of file