Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
79aed51
test(users): add GetCurrentUserQueryHandler tests
evans-costa Mar 10, 2026
8258440
test(users): update CreateUserCommandHandler tests for token response
evans-costa Mar 10, 2026
5261340
feat(application): add GetCurrentUserQuery
evans-costa Mar 10, 2026
ecd2db4
feat(application): add GetCurrentUserQueryHandler
evans-costa Mar 10, 2026
5e471a9
feat(api): add GET /auth/me endpoint
evans-costa Mar 10, 2026
08cd01c
feat(application): add CreateUserResponse with Id and Token
evans-costa Mar 10, 2026
5b56d86
feat(application): return token in CreateUserCommandHandler
evans-costa Mar 10, 2026
1fc1758
feat(api): return token in POST /api/users response
evans-costa Mar 10, 2026
e58553c
docs: document GET /auth/me and token in POST /api/users
evans-costa Mar 10, 2026
cb9f8b0
feat(api): allow anonymous on POST /api/users
evans-costa Mar 10, 2026
3ba7767
feat(api): allow anonymous on POST /auth/login, remove redundant [Aut…
evans-costa Mar 10, 2026
7c589a2
feat(api): require authorization globally on all routes
evans-costa Mar 10, 2026
96bac47
test(users): remove GetUserQueryHandler tests
evans-costa Mar 10, 2026
42108b7
refactor(api): remove GET /api/users/{id} endpoint
evans-costa Mar 10, 2026
c7bbb46
refactor(application): remove GetUserQueryHandler
evans-costa Mar 10, 2026
1be82a7
refactor(application): remove GetUserQuery
evans-costa Mar 10, 2026
47339d7
feat(mappings): add AuthMappingExtensions
evans-costa Mar 10, 2026
bbd0245
refactor(api): use AuthMappingExtensions in AuthController
evans-costa Mar 10, 2026
b24d1ae
feat(mappings): add UserMappingExtensions
evans-costa Mar 10, 2026
42db321
refactor(api): use UserMappingExtensions in UsersController
evans-costa Mar 10, 2026
1e61db7
refactor(application): use UserMappingExtensions in CreateUserCommand…
evans-costa Mar 10, 2026
9542c9f
refactor(application): use UserMappingExtensions in GetCurrentUserQue…
evans-costa Mar 10, 2026
b056781
refactor(application): move GetUserResponse to GetCurrentUser namespace
evans-costa Mar 10, 2026
7d036d7
refactor(tests): update GetCurrentUserQueryHandlerTests namespace import
evans-costa Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,25 @@ Autentica um usuário com e-mail e senha e retorna um JWT token.

---

#### `GET /auth/me` — Usuário autenticado

Retorna os dados do usuário atualmente autenticado, identificado pelo JWT enviado no cabeçalho `Authorization`.

**Headers obrigatórios:**
```
Authorization: Bearer <token>
```

**Respostas:**

| Status | Descrição |
|---|---|
| `200 OK` | `{ "name": "João Silva", "email": "joao@example.com" }` |
| `401 Unauthorized` | Token ausente ou inválido |
| `404 Not Found` | Usuário do token não existe mais no banco |

---

### Usuários

#### `POST /api/users` — Criar usuário
Expand Down Expand Up @@ -347,7 +366,7 @@ Cria um novo usuário na plataforma.

| Status | Descrição |
|---|---|
| `201 Created` | `{ "id": "<guid>" }` |
| `201 Created` | `{ "id": "<guid>", "token": "<JWT>" }` — retorna token para auto-login |
| `400 Bad Request` | Erro de validação (campos inválidos) |
| `409 Conflict` | E-mail ou CPF/CNPJ já cadastrado |

Expand Down
21 changes: 20 additions & 1 deletion src/Voltiq.API/Controllers/Auth/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Voltiq.Application.Features.Auth.Commands.Login;
using Voltiq.Application.Features.Users.Queries.GetCurrentUser;
using Voltiq.Application.Mappings.Auth;

namespace Voltiq.API.Controllers.Auth;

Expand All @@ -10,6 +13,7 @@ public sealed class AuthController : BaseApiController
/// <response code="200">Authentication successful.</response>
/// <response code="400">Validation error.</response>
/// <response code="401">Invalid credentials.</response>
[AllowAnonymous]
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand All @@ -18,9 +22,24 @@ public async Task<IActionResult> Login(
[FromBody] LoginRequest request,
CancellationToken cancellationToken)
{
var command = new LoginCommand(request.Email, request.Password);
var command = request.ToCommand();
var result = await Sender.Send(command, cancellationToken);

return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
}

/// <summary>Returns the currently authenticated user.</summary>
/// <response code="200">Current user data.</response>
/// <response code="401">Token missing or invalid.</response>
/// <response code="404">User no longer exists.</response>
[HttpGet("me")]
[ProducesResponseType(typeof(GetUserResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
{
var result = await Sender.Send(new GetCurrentUserQuery(), cancellationToken);

return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
}
}
27 changes: 7 additions & 20 deletions src/Voltiq.API/Controllers/Users/UsersController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Voltiq.Application.Features.Users.Commands.CreateUser;
using Voltiq.Application.Features.Users.Queries.GetUser;
using Voltiq.Application.Mappings.Users;

namespace Voltiq.API.Controllers.Users;

Expand All @@ -10,6 +11,7 @@ public sealed class UsersController : BaseApiController
/// <response code="201">User created successfully.</response>
/// <response code="400">Validation error.</response>
/// <response code="409">Email and/or document already in use.</response>
[AllowAnonymous]
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand All @@ -18,26 +20,11 @@ public async Task<IActionResult> Create(
[FromBody] CreateUserRequest request,
CancellationToken cancellationToken)
{
var command = new CreateUserCommand(request.Name, request.Email, request.Document, request.Password);
var command = request.ToCommand();
var result = await Sender.Send(command, cancellationToken);

return result.IsFailure ?
ToErrorResult(result) :
CreatedAtAction(nameof(Create), new { id = result.Value }, new { id = result.Value });
}

/// <summary>Gets a user by ID.</summary>
/// <response code="200">User found.</response>
/// <response code="404">User not found.</response>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(GetUserResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(
[FromRoute] Guid id,
CancellationToken cancellationToken)
{
var result = await Sender.Send(new GetUserQuery(id), cancellationToken);

return result.IsFailure ? ToErrorResult(result) : Ok(result.Value);
return result.IsFailure ?
ToErrorResult(result) :
CreatedAtAction(nameof(Create), new { id = result.Value.Id }, result.Value);
}
}
2 changes: 1 addition & 1 deletion src/Voltiq.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapControllers().RequireAuthorization();

app.Run();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ public sealed record CreateUserCommand(
string Name,
string Email,
string Document,
string Password) : IRequest<Result<Guid>>;
string Password) : IRequest<Result<CreateUserResponse>>;
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Mappings.Users;
using Voltiq.Domain.Common;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
Expand All @@ -12,10 +14,11 @@ namespace Voltiq.Application.Features.Users.Commands.CreateUser;
public sealed class CreateUserCommandHandler(
IUserRepository userRepository,
IUnitOfWork unitOfWork,
IPasswordHasher passwordHasher)
: IRequestHandler<CreateUserCommand, Result<Guid>>
IPasswordHasher passwordHasher,
ITokenService tokenService)
: IRequestHandler<CreateUserCommand, Result<CreateUserResponse>>
{
public async Task<Result<Guid>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
public async Task<Result<CreateUserResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
var email = Email.Create(request.Email).Value;
var document = Document.Create(request.Document).Value;
Expand All @@ -24,7 +27,7 @@ public async Task<Result<Guid>> Handle(CreateUserCommand request, CancellationTo
document, email, cancellationToken);

if (userAlreadyExists)
return Result<Guid>.Failure(new ConflictError(ResourceErrorMessages.USUARIO_EMAIL_JA_CADASTRADO));
return Result<CreateUserResponse>.Failure(new ConflictError(ResourceErrorMessages.USUARIO_EMAIL_JA_CADASTRADO));

var passwordHash = passwordHasher.Hash(request.Password);

Expand All @@ -33,6 +36,8 @@ public async Task<Result<Guid>> Handle(CreateUserCommand request, CancellationTo
await userRepository.AddAsync(user, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result<Guid>.Success(user.Id);
var token = tokenService.GenerateToken(user.Id.ToString(), user.Name, []);

return Result<CreateUserResponse>.Success(user.ToCreateUserResponse(token));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Users.Commands.CreateUser;

public sealed record CreateUserResponse(Guid Id, string Token);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using MediatR;
using Voltiq.Domain.Common;

namespace Voltiq.Application.Features.Users.Queries.GetCurrentUser;

public sealed record GetCurrentUserQuery : IRequest<Result<GetUserResponse>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Mappings.Users;
using Voltiq.Domain.Common;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces.Repositories;
using Voltiq.Exceptions.Errors;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Users.Queries.GetCurrentUser;

public sealed class GetCurrentUserQueryHandler(
ICurrentUserService currentUserService,
IRepository<User> userRepository)
: IRequestHandler<GetCurrentUserQuery, Result<GetUserResponse>>
{
public async Task<Result<GetUserResponse>> Handle(GetCurrentUserQuery request, CancellationToken cancellationToken)
{
if (!Guid.TryParse(currentUserService.UserId, out var userId) || userId == Guid.Empty)
return Result<GetUserResponse>.Failure(
new NotFoundError(string.Format(ResourceErrorMessages.ENTIDADE_NAO_ENCONTRADA, nameof(User), currentUserService.UserId)));

var user = await userRepository.GetByIdAsync(userId, cancellationToken);

if (user is null)
return Result<GetUserResponse>.Failure(
new NotFoundError(string.Format(ResourceErrorMessages.ENTIDADE_NAO_ENCONTRADA, nameof(User), userId)));

return Result<GetUserResponse>.Success(user.ToGetUserResponse());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Voltiq.Application.Features.Users.Queries.GetCurrentUser;

public sealed record GetUserResponse(string Name, string Email);

This file was deleted.

This file was deleted.

This file was deleted.

12 changes: 12 additions & 0 deletions src/Voltiq.Application/Mappings/Auth/AuthMappingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Voltiq.Application.Features.Auth.Commands.Login;

namespace Voltiq.Application.Mappings.Auth;

public static class AuthMappingExtensions
{
extension(LoginRequest request)
{
public LoginCommand ToCommand() =>
new(request.Email, request.Password);
}
}
23 changes: 23 additions & 0 deletions src/Voltiq.Application/Mappings/Users/UserMappingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Voltiq.Application.Features.Users.Commands.CreateUser;
using Voltiq.Application.Features.Users.Queries.GetCurrentUser;
using Voltiq.Domain.Entities;

namespace Voltiq.Application.Mappings.Users;

public static class UserMappingExtensions
{
extension(CreateUserRequest request)
{
public CreateUserCommand ToCommand() =>
new(request.Name, request.Email, request.Document, request.Password);
}

extension(User user)
{
public CreateUserResponse ToCreateUserResponse(string token) =>
new(user.Id, token);

public GetUserResponse ToGetUserResponse() =>
new(user.Name, user.Email.Value);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Moq;
using Shouldly;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Features.Users.Commands.CreateUser;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
Expand All @@ -14,15 +15,16 @@ public class CreateUserCommandHandlerTests
private readonly Mock<IUserRepository> _userRepoMock = new();
private readonly Mock<IUnitOfWork> _unitOfWorkMock = new();
private readonly Mock<IPasswordHasher> _passwordHasherMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();

private CreateUserCommandHandler CreateHandler() =>
new(_userRepoMock.Object, _unitOfWorkMock.Object, _passwordHasherMock.Object);
new(_userRepoMock.Object, _unitOfWorkMock.Object, _passwordHasherMock.Object, _tokenServiceMock.Object);

private static CreateUserCommand ValidCommand() =>
new("João Silva", "joao@example.com", "529.982.247-25", "S3cur3P@ssw0rd!");

[Fact]
public async Task Handle_WithValidCommand_ShouldReturnSuccessWithUserId()
public async Task Handle_WithValidCommand_ShouldReturnSuccessWithUserIdAndToken()
{
_userRepoMock
.Setup(r => r.ExistsUserAsync(It.IsAny<Document>(), It.IsAny<Email>(), It.IsAny<CancellationToken>()))
Expand All @@ -32,13 +34,21 @@ public async Task Handle_WithValidCommand_ShouldReturnSuccessWithUserId()
.Setup(h => h.Hash(It.IsAny<string>()))
.Returns("$argon2id$hashed");

_tokenServiceMock
.Setup(t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()))
.Returns("jwt.token.here");

var handler = CreateHandler();
var result = await handler.Handle(ValidCommand(), CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBe(Guid.Empty);
result.Value.Id.ShouldNotBe(Guid.Empty);
result.Value.Token.ShouldBe("jwt.token.here");
_userRepoMock.Verify(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
_tokenServiceMock.Verify(
t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()),
Times.Once);
}

[Fact]
Expand All @@ -56,5 +66,8 @@ public async Task Handle_WhenUserAlreadyExists_ShouldReturnConflictError()
result.IsFailure.ShouldBeTrue();
result.FirstError.ShouldBeOfType<ConflictError>();
_userRepoMock.Verify(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()), Times.Never);
_tokenServiceMock.Verify(
t => t.GenerateToken(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>()),
Times.Never);
}
}
Loading