diff --git a/README.md b/README.md index fa3ce51..69d2d5b 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,12 @@ The project includes a lightweight production deployment workflow: --- +## 🩺 Health Check + +A lightweight endpoint used for uptime monitoring and deployment verification: + +`GET /api/health → { "status": "ok", "timestamp": "..." }` + ## 🔄 Message Pipeline (RabbitMQ) This project implements a production-grade message pipeline: diff --git a/app/DTOs/RegisterDTO.php b/app/DTOs/RegisterDTO.php new file mode 100644 index 0000000..ca2303f --- /dev/null +++ b/app/DTOs/RegisterDTO.php @@ -0,0 +1,28 @@ +toDTO(); + $data = $service->register($dto); + + return response()->json($data, 201); + } + public function login(Request $request): JsonResponse { /** @var JWTGuard $guard */ @@ -56,11 +67,11 @@ public function logout(): Response return response()->noContent(); } - public function me(): JsonResponse + public function me(): UserResource { /** @var JWTGuard $guard */ $guard = auth('api'); - return response()->json($guard->user()); + return UserResource::make($guard->user()); } } diff --git a/app/Http/Controllers/HealthController.php b/app/Http/Controllers/HealthController.php new file mode 100644 index 0000000..238a14b --- /dev/null +++ b/app/Http/Controllers/HealthController.php @@ -0,0 +1,16 @@ +json([ + 'status' => 'ok', + 'timestamp' => now()->toISOString(), + ]); + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..6325c4f --- /dev/null +++ b/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:80'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8'], + 'password_confirmation' => ['required', 'same:password'], + ]; + } + + public function toDTO(): RegisterDTO + { + return RegisterDTO::fromArray($this->validated()); + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..9331fcb --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'avatar_url' => $this->avatar_url, + ]; + } +} diff --git a/app/Http/Services/AuthService.php b/app/Http/Services/AuthService.php new file mode 100644 index 0000000..ce93d31 --- /dev/null +++ b/app/Http/Services/AuthService.php @@ -0,0 +1,35 @@ + + */ + public function register(RegisterDTO $dto): array + { + $user = User::create([ + 'name' => $dto->name, + 'email' => $dto->email, + 'password' => Hash::make($dto->password), + ]); + + /** @var JWTGuard $guard */ + $guard = auth('api'); + $token = $guard->login($user); + + return [ + 'token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => (int) config('jwt.ttl', 60) * 60, + 'user' => UserResource::make($guard->user()), + ]; + } +} diff --git a/docs/api-examples.md b/docs/api-examples.md index a4aeb70..03fe6ce 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -6,7 +6,20 @@ This document contains practical examples showing how to interact with the Task ## Authentication +### Register + +```bash +POST /api/auth/register +{ + "name": "Demo", + "email": "demo@example.com", + "password": "secret123", + "password_confirmation": "secret123" +} +``` + ### Login + ```bash POST /api/auth/login { diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 8bb6a48..bd0aa58 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -41,6 +41,8 @@ tags: description: Add comments to tasks. - name: Task Labels description: Attach/detach labels to tasks. + - name: System + description: Lightweight system endpoints for health and uptime checks. security: - bearerAuth: [] # default: all endpoints require JWT unless overridden per-path @@ -215,6 +217,43 @@ components: required: [data] paths: + /api/auth/register: + post: + operationId: auth_register + tags: [Auth] + security: [] + summary: Register a new user + description: Create a new user account and automatically return a JWT access token. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, example: John Doe } + email: { type: string, format: email, example: john@example.com } + password: { type: string, format: password, minLength: 8, example: password123 } + password_confirmation: { type: string, format: password, minLength: 8, example: password123 } + required: [ name, email, password, password_confirmation ] + responses: + '201': + description: User registered successfully + content: + application/json: + schema: + type: object + properties: + token: { type: string } + token_type: { type: string, example: Bearer } + expires_in: { type: integer, example: 3600 } + user: { $ref: '#/components/schemas/User' } + '422': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + /api/auth/login: post: operationId: auth_login @@ -265,7 +304,11 @@ paths: description: OK content: application/json: - schema: { $ref: '#/components/schemas/User' } + schema: + type: object + properties: + data: + $ref: '#/components/schemas/User' '401': description: Unauthorized @@ -815,3 +858,21 @@ paths: '403': { description: Forbidden } '404': { description: Not Found } '409': { description: Conflict (cross-project) } + + /api/health: + get: + operationId: health_check + tags: [System] + security: [] + summary: Health check endpoint + description: Lightweight health probe used by load balancers, uptime monitoring and deployment checks. Does not touch external dependencies and always returns quickly. + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: ok } + timestamp: { type: string, format: date-time, example: 2025-12-08T12:34:56Z } diff --git a/docs/postman/task_manager_api.postman_collection.json b/docs/postman/task_manager_api.postman_collection.json index 93f7225..2e5cb0f 100644 --- a/docs/postman/task_manager_api.postman_collection.json +++ b/docs/postman/task_manager_api.postman_collection.json @@ -17,6 +17,34 @@ { "name": "Auth", "item": [ + { + "name": "Register", + "request": { + "method": "POST", + "header": [ + { "key": "Accept", "value": "application/json" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"{{$randomFullName}}\",\n \"email\": \"{{$randomEmail}}\",\n \"password\": \"secret123\",\n \"password_confirmation\": \"secret123\"\n}" + }, + "url": "{{base_url}}/api/auth/register" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status 201', () => pm.response.to.have.status(201));", + "const json = pm.response.json();", + "pm.expect(json).to.have.property('token');", + "pm.environment.set('token', json.token);" + ] + } + } + ] + }, { "name": "Login", "request": { @@ -483,6 +511,20 @@ } } ] + }, + { + "name": "System", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/api/health" + }, + "response": [] + } + ] } ] } diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 8bb6a48..bd0aa58 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -41,6 +41,8 @@ tags: description: Add comments to tasks. - name: Task Labels description: Attach/detach labels to tasks. + - name: System + description: Lightweight system endpoints for health and uptime checks. security: - bearerAuth: [] # default: all endpoints require JWT unless overridden per-path @@ -215,6 +217,43 @@ components: required: [data] paths: + /api/auth/register: + post: + operationId: auth_register + tags: [Auth] + security: [] + summary: Register a new user + description: Create a new user account and automatically return a JWT access token. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, example: John Doe } + email: { type: string, format: email, example: john@example.com } + password: { type: string, format: password, minLength: 8, example: password123 } + password_confirmation: { type: string, format: password, minLength: 8, example: password123 } + required: [ name, email, password, password_confirmation ] + responses: + '201': + description: User registered successfully + content: + application/json: + schema: + type: object + properties: + token: { type: string } + token_type: { type: string, example: Bearer } + expires_in: { type: integer, example: 3600 } + user: { $ref: '#/components/schemas/User' } + '422': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + /api/auth/login: post: operationId: auth_login @@ -265,7 +304,11 @@ paths: description: OK content: application/json: - schema: { $ref: '#/components/schemas/User' } + schema: + type: object + properties: + data: + $ref: '#/components/schemas/User' '401': description: Unauthorized @@ -815,3 +858,21 @@ paths: '403': { description: Forbidden } '404': { description: Not Found } '409': { description: Conflict (cross-project) } + + /api/health: + get: + operationId: health_check + tags: [System] + security: [] + summary: Health check endpoint + description: Lightweight health probe used by load balancers, uptime monitoring and deployment checks. Does not touch external dependencies and always returns quickly. + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: ok } + timestamp: { type: string, format: date-time, example: 2025-12-08T12:34:56Z } diff --git a/routes/api.php b/routes/api.php index 2493f8b..7454947 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { Route::scopeBindings()->group(function () { + Route::post('/auth/register', [AuthController::class, 'register'])->withoutMiddleware('jwt.auth'); Route::post('/auth/login', [AuthController::class, 'login'])->withoutMiddleware('jwt.auth'); Route::post('/auth/refresh', [AuthController::class, 'refresh']); Route::post('/auth/logout', [AuthController::class, 'logout']); diff --git a/tests/Feature/Api/AuthApiTest.php b/tests/Feature/Api/AuthApiTest.php index 8804369..98405f7 100644 --- a/tests/Feature/Api/AuthApiTest.php +++ b/tests/Feature/Api/AuthApiTest.php @@ -2,12 +2,76 @@ use App\Models\User; +use Illuminate\Support\Facades\Hash; +use Illuminate\Testing\Fluent\AssertableJson; + +use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\getJson; use function Pest\Laravel\postJson; use function Pest\Laravel\withHeaders; use PHPOpenSourceSaver\JWTAuth\JWTGuard; +it('registers a new user and returns token + user resource', function () { + $payload = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'secret123', + 'password_confirmation' => 'secret123', + ]; + + $response = postJson('/api/auth/register', $payload); + + $response->assertCreated(); + + $response->assertJson(fn (AssertableJson $json) => $json->has('token') + ->where('token_type', 'Bearer') + ->has('expires_in') + ->has('user', fn ($json) => $json->where('name', 'John Doe') + ->where('email', 'john@example.com') + ->etc() + ) + ); + + assertDatabaseHas('users', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $user = User::firstWhere('email', 'john@example.com'); + expect(Hash::check('secret123', $user->password))->toBeTrue(); +}); + +it('fails if email already exists', function () { + User::factory()->create(['email' => 'dup@example.com']); + + $payload = [ + 'name' => 'Test', + 'email' => 'dup@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = postJson('/api/auth/register', $payload); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['email']); +}); + +it('fails when validation rules are not met', function () { + $payload = [ + 'name' => '', + 'email' => 'not-an-email', + 'password' => '123', + 'password_confirmation' => '456', + ]; + + $response = postJson('/api/auth/register', $payload); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'email', 'password']); +}); + it('login works and returns bearer token', function () { $pwd = 'secret123'; $user = User::factory()->create(['password' => bcrypt($pwd)]); @@ -40,7 +104,7 @@ requestAs($user, 'GET', '/api/auth/me') ->assertOk() - ->assertJson(['id' => $user->id]); + ->assertJsonPath('data.id', $user->id); requestAs($user, 'POST', '/api/auth/refresh') ->assertOk() diff --git a/tests/Feature/Api/HealthCheckTest.php b/tests/Feature/Api/HealthCheckTest.php new file mode 100644 index 0000000..29efb9d --- /dev/null +++ b/tests/Feature/Api/HealthCheckTest.php @@ -0,0 +1,14 @@ +assertOk() + ->assertJson(fn (AssertableJson $json) => $json->where('status', 'ok') + ->has('timestamp') + ); +});