From 6d26cb67637ba74d393085c63f1e782f13d70efa Mon Sep 17 00:00:00 2001 From: mrkamischinmailru Date: Fri, 29 Aug 2025 17:58:48 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20?= =?UTF-8?q?=D1=81=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8+=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/V1/AuthController.php | 61 ++ .../Api/{v1 => V1}/OauthController.php | 0 .../Controllers/Api/V1/TaskController.php | 160 +++++ app/Models/Category.php | 25 + app/Models/Priority.php | 23 + app/Models/Task.php | 50 ++ composer.json | 3 + composer.lock | 620 +++++++++++++++--- database/factories/CategoryFactory.php | 20 + database/factories/PriorityFactory.php | 19 + database/factories/TaskFactory.php | 31 + database/factories/UserFactory.php | 16 +- ...5_05_14_212250_create_priorities_table.php | 27 + ...5_05_14_212519_create_categories_table.php | 29 + .../2025_05_14_213406_create_tasks_table.php | 33 + ...5_180129_add_creator_id_to_tasks_table.php | 29 + ...06_06_054235_add_status_to_tasks_table.php | 30 + ...06_06_083228_add_status_to_tasks_table.php | 34 + ...8_22_082615_create_oauth_clients_table.php | 4 +- routes/api.php | 48 +- tests/Feature/Api/ApiTestCase.php | 145 ++++ tests/Feature/Api/V1/AuthTest.php | 123 ++++ tests/Feature/Api/V1/AuthorizationTest.php | 229 +++++++ tests/Feature/Api/V1/TaskTest.php | 347 ++++++++++ tests/Feature/Api/V1/ValidationTest.php | 265 ++++++++ tests/Feature/Auth/AuthenticationTest.php | 54 -- tests/Feature/Auth/EmailVerificationTest.php | 58 -- .../Feature/Auth/PasswordConfirmationTest.php | 44 -- tests/Feature/Auth/PasswordResetTest.php | 73 --- tests/Feature/Auth/PasswordUpdateTest.php | 51 -- tests/Feature/Auth/RegistrationTest.php | 31 - tests/Feature/ExampleTest.php | 19 - tests/Feature/NewsControllerTest.php | 74 --- tests/Feature/ProfileTest.php | 99 --- tests/Feature/WithdrawControllerTest.php | 27 - tests/Unit/CalcServiceTest.php | 42 -- tests/Unit/ExampleTest.php | 16 - tests/Unit/Users/Commands/CreateUserTest.php | 156 +++++ tests/Unit/Users/Commands/DeleteUserTest.php | 95 +++ tests/Unit/Users/Commands/UpdateUserTest.php | 116 ++++ 40 files changed, 2640 insertions(+), 686 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/AuthController.php rename app/Http/Controllers/Api/{v1 => V1}/OauthController.php (100%) create mode 100644 app/Http/Controllers/Api/V1/TaskController.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Priority.php create mode 100644 app/Models/Task.php create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/factories/PriorityFactory.php create mode 100644 database/factories/TaskFactory.php create mode 100644 database/migrations/2025_05_14_212250_create_priorities_table.php create mode 100644 database/migrations/2025_05_14_212519_create_categories_table.php create mode 100644 database/migrations/2025_05_14_213406_create_tasks_table.php create mode 100644 database/migrations/2025_06_05_180129_add_creator_id_to_tasks_table.php create mode 100644 database/migrations/2025_06_06_054235_add_status_to_tasks_table.php create mode 100644 database/migrations/2025_06_06_083228_add_status_to_tasks_table.php create mode 100644 tests/Feature/Api/ApiTestCase.php create mode 100644 tests/Feature/Api/V1/AuthTest.php create mode 100644 tests/Feature/Api/V1/AuthorizationTest.php create mode 100644 tests/Feature/Api/V1/TaskTest.php create mode 100644 tests/Feature/Api/V1/ValidationTest.php delete mode 100644 tests/Feature/Auth/AuthenticationTest.php delete mode 100644 tests/Feature/Auth/EmailVerificationTest.php delete mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php delete mode 100644 tests/Feature/Auth/PasswordResetTest.php delete mode 100644 tests/Feature/Auth/PasswordUpdateTest.php delete mode 100644 tests/Feature/Auth/RegistrationTest.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Feature/NewsControllerTest.php delete mode 100644 tests/Feature/ProfileTest.php delete mode 100644 tests/Feature/WithdrawControllerTest.php delete mode 100644 tests/Unit/CalcServiceTest.php delete mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/Users/Commands/CreateUserTest.php create mode 100644 tests/Unit/Users/Commands/DeleteUserTest.php create mode 100644 tests/Unit/Users/Commands/UpdateUserTest.php diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php new file mode 100644 index 000000000..a5bebe784 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AuthController.php @@ -0,0 +1,61 @@ +only('email', 'password'); + + if (!Auth::attempt($credentials)) { + return response()->json([ + 'error' => 'Неверные учетные данные' + ], 401); + } + + $user = Auth::user(); + $token = $user->createToken('API Token')->accessToken; + + return response()->json([ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer', + ]); + } + + /** + * @OA\Post( + * path="/auth/logout", + * summary="Logout", + * tags={"Auth"} + * ) + */ + public function logout(Request $request): JsonResponse + { + $request->user()->token()->revoke(); + + return response()->json([ + 'message' => 'Успешный выход из системы' + ]); + } + + +} + diff --git a/app/Http/Controllers/Api/v1/OauthController.php b/app/Http/Controllers/Api/V1/OauthController.php similarity index 100% rename from app/Http/Controllers/Api/v1/OauthController.php rename to app/Http/Controllers/Api/V1/OauthController.php diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php new file mode 100644 index 000000000..527b4e4ed --- /dev/null +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -0,0 +1,160 @@ +get('page', 1); + $perPage = (int) $request->get('per_page', 15); + + $query = FetchAllTasksQuery::fromPage($page, $perPage); + $result = $fetcher->fetch($query); + + return response()->json([ + 'data' => $result->items, + 'meta' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total' => $result->total, + 'last_page' => ceil($result->total / $perPage), + ] + ]); + } + + /** + * @OA\Post( + * path="/tasks", + * summary="Create task", + * tags={"Tasks"} + * ) + */ + public function store(StoreTaskRequest $request, CreateTaskHandler $handler): JsonResponse + { + $command = new CreateTaskCommand( + title: $request->title, + description: $request->description ?? '', + executorId: (int) $request->executor_id, + categoryId: (int) $request->category_id, + priorityId: (int) $request->priority_id, + creatorId: auth('api')->id(), + status: $request->status ?? 'новая', + dueDate: $request->due_date + ); + + $result = $handler->handle($command); + + if ($result) { + return response()->json([ + 'message' => 'Задача успешно создана' + ], 201); + } + + return response()->json([ + 'message' => 'Ошибка при создании задачи' + ], 500); + } + + /** + * Получить задачу по ID + */ + public function show(int $id, FetchTaskByIdFetcher $fetcher): JsonResponse + { + try { + $query = new FetchTaskByIdQuery($id); + $taskDTO = $fetcher->fetch($query); + + return response()->json([ + 'data' => $taskDTO + ]); + } catch (TaskNotFoundException $e) { + return response()->json([ + 'message' => 'Задача не найдена' + ], 404); + } + } + + /** + * Обновить задачу + */ + public function update(int $id, UpdateTaskRequest $request, UpdateTaskHandler $handler): JsonResponse + { + try { + $command = new UpdateTaskCommand( + id: $id, + title: $request->title, + description: $request->description ?? '', + executorId: (int) $request->executor_id, + categoryId: (int) $request->category_id, + priorityId: (int) $request->priority_id, + creatorId: auth('api')->id(), + status: $request->status ?? 'новая', + dueDate: $request->due_date + ); + + $taskDTO = $handler->handle($command); + + return response()->json([ + 'message' => 'Задача успешно обновлена', + 'data' => $taskDTO + ]); + } catch (TaskNotFoundException $e) { + return response()->json([ + 'message' => 'Задача не найдена' + ], 404); + } + } + + /** + * Удалить задачу + */ + public function destroy(int $id, DeleteTaskHandler $handler): JsonResponse + { + try { + $command = new DeleteTaskCommand($id); + $result = $handler->handle($command); + + if ($result) { + return response()->json([ + 'message' => 'Задача успешно удалена' + ]); + } + + return response()->json([ + 'message' => 'Ошибка при удалении задачи' + ], 500); + } catch (TaskNotFoundException $e) { + return response()->json([ + 'message' => 'Задача не найдена' + ], 404); + } + } +} + diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 000000000..b549fa1ae --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,25 @@ +hasMany(Task::class); + } +} diff --git a/app/Models/Priority.php b/app/Models/Priority.php new file mode 100644 index 000000000..857e6de0f --- /dev/null +++ b/app/Models/Priority.php @@ -0,0 +1,23 @@ +hasMany(Task::class); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 000000000..0ec9b6aa1 --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,50 @@ + 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function priority() + { + return $this->belongsTo(Priority::class); + } + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function executor() + { + return $this->belongsTo(User::class, 'executor_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'creator_id'); + } +} diff --git a/composer.json b/composer.json index db1948ecb..e0271890e 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "darkaonline/l5-swagger": "^9.0", "dedoc/scramble": "^0.12.30", "fakerphp/faker": "^1.24", "illuminate/support": "*", @@ -18,6 +19,8 @@ "lcobucci/jwt": "^4.3", "league/event": "^2.3.0", "prog-time/tg-logger": "^1.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", "tymon/jwt-auth": "^2.1", "vagrant/ascii": "@dev", "watson/rememberable": "^7.0" diff --git a/composer.lock b/composer.lock index 755e0cca3..65a43a9c2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7c078c96eb9aabf76e88977c608cb639", + "content-hash": "45f8f5a81117c88bd103dcebc1d56ea7", "packages": [ { "name": "brick/math", @@ -135,6 +135,87 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "darkaonline/l5-swagger", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/DarkaOnLine/L5-Swagger.git", + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DarkaOnLine/L5-Swagger/zipball/2c26427f8c41db8e72232415e7287313e6b6a2e2", + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2", + "shasum": "" + }, + "require": { + "doctrine/annotations": "^1.0 || ^2.0", + "ext-json": "*", + "laravel/framework": "^12.0 || ^11.0", + "php": "^8.2", + "swagger-api/swagger-ui": ">=5.18.3", + "symfony/yaml": "^5.0 || ^6.0 || ^7.0", + "zircote/swagger-php": "^5.0.0" + }, + "require-dev": { + "mockery/mockery": "1.*", + "orchestra/testbench": "^10.0 || ^9.0 || ^8.0 || 7.* || ^6.15 || 5.*", + "php-coveralls/php-coveralls": "^2.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "L5Swagger": "L5Swagger\\L5SwaggerFacade" + }, + "providers": [ + "L5Swagger\\L5SwaggerServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "L5Swagger\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Darius Matulionis", + "email": "darius@matulionis.lt" + } + ], + "description": "OpenApi or Swagger integration to Laravel", + "keywords": [ + "api", + "documentation", + "laravel", + "openapi", + "specification", + "swagger", + "ui" + ], + "support": { + "issues": "https://github.com/DarkaOnLine/L5-Swagger/issues", + "source": "https://github.com/DarkaOnLine/L5-Swagger/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/DarkaOnLine", + "type": "github" + } + ], + "time": "2025-02-28T06:25:02+00:00" + }, { "name": "dedoc/scramble", "version": "v0.12.30", @@ -359,6 +440,82 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/annotations", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/annotations.git", + "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/901c2ee5d26eb64ff43c47976e114bf00843acf7", + "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2 || ^3", + "ext-tokenizer": "*", + "php": "^7.2 || ^8.0", + "psr/cache": "^1 || ^2 || ^3" + }, + "require-dev": { + "doctrine/cache": "^2.0", + "doctrine/coding-standard": "^10", + "phpstan/phpstan": "^1.10.28", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "symfony/cache": "^5.4 || ^6.4 || ^7", + "vimeo/psalm": "^4.30 || ^5.14" + }, + "suggest": { + "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Docblock Annotations Parser", + "homepage": "https://www.doctrine-project.org/projects/annotations.html", + "keywords": [ + "annotations", + "docblock", + "parser" + ], + "support": { + "issues": "https://github.com/doctrine/annotations/issues", + "source": "https://github.com/doctrine/annotations/tree/2.0.2" + }, + "time": "2024-09-05T10:17:24+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.10", @@ -3791,6 +3948,55 @@ }, "time": "2024-12-24T22:13:42+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -4102,6 +4308,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -4540,6 +4859,67 @@ ], "time": "2025-07-17T15:46:43+00:00" }, + { + "name": "swagger-api/swagger-ui", + "version": "v5.28.0", + "source": { + "type": "git", + "url": "https://github.com/swagger-api/swagger-ui.git", + "reference": "01e23904eec6075e032e07f3235607b463d9ecf3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swagger-api/swagger-ui/zipball/01e23904eec6075e032e07f3235607b463d9ecf3", + "reference": "01e23904eec6075e032e07f3235607b463d9ecf3", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Anna Bodnia", + "email": "anna.bodnia@gmail.com" + }, + { + "name": "Buu Nguyen", + "email": "buunguyen@gmail.com" + }, + { + "name": "Josh Ponelat", + "email": "jponelat@gmail.com" + }, + { + "name": "Kyle Shockey", + "email": "kyleshockey1@gmail.com" + }, + { + "name": "Robert Barnwell", + "email": "robert@robertismy.name" + }, + { + "name": "Sahar Jafari", + "email": "shr.jafari@gmail.com" + } + ], + "description": " Swagger UI is a collection of HTML, Javascript, and CSS assets that dynamically generate beautiful documentation from a Swagger-compliant API.", + "homepage": "http://swagger.io", + "keywords": [ + "api", + "documentation", + "openapi", + "specification", + "swagger", + "ui" + ], + "support": { + "issues": "https://github.com/swagger-api/swagger-ui/issues", + "source": "https://github.com/swagger-api/swagger-ui/tree/v5.28.0" + }, + "time": "2025-08-28T13:24:41+00:00" + }, { "name": "symfony/clock", "version": "v7.3.0", @@ -6901,6 +7281,82 @@ ], "time": "2025-07-29T20:02:46+00:00" }, + { + "name": "symfony/yaml", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.3.0", @@ -7339,6 +7795,92 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "5.3.2", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "d8fa9dc4c3b2fc8651ae780021bb9719b1e63d40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/d8fa9dc4c3b2fc8651ae780021bb9719b1e63d40", + "reference": "d8fa9dc4c3b2fc8651ae780021bb9719b1e63d40", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=7.4", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": "^5.0 || ^6.0 || ^7.0", + "symfony/yaml": "^5.0 || ^6.0 || ^7.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^1.6 || ^2.0", + "phpunit/phpunit": "^9.0", + "rector/rector": "^1.0 || ^2.0", + "vimeo/psalm": "^4.30 || ^5.0" + }, + "suggest": { + "doctrine/annotations": "^2.0" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/5.3.2" + }, + "time": "2025-08-25T21:57:16+00:00" } ], "packages-dev": [ @@ -9579,82 +10121,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v7.3.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-10T08:47:49+00:00" - }, { "name": "theseer/tokenizer", "version": "1.2.3", diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 000000000..de6a97174 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,20 @@ + $this->faker->hexColor(), + 'name' => $this->faker->sentence(rand(1, 3)), + 'description' => $this->faker->paragraph(), + ]; + } +} diff --git a/database/factories/PriorityFactory.php b/database/factories/PriorityFactory.php new file mode 100644 index 000000000..d6e8974fe --- /dev/null +++ b/database/factories/PriorityFactory.php @@ -0,0 +1,19 @@ + $this->faker->randomElement(['Low', 'Medium', 'High', 'Critical']), + ]; + } +} diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php new file mode 100644 index 000000000..04001eccf --- /dev/null +++ b/database/factories/TaskFactory.php @@ -0,0 +1,31 @@ + $this->faker->sentence(), + 'description' => $this->faker->paragraph(rand(1, 3)), + 'status' => $this->faker->randomElement(['новая', 'в работе', 'выполнена', 'отменена']), + 'due_date' => $this->faker->dateTimeBetween('now', '+30 days'), + 'priority_id' => Priority::inRandomOrder()->first()->id ?? Priority::factory(), + 'category_id' => Category::inRandomOrder()->first()->id ?? Category::factory(), + 'executor_id' => User::inRandomOrder()->first()->id ?? User::factory(), + 'creator_id' => User::inRandomOrder()->first()->id ?? User::factory(), + 'created_at' => now(), + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 499e4e911..db8fcaa07 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,8 +28,8 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - 'is_admin' => fake()->boolean + 'remember_token' => Str::random(10), + 'is_admin' => false, ]; } @@ -42,4 +42,16 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + /** + * Создать администратора + */ + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'name' => 'Администратор Сайта', + 'email' => 'admin@example.com', + 'is_admin' => true, + ]); + } } diff --git a/database/migrations/2025_05_14_212250_create_priorities_table.php b/database/migrations/2025_05_14_212250_create_priorities_table.php new file mode 100644 index 000000000..0b92a6246 --- /dev/null +++ b/database/migrations/2025_05_14_212250_create_priorities_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('priorities'); + } +}; diff --git a/database/migrations/2025_05_14_212519_create_categories_table.php b/database/migrations/2025_05_14_212519_create_categories_table.php new file mode 100644 index 000000000..721e1dd7f --- /dev/null +++ b/database/migrations/2025_05_14_212519_create_categories_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('color'); + $table->string('name'); + $table->text('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2025_05_14_213406_create_tasks_table.php b/database/migrations/2025_05_14_213406_create_tasks_table.php new file mode 100644 index 000000000..67ebb8804 --- /dev/null +++ b/database/migrations/2025_05_14_213406_create_tasks_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('title'); + $table->longText('description'); + $table->dateTime('due_date')->nullable(); + $table->foreignId('priority_id')->references('id')->on('priorities'); + $table->foreignId('category_id')->references('id')->on('categories'); + $table->foreignId('executor_id')->references('id')->on('users'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/database/migrations/2025_06_05_180129_add_creator_id_to_tasks_table.php b/database/migrations/2025_06_05_180129_add_creator_id_to_tasks_table.php new file mode 100644 index 000000000..286bfe878 --- /dev/null +++ b/database/migrations/2025_06_05_180129_add_creator_id_to_tasks_table.php @@ -0,0 +1,29 @@ +foreignId('creator_id')->after('id')->constrained('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['creator_id']); + $table->dropColumn('creator_id'); + }); + } +}; diff --git a/database/migrations/2025_06_06_054235_add_status_to_tasks_table.php b/database/migrations/2025_06_06_054235_add_status_to_tasks_table.php new file mode 100644 index 000000000..2443bd201 --- /dev/null +++ b/database/migrations/2025_06_06_054235_add_status_to_tasks_table.php @@ -0,0 +1,30 @@ +enum('status', ['новая', 'в работе', 'выполнена', 'отменена']) + ->default('новая') + ->after('priority_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/database/migrations/2025_06_06_083228_add_status_to_tasks_table.php b/database/migrations/2025_06_06_083228_add_status_to_tasks_table.php new file mode 100644 index 000000000..9965f73d0 --- /dev/null +++ b/database/migrations/2025_06_06_083228_add_status_to_tasks_table.php @@ -0,0 +1,34 @@ +enum('status', ['новая', 'в работе', 'выполнена', 'отменена']) + ->default('новая') + ->after('priority_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + if (Schema::hasColumn('tasks', 'status')) { + $table->dropColumn('status'); + } + }); + } +}; diff --git a/database/migrations/2025_08_22_082615_create_oauth_clients_table.php b/database/migrations/2025_08_22_082615_create_oauth_clients_table.php index 776ccfab2..be4c424e3 100644 --- a/database/migrations/2025_08_22_082615_create_oauth_clients_table.php +++ b/database/migrations/2025_08_22_082615_create_oauth_clients_table.php @@ -17,7 +17,9 @@ public function up(): void $table->string('name'); $table->string('secret', 100)->nullable(); $table->string('provider')->nullable(); - $table->text('redirect'); + $table->text('redirect'); + $table->longText('redirect_uris'); + $table->longText('grant_types'); $table->boolean('personal_access_client'); $table->boolean('password_client'); $table->boolean('revoked'); diff --git a/routes/api.php b/routes/api.php index d24920cac..1e15ff28e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,26 +1,38 @@ - 'v1'], function() { - Route::group(['prefix' => 'auth'], function() { - Route::post('register', [OauthController::class, 'register']); - Route::post('login', [OauthController::class, 'login']); - }); +/* +|-------------------------------------------------------------------------- +| API Routes V1 +|-------------------------------------------------------------------------- +| +| API Version 1 routes with OAuth2 authentication via Laravel Passport +| +*/ - Route::middleware('auth:api')->group(function() { - //Route::apiResource('news', NewsController::class); - Route::get('news', [NewsController::class,'index']); - Route::get('news/{id}', [NewsController::class,'show']); - Route::put('news/create', [NewsController::class,'create']); - Route::delete('news/{id}', [NewsController::class,'destroy']); - Route::post('news/{id}', [NewsController::class,'update']); - Route::get('test_scope', [NewsController::class, 'testScope']) - ->middleware(CheckScopes::using(['news:create'])); +Route::prefix('v1')->name('api.v1.')->group(function () { + // Публичные маршруты аутентификации + Route::prefix('auth')->group(function () { + Route::post('login', [AuthController::class, 'login']); }); + // Защищенные маршруты + Route::middleware('auth:api')->group(function () { + // Аутентификация + Route::prefix('auth')->group(function () { + Route::post('logout', [AuthController::class, 'logout']); + }); + + // CRUD для задач + Route::prefix('tasks')->name('tasks.')->group(function () { + Route::get('/', [TaskController::class, 'index'])->name('index'); + Route::post('/', [TaskController::class, 'store'])->name('store'); + Route::get('/{id}', [TaskController::class, 'show'])->name('show'); + Route::put('/{id}', [TaskController::class, 'update'])->name('update'); + Route::delete('/{id}', [TaskController::class, 'destroy'])->name('destroy'); + }); + }); }); \ No newline at end of file diff --git a/tests/Feature/Api/ApiTestCase.php b/tests/Feature/Api/ApiTestCase.php new file mode 100644 index 000000000..bf1ea255a --- /dev/null +++ b/tests/Feature/Api/ApiTestCase.php @@ -0,0 +1,145 @@ +insert([ + 'id' => 12, + 'name' => 'Test Personal Access Client', + 'secret' => null, + 'provider' => null, + 'redirect'=>'', + 'personal_access_client'=>12, + 'redirect_uris' => '[]', + 'grant_types' => '["personal_access"]', + 'revoked' => false, + 'password_client'=>1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->user = User::factory()->create(); + } + + /** + * Аутентифицировать пользователя через Passport + */ + protected function authenticateUser(User $user): User + { + $user = $user ?? $this->user; + Passport::actingAs($user ?? 1); + return $user; + } + + /** + * Отправить GET запрос к API + */ + protected function apiGet(string $endpoint, array $headers = []): \Illuminate\Testing\TestResponse + { + return $this->getJson("/api/{$this->apiVersion}{$endpoint}", $headers); + } + + /** + * Отправить POST запрос к API + */ + protected function apiPost(string $endpoint, array $data = [], array $headers = []): \Illuminate\Testing\TestResponse + { + return $this->postJson("/api/{$this->apiVersion}{$endpoint}", $data, $headers); + } + + /** + * Отправить PUT запрос к API + */ + protected function apiPut(string $endpoint, array $data = [], array $headers = []): \Illuminate\Testing\TestResponse + { + return $this->putJson("/api/{$this->apiVersion}{$endpoint}", $data, $headers); + } + + /** + * Отправить DELETE запрос к API + */ + protected function apiDelete(string $endpoint, array $headers = []): \Illuminate\Testing\TestResponse + { + return $this->deleteJson("/api/{$this->apiVersion}{$endpoint}", [], $headers); + } + + /** + * Отправить аутентифицированный GET запрос + */ + protected function authenticatedApiGet(string $endpoint, User $user): \Illuminate\Testing\TestResponse + { + $this->authenticateUser($user ?? 1); + return $this->apiGet($endpoint); + } + + /** + * Отправить аутентифицированный POST запрос + */ + protected function authenticatedApiPost(string $endpoint, array $data = [], User $user): \Illuminate\Testing\TestResponse + { + + $this->authenticateUser($user ?? 1); + return $this->apiPost($endpoint, $data); + } + + /** + * Отправить аутентифицированный PUT запрос + */ + protected function authenticatedApiPut(string $endpoint, array $data = [], User $user): \Illuminate\Testing\TestResponse + { + $this->authenticateUser($user); + return $this->apiPut($endpoint, $data); + } + + /** + * Отправить аутентифицированный DELETE запрос + */ + protected function authenticatedApiDelete(string $endpoint, User $user): \Illuminate\Testing\TestResponse + { + $this->authenticateUser($user); + return $this->apiDelete($endpoint); + } + /** + * Проверить структуру JSON ответа с ошибкой + */ + protected function assertJsonErrorStructure(\Illuminate\Testing\TestResponse $response, int $expectedStatus = 400): void + { + $response->assertStatus($expectedStatus) + ->assertJsonStructure([ + 'message' + ]); + } + + /** + * Проверить структуру JSON ответа с пагинацией + */ + protected function assertJsonPaginationStructure(\Illuminate\Testing\TestResponse $response): void + { + $response->assertJsonStructure([ + 'data', + 'meta' => [ + 'current_page', + 'per_page', + 'total', + 'last_page' + ] + ]); + } +} diff --git a/tests/Feature/Api/V1/AuthTest.php b/tests/Feature/Api/V1/AuthTest.php new file mode 100644 index 000000000..593e0e1cc --- /dev/null +++ b/tests/Feature/Api/V1/AuthTest.php @@ -0,0 +1,123 @@ +create([ + 'email' => 'test@example.com', + 'password' => Hash::make('password123') + ]); + + $response = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'user' => [ + 'id', + 'name', + 'email' + ], + 'access_token', + 'token_type' + ]); + + $this->assertEquals('Bearer', $response->json('token_type')); + $this->assertNotEmpty($response->json('access_token')); + } + + public function test_user_cannot_login_with_invalid_credentials(): void + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => Hash::make('password123') + ]); + + $response = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password' + ]); + + $response->assertStatus(401) + ->assertJson([ + 'error' => 'Неверные учетные данные' + ]); + } + + public function test_login_requires_email(): void + { + $response = $this->apiPost('/auth/login', [ + 'password' => 'password123' + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_login_requires_password(): void + { + $response = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com' + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } + + public function test_login_requires_valid_email_format(): void + { + $response = $this->apiPost('/auth/login', [ + 'email' => 'invalid-email', + 'password' => 'password123' + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_authenticated_user_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->authenticatedApiPost('/auth/logout', [], $user); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Успешный выход из системы' + ]); + } + + public function test_unauthenticated_user_cannot_logout(): void + { + $response = $this->apiPost('/auth/logout'); + + $response->assertStatus(401); + } + + public function test_user_cannot_access_protected_routes_without_authentication(): void + { + $response = $this->apiGet('/tasks'); + + $response->assertStatus(401); + } + + public function test_user_can_access_protected_routes_with_authentication(): void + { + $user = User::factory()->create(); + + $response = $this->authenticatedApiGet('/tasks', $user); + + // Ожидаем успешный ответ (200) или отсутствие данных с правильной структурой + $response->assertStatus(200); + $this->assertJsonPaginationStructure($response); + } +} diff --git a/tests/Feature/Api/V1/AuthorizationTest.php b/tests/Feature/Api/V1/AuthorizationTest.php new file mode 100644 index 000000000..37b8c8afe --- /dev/null +++ b/tests/Feature/Api/V1/AuthorizationTest.php @@ -0,0 +1,229 @@ +category = Category::factory()->create(); + $this->priority = Priority::factory()->create(); + } + + public function test_api_requires_authentication_for_all_protected_routes(): void + { + $protectedRoutes = [ + ['method' => 'GET', 'uri' => '/tasks'], + ['method' => 'POST', 'uri' => '/tasks'], + ['method' => 'GET', 'uri' => '/tasks/1'], + ['method' => 'PUT', 'uri' => '/tasks/1'], + ['method' => 'DELETE', 'uri' => '/tasks/1'], + ['method' => 'POST', 'uri' => '/auth/logout'], + ]; + + foreach ($protectedRoutes as $route) { + $response = match($route['method']) { + 'GET' => $this->apiGet($route['uri']), + 'POST' => $this->apiPost($route['uri']), + 'PUT' => $this->apiPut($route['uri']), + 'DELETE' => $this->apiDelete($route['uri']), + }; + + $response->assertStatus(401, + "Route {$route['method']} {$route['uri']} should require authentication" + ); + } + } + + public function test_authenticated_user_can_access_all_protected_routes(): void + { + $user = User::factory()->create(); + $executor = User::factory()->create(); + + $task = Task::factory()->create([ + 'creator_id' => $user->id, + 'executor_id' => $executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]); + + // GET /tasks + $response = $this->authenticatedApiGet('/tasks', $user); + $response->assertStatus(200); + + // POST /tasks + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Новая задача', + 'executor_id' => $executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + $response->assertStatus(201); + + // GET /tasks/{id} + $response = $this->authenticatedApiGet("/tasks/{$task->id}", $user); + $response->assertStatus(200); + + // PUT /tasks/{id} + $response = $this->authenticatedApiPut("/tasks/{$task->id}", [ + 'title' => 'Обновленная задача', + 'executor_id' => $executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + $response->assertStatus(200); + + // DELETE /tasks/{id} + $response = $this->authenticatedApiDelete("/tasks/{$task->id}", $user); + $response->assertStatus(200); + + // POST /auth/logout + $response = $this->authenticatedApiPost('/auth/logout', [], $user); + $response->assertStatus(200); + } + + public function test_public_auth_login_route_does_not_require_authentication(): void + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123') + ]); + + $response = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $response->assertStatus(200); + } + + public function test_token_based_authentication_works(): void + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123') + ]); + + // Получаем токен через логин + $loginResponse = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $loginResponse->assertStatus(200); + $token = $loginResponse->json('access_token'); + $this->assertNotEmpty($token); + + // Используем токен для доступа к защищенному маршруту + $response = $this->apiGet('/tasks', [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + } + + public function test_invalid_token_returns_401(): void + { + $response = $this->apiGet('/tasks', [ + 'Authorization' => 'Bearer invalid-token' + ]); + + $response->assertStatus(401); + } + + public function test_expired_or_revoked_token_returns_401(): void + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123') + ]); + + // Получаем токен + $loginResponse = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $token = $loginResponse->json('access_token'); + + // Выходим из системы (отзываем токен) + $logoutResponse = $this->apiPost('/auth/logout', [], [ + 'Authorization' => "Bearer {$token}" + ]); + + $logoutResponse->assertStatus(200); + + // Проверяем что logout прошел успешно + $logoutResponse->assertJson([ + 'message' => 'Успешный выход из системы' + ]); + } + + public function test_malformed_authorization_header_returns_401(): void + { + $malformedHeaders = [ + ['Authorization' => 'InvalidFormat token'], + ['Authorization' => 'Bearer'], + ['Authorization' => 'Bearer '], + ['Authorization' => ''], + ]; + + foreach ($malformedHeaders as $header) { + $response = $this->apiGet('/tasks', $header); + $response->assertStatus(401); + } + } + + public function test_passport_scope_authentication(): void + { + $user = User::factory()->create(); + + // Аутентификация через Passport + $this->authenticateUser($user); + + $response = $this->apiGet('/tasks'); + $response->assertStatus(200); + } + + public function test_multiple_simultaneous_sessions_are_supported(): void + { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password123') + ]); + + // Создаем два токена для одного пользователя + $response1 = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $response2 = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => 'password123' + ]); + + $token1 = $response1->json('access_token'); + $token2 = $response2->json('access_token'); + + $this->assertNotEquals($token1, $token2); + + // Оба токена должны работать + $response = $this->apiGet('/tasks', ['Authorization' => "Bearer {$token1}"]); + $response->assertStatus(200); + + $response = $this->apiGet('/tasks', ['Authorization' => "Bearer {$token2}"]); + $response->assertStatus(200); + } +} diff --git a/tests/Feature/Api/V1/TaskTest.php b/tests/Feature/Api/V1/TaskTest.php new file mode 100644 index 000000000..1a9984858 --- /dev/null +++ b/tests/Feature/Api/V1/TaskTest.php @@ -0,0 +1,347 @@ +category = Category::factory()->create(); + $this->priority = Priority::factory()->create(); + $this->executor = User::factory()->create(); + } + + public function test_authenticated_user_can_get_tasks_list(): void + { + $user = User::factory()->create(); + + // Создаем несколько задач + Task::factory()->count(3)->create([ + 'creator_id' => $user->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + ]); + + $response = $this->authenticatedApiGet('/tasks', $user); + + $response->assertStatus(200); + $this->assertJsonPaginationStructure($response); + + $data = $response->json('data'); + $this->assertCount(3, $data); + } + + public function test_unauthenticated_user_cannot_get_tasks_list(): void + { + $response = $this->apiGet('/tasks'); + + $response->assertStatus(401); + } + + public function test_authenticated_user_can_create_task(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'title' => 'Тестовая задача', + 'description' => 'Описание тестовой задачи', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => 'новая', + 'due_date' => now()->addDays(7)->format('Y-m-d') + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(201) + ->assertJson([ + 'message' => 'Задача успешно создана' + ]); + + $this->assertDatabaseHas('tasks', [ + 'title' => 'Тестовая задача', + 'creator_id' => $user->id, + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => 'новая' + ]); + } + + public function test_create_task_requires_title(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'description' => 'Описание без заголовка', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['title']); + } + + public function test_create_task_requires_valid_executor_id(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'title' => 'Тестовая задача', + 'executor_id' => 99999, // Несуществующий пользователь + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['executor_id']); + } + + public function test_create_task_requires_valid_category_id(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => 99999, // Несуществующая категория + 'priority_id' => $this->priority->id, + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['category_id']); + } + + public function test_create_task_requires_valid_priority_id(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => 99999, // Несуществующий приоритет + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['priority_id']); + } + + public function test_create_task_validates_status(): void + { + $user = User::factory()->create(); + + $taskData = [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => 'неверный статус' + ]; + + $response = $this->authenticatedApiPost('/tasks', $taskData, $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['status']); + } + + public function test_authenticated_user_can_get_specific_task(): void + { + $user = User::factory()->create(); + + $task = Task::factory()->create([ + 'creator_id' => $user->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + ]); + + $response = $this->authenticatedApiGet("/tasks/{$task->id}", $user); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'title', + 'description', + 'status', + 'createdAt', + 'updatedAt' + ] + ]); + + $this->assertEquals($task->id, $response->json('data.id')); + } + + public function test_get_nonexistent_task_returns_404(): void + { + $user = User::factory()->create(); + + $response = $this->authenticatedApiGet('/tasks/99999', $user); + + $response->assertStatus(404) + ->assertJson([ + 'message' => 'Задача не найдена' + ]); + } + + public function test_authenticated_user_can_update_task(): void + { + $user = User::factory()->create(); + + $task = Task::factory()->create([ + 'creator_id' => $user->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + 'title' => 'Старый заголовок' + ]); + + $updateData = [ + 'title' => 'Обновленный заголовок', + 'description' => 'Обновленное описание', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => 'в работе' + ]; + + $response = $this->authenticatedApiPut("/tasks/{$task->id}", $updateData, $user); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'message', + 'data' + ]); + + $this->assertDatabaseHas('tasks', [ + 'id' => $task->id, + 'title' => 'Обновленный заголовок', + 'status' => 'в работе' + ]); + } + + public function test_update_nonexistent_task_returns_404(): void + { + $user = User::factory()->create(); + + $updateData = [ + 'title' => 'Обновленный заголовок', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]; + + $response = $this->authenticatedApiPut('/tasks/99999', $updateData, $user); + + $response->assertStatus(404) + ->assertJson([ + 'message' => 'Задача не найдена' + ]); + } + + public function test_authenticated_user_can_delete_task(): void + { + $user = User::factory()->create(); + + $task = Task::factory()->create([ + 'creator_id' => $user->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + ]); + + $response = $this->authenticatedApiDelete("/tasks/{$task->id}", $user); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Задача успешно удалена' + ]); + + $this->assertDatabaseMissing('tasks', [ + 'id' => $task->id + ]); + } + + public function test_delete_nonexistent_task_returns_404(): void + { + $user = User::factory()->create(); + + $response = $this->authenticatedApiDelete('/tasks/99999', $user); + + $response->assertStatus(404) + ->assertJson([ + 'message' => 'Задача не найдена' + ]); + } + + public function test_unauthenticated_user_cannot_create_task(): void + { + $taskData = [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]; + + $response = $this->apiPost('/tasks', $taskData); + + $response->assertStatus(401); + } + + public function test_unauthenticated_user_cannot_update_task(): void + { + $task = Task::factory()->create([ + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + ]); + + $updateData = [ + 'title' => 'Обновленный заголовок', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ]; + + $response = $this->apiPut("/tasks/{$task->id}", $updateData); + + $response->assertStatus(401); + } + + public function test_unauthenticated_user_cannot_delete_task(): void + { + $task = Task::factory()->create([ + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'executor_id' => $this->executor->id, + ]); + + $response = $this->apiDelete("/tasks/{$task->id}"); + + $response->assertStatus(401); + } +} diff --git a/tests/Feature/Api/V1/ValidationTest.php b/tests/Feature/Api/V1/ValidationTest.php new file mode 100644 index 000000000..7d3c6b194 --- /dev/null +++ b/tests/Feature/Api/V1/ValidationTest.php @@ -0,0 +1,265 @@ +category = Category::factory()->create(); + $this->priority = Priority::factory()->create(); + $this->executor = User::factory()->create(); + } + + public function test_login_validation_rules(): void + { + // Пустые данные + $response = $this->apiPost('/auth/login', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email', 'password']); + + // Неверный формат email + $response = $this->apiPost('/auth/login', [ + 'email' => 'invalid-email', + 'password' => 'password123' + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + // Слишком короткий пароль + $response = $this->apiPost('/auth/login', [ + 'email' => 'test@example.com', + 'password' => '123' + ]); + + $response->assertStatus(401); + } + + public function test_create_task_validation_rules(): void + { + $user = User::factory()->create(); + + // Пустые обязательные поля + $response = $this->authenticatedApiPost('/tasks', [], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'title', + 'executor_id', + 'category_id', + 'priority_id' + ]); + + // Слишком длинный заголовок + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => str_repeat('a', 256), // Превышаем лимит + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['title']); + + // Несуществующий executor_id + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => 99999, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['executor_id']); + + // Несуществующий category_id + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => 99999, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['category_id']); + + // Несуществующий priority_id + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => 99999, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['priority_id']); + + // Неверный статус + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => 'неверный_статус' + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['status']); + + // Неверная дата + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'due_date' => 'не-дата' + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['due_date']); + + // Дата в прошлом + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'due_date' => '2020-01-01' + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['due_date']); + } + + public function test_update_task_validation_rules(): void + { + $user = User::factory()->create(); + + // Обновление с валидными необязательными полями должно работать + $response = $this->authenticatedApiPut('/tasks/99999', [ + 'title' => 'Обновленный заголовок' + ], $user); + + // Ожидаем 404 для несуществующей задачи, но не ошибки валидации + $response->assertStatus(404); + + // Слишком длинный заголовок + $response = $this->authenticatedApiPut('/tasks/99999', [ + 'title' => str_repeat('a', 256) + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['title']); + + // Несуществующий executor_id + $response = $this->authenticatedApiPut('/tasks/99999', [ + 'executor_id' => 99999 + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['executor_id']); + + // Неверный статус + $response = $this->authenticatedApiPut('/tasks/99999', [ + 'status' => 'неверный_статус' + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['status']); + } + + public function test_valid_status_values_are_accepted(): void + { + $user = User::factory()->create(); + + $validStatuses = ['новая', 'в работе', 'выполнена', 'отменена']; + + foreach ($validStatuses as $status) { + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + 'status' => $status + ], $user); + + // Ожидаем успешное создание для всех валидных статусов + $response->assertStatus(201); + } + } + + public function test_title_is_required_but_description_is_optional(): void + { + $user = User::factory()->create(); + + // Без описания должно работать + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Задача без описания', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(201); + + // Без заголовка не должно работать + $response = $this->authenticatedApiPost('/tasks', [ + 'description' => 'Описание без заголовка', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['title']); + } + + public function test_numeric_fields_validation(): void + { + $user = User::factory()->create(); + + // Строки вместо чисел + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => 2, + 'category_id' => $this->category->id, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['executor_id']); + + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => 12, + 'priority_id' => $this->priority->id, + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['category_id']); + + $response = $this->authenticatedApiPost('/tasks', [ + 'title' => 'Тестовая задача', + 'executor_id' => $this->executor->id, + 'category_id' => $this->category->id, + 'priority_id' => 'не-число', + ], $user); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['priority_id']); + } +} diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index 13dcb7ce7..000000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,54 +0,0 @@ -get('/login'); - - $response->assertStatus(200); - } - - public function test_users_can_authenticate_using_the_login_screen(): void - { - $user = User::factory()->create(); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - } - - public function test_users_can_not_authenticate_with_invalid_password(): void - { - $user = User::factory()->create(); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $this->assertGuest(); - } - - public function test_users_can_logout(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/logout'); - - $this->assertGuest(); - $response->assertRedirect('/'); - } -} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 705570b43..000000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,58 +0,0 @@ -unverified()->create(); - - $response = $this->actingAs($user)->get('/verify-email'); - - $response->assertStatus(200); - } - - public function test_email_can_be_verified(): void - { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - $this->assertTrue($user->fresh()->hasVerifiedEmail()); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); - } - - public function test_email_is_not_verified_with_invalid_hash(): void - { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - $this->assertFalse($user->fresh()->hasVerifiedEmail()); - } -} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index ff85721e2..000000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,44 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get('/confirm-password'); - - $response->assertStatus(200); - } - - public function test_password_can_be_confirmed(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'password', - ]); - - $response->assertRedirect(); - $response->assertSessionHasNoErrors(); - } - - public function test_password_is_not_confirmed_with_invalid_password(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrors(); - } -} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index aa5035058..000000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,73 +0,0 @@ -get('/forgot-password'); - - $response->assertStatus(200); - } - - public function test_reset_password_link_can_be_requested(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); - } - - public function test_reset_password_screen_can_be_rendered(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get('/reset-password/'.$notification->token); - - $response->assertStatus(200); - - return true; - }); - } - - public function test_password_can_be_reset_with_valid_token(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post('/reset-password', [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login')); - - return true; - }); - } -} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php deleted file mode 100644 index ca28c6c6e..000000000 --- a/tests/Feature/Auth/PasswordUpdateTest.php +++ /dev/null @@ -1,51 +0,0 @@ -create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->put('/password', [ - 'current_password' => 'password', - 'password' => 'new-password', - 'password_confirmation' => 'new-password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); - } - - public function test_correct_password_must_be_provided_to_update_password(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->put('/password', [ - 'current_password' => 'wrong-password', - 'password' => 'new-password', - 'password_confirmation' => 'new-password', - ]); - - $response - ->assertSessionHasErrorsIn('updatePassword', 'current_password') - ->assertRedirect('/profile'); - } -} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index 1489d0e0f..000000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,31 +0,0 @@ -get('/register'); - - $response->assertStatus(200); - } - - public function test_new_users_can_register(): void - { - $response = $this->post('/register', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - } -} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 9da58f630..000000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} \ No newline at end of file diff --git a/tests/Feature/NewsControllerTest.php b/tests/Feature/NewsControllerTest.php deleted file mode 100644 index e9e89d8d8..000000000 --- a/tests/Feature/NewsControllerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -setUpTheTestEnvironment(); - $this->indexUrl = route('news.index'); - $this->createUrl = route('news.create'); - } - /** - * A basic feature test example. - */ - public function test_news_are_accessable(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->get($this->indexUrl); - - $response->assertStatus(200); - } - - public function test_news_content() - { - $news = News::factory()->create(); - $expectedPostJson = [ - 'name' => $news->name, - 'preview'=> $news->preview, - 'text' => $news->text, - 'link'=> $news->link, - 'user_id'=>$news->user_id, - 'photo'=> $news->photo, - 'create_at' => $news->create_at - ]; - - $response = $this->get('/e/one-all'); - $response->assertOk(); - $response->assertJsonFragment($expectedPostJson); - } - - public function test_create_news_stored_in_db() - { - $news = News::factory()->make(); - $user = User::factory()->create(); - - $payload = [ - "name" => $news->name, - "text" => $news->text, - "preview" => $news->preview, - "link"=>$news->link, - "photo" => $news->photo, - "user_id" => $news->user_id - ]; - - $response = $this->actingAs($user)->post($this->createUrl, $payload); - dd($response->json()); - $id = $response->json()['id']; - $this->assertNotNull(News::find($id), 'News is not found'); - } -} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php deleted file mode 100644 index 252fdcc52..000000000 --- a/tests/Feature/ProfileTest.php +++ /dev/null @@ -1,99 +0,0 @@ -create(); - - $response = $this - ->actingAs($user) - ->get('/profile'); - - $response->assertOk(); - } - - public function test_profile_information_can_be_updated(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $user->refresh(); - - $this->assertSame('Test User', $user->name); - $this->assertSame('test@example.com', $user->email); - $this->assertNull($user->email_verified_at); - } - - public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', - 'email' => $user->email, - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $this->assertNotNull($user->refresh()->email_verified_at); - } - - public function test_user_can_delete_their_account(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->delete('/profile', [ - 'password' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/'); - - $this->assertGuest(); - $this->assertNull($user->fresh()); - } - - public function test_correct_password_must_be_provided_to_delete_account(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->delete('/profile', [ - 'password' => 'wrong-password', - ]); - - $response - ->assertSessionHasErrorsIn('userDeletion', 'password') - ->assertRedirect('/profile'); - - $this->assertNotNull($user->fresh()); - } -} diff --git a/tests/Feature/WithdrawControllerTest.php b/tests/Feature/WithdrawControllerTest.php deleted file mode 100644 index 965cb531c..000000000 --- a/tests/Feature/WithdrawControllerTest.php +++ /dev/null @@ -1,27 +0,0 @@ -mock(WithdrawService::class, function (MockInterface $withdrawServiceMock) { - $withdrawServiceMock->expects('withdraw')->with(1, 100)->andReturn(777); - }); - - $response = $this->get(route('withdraw')); - dd($response->assertJson(['ok' => true])); - $response->assertStatus(200); - $response->assertJson(['ok' => true]); - } -} diff --git a/tests/Unit/CalcServiceTest.php b/tests/Unit/CalcServiceTest.php deleted file mode 100644 index 4d593f44a..000000000 --- a/tests/Unit/CalcServiceTest.php +++ /dev/null @@ -1,42 +0,0 @@ -sum($a, $b); - - // Assert - $this->assertEquals($expected, $result); - } - - public function test_sub(): void - { - // Arrange - $calc = new CalcService(); - $a = 1; - $b = 3; - $expected = -2; - - // Act - $result = $calc->sub($a, $b); - - // Assert - $this->assertEquals($expected, $result); - } -} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0ceb..000000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/Users/Commands/CreateUserTest.php b/tests/Unit/Users/Commands/CreateUserTest.php new file mode 100644 index 000000000..aa82dd25b --- /dev/null +++ b/tests/Unit/Users/Commands/CreateUserTest.php @@ -0,0 +1,156 @@ +shouldReceive('hash')->with('password')->andReturn('hashed_password'); + + // Говорим мок репозиторию, что пользователя с таким email нет + $repository->shouldReceive('existsByEmail')->with('test@example.io')->andReturn(false); + + $repository->shouldReceive('save')->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + name: 'Иванов Иван Иванович', + email: 'test@example.io', + isAdmin: false, + password: 'password', + ); + + $result = $handler->handle($command); + + $this->assertTrue($result); + } + + public function test_can_create_admin_user(){ + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + $passwordHasher->shouldReceive('hash')->with('password')->andReturn('hashed_password'); + + $repository->shouldReceive('existsByEmail')->with('admin@example.io')->andReturn(false); + $repository->shouldReceive('save')->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + // Тестируем создание админа с isAdmin: true + $command = new Command( + name: 'Админ Сайта', + email: 'admin@example.io', + isAdmin: true, + password: 'password', + ); + + $result = $handler->handle($command); + + $this->assertTrue($result); + } + + public function test_can_create_user_with_default_parameters(){ + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + $passwordHasher->shouldReceive('hash')->with('password')->andReturn('hashed_password'); + + $repository->shouldReceive('existsByEmail')->with('default@example.io')->andReturn(false); + $repository->shouldReceive('save')->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + // Тестируем создание пользователя с дефолтными параметрами + $command = new Command( + name: 'Тестовый Пользователь', + email: 'default@example.io', + password: 'password', + ); + + $result = $handler->handle($command); + + $this->assertTrue($result); + } + + public function test_can_create_user_without_password(){ + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + // Пароль null, поэтому hash не должен вызываться + $passwordHasher->shouldNotReceive('hash'); + + $repository->shouldReceive('existsByEmail')->with('nopass@example.io')->andReturn(false); + $repository->shouldReceive('save')->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + // Тестируем создание пользователя без пароля (password = null по умолчанию) + $command = new Command( + name: 'Без Пароля', + email: 'nopass@example.io' + ); + + $result = $handler->handle($command); + + $this->assertTrue($result); + } + + public function test_throws_exception_when_email_already_exists(){ + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + + // Email уже существует + $repository->shouldReceive('existsByEmail')->with('existing@example.io')->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + name: 'Дублированный Пользователь', + email: 'existing@example.io', + password: 'password', + ); + + $this->expectException(UserEmailAlreadyExistsException::class); + $this->expectExceptionMessage('existing@example.io'); + + $handler->handle($command); + } + + public function test_throws_exception_when_save_fails(){ + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + $passwordHasher->shouldReceive('hash')->with('password')->andReturn('hashed_password'); + + $repository->shouldReceive('existsByEmail')->with('failsave@example.io')->andReturn(false); + // Сохранение не удалось + $repository->shouldReceive('save')->andReturn(false); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + name: 'Неудачное Сохранение', + email: 'failsave@example.io', + password: 'password', + ); + + $this->expectException(UserSaveException::class); + $this->expectExceptionMessage("Не удалось сохранить пользователя 'Неудачное Сохранение'"); + + $handler->handle($command); + } + +} diff --git a/tests/Unit/Users/Commands/DeleteUserTest.php b/tests/Unit/Users/Commands/DeleteUserTest.php new file mode 100644 index 000000000..052a8b126 --- /dev/null +++ b/tests/Unit/Users/Commands/DeleteUserTest.php @@ -0,0 +1,95 @@ +id = 1; + $user->name = 'Пользователь для удаления'; + $user->email = 'delete@example.com'; + $user->is_admin = false; + + $repository->shouldReceive('find')->with(1)->andReturn($user); + $repository->shouldReceive('delete')->with($user)->andReturn(true); + + $handler = new Handler($repository); + + $command = new Command(id: 1); + + $result = $handler->handle($command); + + $this->assertTrue($result); + } + + public function test_throws_exception_when_user_not_found() + { + $repository = Mockery::mock(UserRepositoryInterface::class); + + // Пользователь не найден + $repository->shouldReceive('find')->with(999)->andReturn(null); + + $handler = new Handler($repository); + + $command = new Command(id: 999); + + $this->expectException(UserNotFoundException::class); + $this->expectExceptionMessage('Пользователь не найден'); + + $handler->handle($command); + } + + public function test_returns_false_when_delete_fails() + { + $repository = Mockery::mock(UserRepositoryInterface::class); + + $user = new User(); + $user->id = 2; + $user->name = 'Пользователь с ошибкой удаления'; + $user->email = 'faildelete@example.com'; + $user->is_admin = false; + + $repository->shouldReceive('find')->with(2)->andReturn($user); + $repository->shouldReceive('delete')->with($user)->andReturn(false); + + $handler = new Handler($repository); + + $command = new Command(id: 2); + + $result = $handler->handle($command); + + $this->assertFalse($result); + } + + public function test_creates_command_with_id() + { + $command = new Command(id: 42); + + $this->assertEquals(42, $command->id); + } + + public function test_creates_command_with_different_ids() + { + $command1 = new Command(id: 1); + $command2 = new Command(id: 999); + $command3 = new Command(id: 123456); + + $this->assertEquals(1, $command1->id); + $this->assertEquals(999, $command2->id); + $this->assertEquals(123456, $command3->id); + } +} diff --git a/tests/Unit/Users/Commands/UpdateUserTest.php b/tests/Unit/Users/Commands/UpdateUserTest.php new file mode 100644 index 000000000..4663ffaef --- /dev/null +++ b/tests/Unit/Users/Commands/UpdateUserTest.php @@ -0,0 +1,116 @@ +id = 1; + $user->name = 'Старое Имя'; + $user->email = 'old@example.com'; + $user->is_admin = false; + $user->created_at = Carbon::now(); + $user->updated_at = Carbon::now(); + $user->email_verified_at = null; + + $repository->shouldReceive('find')->with(1)->andReturn($user); + $passwordHasher->shouldReceive('hash')->with('newpassword')->andReturn('hashed_newpassword'); + $repository->shouldReceive('save')->with($user)->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + id: 1, + name: 'Новое Имя', + email: 'new@example.com', + isAdmin: true, + password: 'newpassword' + ); + + $result = $handler->handle($command); + + $this->assertInstanceOf(UserDTO::class, $result); + $this->assertEquals(1, $result->id); + $this->assertEquals('Новое Имя', $result->name); + $this->assertEquals('new@example.com', $result->email); + $this->assertTrue($result->isAdmin); + } + + public function test_can_update_user_without_password() + { + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + + $user = new User(); + $user->id = 2; + $user->name = 'Старое Имя'; + $user->email = 'old@example.com'; + $user->is_admin = true; + $user->created_at = Carbon::now(); + $user->updated_at = Carbon::now(); + $user->email_verified_at = Carbon::now(); + + $repository->shouldReceive('find')->with(2)->andReturn($user); + // Пароль не передан, поэтому hash не должен вызываться + $passwordHasher->shouldNotReceive('hash'); + $repository->shouldReceive('save')->with($user)->andReturn(true); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + id: 2, + name: 'Обновленное Имя', + email: 'updated@example.com', + isAdmin: false + // password не указан - null по умолчанию + ); + + $result = $handler->handle($command); + + $this->assertInstanceOf(UserDTO::class, $result); + $this->assertEquals(2, $result->id); + $this->assertEquals('Обновленное Имя', $result->name); + $this->assertEquals('updated@example.com', $result->email); + $this->assertFalse($result->isAdmin); + } + + public function test_throws_exception_when_user_not_found() + { + $repository = Mockery::mock(UserRepositoryInterface::class); + $passwordHasher = Mockery::mock(PasswordHasherInterface::class); + + // Пользователь не найден + $repository->shouldReceive('find')->with(999)->andReturn(null); + + $handler = new Handler($repository, $passwordHasher); + + $command = new Command( + id: 999, + name: 'Несуществующий', + email: 'notfound@example.com' + ); + + $this->expectException(UserNotFoundException::class); + $this->expectExceptionMessage('Пользователь не найден'); + + $handler->handle($command); + } +}