mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
formatting
This commit is contained in:
@@ -30,7 +30,7 @@ public class ConfirmUserHandlerTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
|
||||
return new ValidatedToken(userId, username, principal);
|
||||
@@ -46,10 +46,15 @@ public class ConfirmUserHandlerTests
|
||||
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
||||
|
||||
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||
|
||||
ConfirmationPayload result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||
ConfirmationPayload result = await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
@@ -64,7 +69,8 @@ public class ConfirmUserHandlerTests
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
||||
|
||||
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
@@ -79,10 +85,18 @@ public class ConfirmUserHandlerTests
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ public class RefreshTokenHandlerTests
|
||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||
|
||||
LoginPayload result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
|
||||
LoginPayload result = await handler.Handle(
|
||||
new RefreshTokenCommand("old-refresh-token"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("testuser");
|
||||
result.RefreshToken.Should().Be("new-refresh-token");
|
||||
result.AccessToken.Should().Be("new-access-token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,19 @@ public class RegisterUserHandlerTests
|
||||
);
|
||||
}
|
||||
|
||||
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com")
|
||||
private static RegisterUserCommand ValidCommand(
|
||||
string username = "newuser",
|
||||
string email = "john.doe@example.com"
|
||||
)
|
||||
{
|
||||
return new RegisterUserCommand(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
|
||||
return new RegisterUserCommand(
|
||||
username,
|
||||
"John",
|
||||
"Doe",
|
||||
email,
|
||||
new DateTime(1990, 1, 1),
|
||||
"SecurePassword123!"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,27 +57,46 @@ public class RegisterUserHandlerTests
|
||||
const string hashedPassword = "hashed_password_value";
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
|
||||
command.DateOfBirth, hashedPassword))
|
||||
.ReturnsAsync(new UserAccount
|
||||
{
|
||||
UserAccountId = expectedUserId,
|
||||
Username = command.Username,
|
||||
FirstName = command.FirstName,
|
||||
LastName = command.LastName,
|
||||
Email = command.Email,
|
||||
DateOfBirth = command.DateOfBirth,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = expectedUserId,
|
||||
Username = command.Username,
|
||||
FirstName = command.FirstName,
|
||||
LastName = command.LastName,
|
||||
Email = command.Email,
|
||||
DateOfBirth = command.DateOfBirth,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||
.Returns("confirmation-token");
|
||||
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
@@ -89,18 +118,36 @@ public class RegisterUserHandlerTests
|
||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand("existinguser");
|
||||
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "existinguser",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync(existingUser);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -109,14 +156,23 @@ public class RegisterUserHandlerTests
|
||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Email = "existing@example.com",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -125,24 +181,47 @@ public class RegisterUserHandlerTests
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
const string hashedPassword = "hashed_secure_password";
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
|
||||
command.DateOfBirth, hashedPassword),
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
@@ -152,19 +231,43 @@ public class RegisterUserHandlerTests
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new UserAccount
|
||||
{ UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = command.Username,
|
||||
Email = command.Email,
|
||||
}
|
||||
);
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
_mediatorMock
|
||||
.Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(x =>
|
||||
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
|
||||
)
|
||||
.ThrowsAsync(new Exception("smtp down"));
|
||||
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
@@ -172,4 +275,4 @@ public class RegisterUserHandlerTests
|
||||
result.ConfirmationEmailSent.Should().BeFalse();
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,23 @@ public class ResendConfirmationEmailHandlerTests
|
||||
|
||||
public ResendConfirmationEmailHandlerTests()
|
||||
{
|
||||
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object,
|
||||
_mediatorMock.Object);
|
||||
_handler = new ResendConfirmationEmailHandler(
|
||||
_authRepositoryMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new() { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = userId,
|
||||
FirstName = "Aaron",
|
||||
Email = "aaron@example.com",
|
||||
};
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
||||
@@ -34,9 +42,15 @@ public class ResendConfirmationEmailHandlerTests
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||
c.FirstName == "Aaron" && c.Email == "aaron@example.com" && c.ConfirmationToken == "fresh-token"
|
||||
), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||
c.FirstName == "Aaron"
|
||||
&& c.Email == "aaron@example.com"
|
||||
&& c.ConfirmationToken == "fresh-token"
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
@@ -50,7 +64,11 @@ public class ResendConfirmationEmailHandlerTests
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -67,8 +85,12 @@ public class ResendConfirmationEmailHandlerTests
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||
<PackageReference Include="DbMocker" Version="1.26.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj"/>
|
||||
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -22,7 +22,11 @@ public class LoginHandlerTests
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new LoginHandler(_authRepoMock.Object, _passwordInfraMock.Object, _tokenServiceMock.Object);
|
||||
_handler = new LoginHandler(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,7 +42,7 @@ public class LoginHandlerTests
|
||||
FirstName = "René",
|
||||
LastName = "Descartes",
|
||||
Email = "r.descartes@example.com",
|
||||
DateOfBirth = new DateTime(1596, 03, 31)
|
||||
DateOfBirth = new DateTime(1596, 03, 31),
|
||||
};
|
||||
|
||||
UserCredential userCredential = new()
|
||||
@@ -46,16 +50,27 @@ public class LoginHandlerTests
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "some-hash",
|
||||
Expiry = DateTime.MaxValue
|
||||
Expiry = DateTime.MaxValue,
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
||||
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(true);
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
LoginPayload result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
|
||||
LoginPayload result = await _handler.Handle(
|
||||
new LoginQuery(username, "any-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userAccountId);
|
||||
@@ -68,12 +83,18 @@ public class LoginHandlerTests
|
||||
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "de_beauvoir";
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,13 +105,20 @@ public class LoginHandlerTests
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync((UserCredential?)null);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,14 +127,29 @@ public class LoginHandlerTests
|
||||
const string username = "RCarnap";
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
UserCredential userCredential = new() { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "hashed-password",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
||||
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new LoginQuery(username, "wrong-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ public class AuthRepositoryTests
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
||||
.ReturnsScalar(expectedUserId);
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
|
||||
|
||||
// Mock the subsequent read for the newly created user by id
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||
@@ -132,9 +131,7 @@ public class AuthRepositoryTests
|
||||
Guid userId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -175,9 +172,7 @@ public class AuthRepositoryTests
|
||||
{
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
@@ -193,9 +188,7 @@ public class AuthRepositoryTests
|
||||
Guid credentialId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -205,13 +198,7 @@ public class AuthRepositoryTests
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
credentialId,
|
||||
userId,
|
||||
"hashed_password_value",
|
||||
DateTime.UtcNow,
|
||||
null
|
||||
)
|
||||
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
|
||||
);
|
||||
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
@@ -229,9 +216,7 @@ public class AuthRepositoryTests
|
||||
Guid userId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
@@ -247,14 +232,12 @@ public class AuthRepositoryTests
|
||||
string newPasswordHash = "new_hashed_password";
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
||||
.ReturnsScalar(1);
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
|
||||
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
|
||||
// Should not throw
|
||||
Func<Task> act = async () =>
|
||||
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,20 @@ public class TokenServiceRefreshTests
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -45,7 +50,7 @@ public class TokenServiceRefreshTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -58,7 +63,7 @@ public class TokenServiceRefreshTests
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1)
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
// Mock the validation of refresh token
|
||||
@@ -69,11 +74,11 @@ public class TokenServiceRefreshTests
|
||||
// Mock the generation of new tokens
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||
.Returns(
|
||||
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
|
||||
);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
@@ -85,14 +90,17 @@ public class TokenServiceRefreshTests
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.GetUserByIdAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_tokenInfraMock.Verify(
|
||||
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
x =>
|
||||
x.GenerateJwt(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Exactly(2)
|
||||
);
|
||||
}
|
||||
@@ -108,9 +116,10 @@ public class TokenServiceRefreshTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -124,9 +133,10 @@ public class TokenServiceRefreshTests
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -141,7 +151,7 @@ public class TokenServiceRefreshTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -151,14 +161,13 @@ public class TokenServiceRefreshTests
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,20 @@ public class TokenServiceValidationTests
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -44,7 +49,7 @@ public class TokenServiceValidationTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -55,15 +60,17 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
ValidatedToken result =
|
||||
await _tokenService.ValidateAccessTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
result.Principal.Should().NotBeNull();
|
||||
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
||||
result
|
||||
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
|
||||
?.Value.Should()
|
||||
.Be(userId.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,7 +85,7 @@ public class TokenServiceValidationTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -89,8 +96,7 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
ValidatedToken result =
|
||||
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -110,7 +116,7 @@ public class TokenServiceValidationTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -121,8 +127,7 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
ValidatedToken result =
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -141,9 +146,10 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,14 +160,13 @@ public class TokenServiceValidationTests
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Token has expired"
|
||||
));
|
||||
.ThrowsAsync(new UnauthorizedException("Token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -175,7 +180,7 @@ public class TokenServiceValidationTests
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -186,9 +191,10 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
@@ -203,7 +209,7 @@ public class TokenServiceValidationTests
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -214,9 +220,10 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
@@ -232,7 +239,7 @@ public class TokenServiceValidationTests
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
@@ -243,9 +250,10 @@ public class TokenServiceValidationTests
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*malformed user ID*");
|
||||
}
|
||||
|
||||
@@ -260,9 +268,10 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -276,8 +285,9 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Features.Auth.Commands.ConfirmUser;
|
||||
/// Validates a confirmation token and confirms the corresponding user account.
|
||||
/// </summary>
|
||||
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
|
||||
@@ -13,22 +13,26 @@ namespace Features.Auth.Commands.ConfirmUser;
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||
public class ConfirmUserHandler(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService
|
||||
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||
/// </exception>
|
||||
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
|
||||
public async Task<ConfirmationPayload> Handle(
|
||||
ConfirmUserCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
request.Token
|
||||
);
|
||||
|
||||
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||
|
||||
if (user == null) throw new UnauthorizedException("User account not found");
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
|
||||
@@ -12,10 +12,17 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||
{
|
||||
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
public async Task<LoginPayload> Handle(
|
||||
RefreshTokenCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken,
|
||||
result.AccessToken);
|
||||
return new LoginPayload(
|
||||
result.UserAccount.UserAccountId,
|
||||
result.UserAccount.Username,
|
||||
result.RefreshToken,
|
||||
result.AccessToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
/// </summary>
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ public record RegisterUserCommand(
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string Password
|
||||
) : IRequest<RegistrationPayload>;
|
||||
) : IRequest<RegistrationPayload>;
|
||||
|
||||
@@ -28,7 +28,10 @@ public class RegisterUserHandler(
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </exception>
|
||||
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
||||
public async Task<RegistrationPayload> Handle(
|
||||
RegisterUserCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||
|
||||
@@ -48,14 +51,23 @@ public class RegisterUserHandler(
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
|
||||
false);
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false
|
||||
);
|
||||
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken),
|
||||
new SendRegistrationEmailCommand(
|
||||
createdUser.FirstName,
|
||||
createdUser.Email,
|
||||
confirmationToken
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
emailSent = true;
|
||||
@@ -67,8 +79,13 @@ public class RegisterUserHandler(
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
|
||||
emailSent);
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
|
||||
/// <exception cref="ConflictException">
|
||||
@@ -82,4 +99,4 @@ public class RegisterUserHandler(
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,6 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
.WithMessage("Password must contain at least one special character");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
||||
/// </summary>
|
||||
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
|
||||
@@ -23,12 +23,17 @@ public class ResendConfirmationEmailHandler(
|
||||
IMediator mediator
|
||||
) : IRequestHandler<ResendConfirmationEmailCommand>
|
||||
{
|
||||
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
||||
public async Task Handle(
|
||||
ResendConfirmationEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||
if (user == null) return; // Silent return to prevent user enumeration
|
||||
if (user == null)
|
||||
return; // Silent return to prevent user enumeration
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
||||
return; // Already confirmed, no-op
|
||||
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
await mediator.Send(
|
||||
@@ -36,4 +41,4 @@ public class ResendConfirmationEmailHandler(
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
)
|
||||
{
|
||||
RegistrationPayload payload = await mediator.Send(command);
|
||||
return Created("/", new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
return Created(
|
||||
"/",
|
||||
new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -61,11 +64,13 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(query);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -78,14 +83,18 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
|
||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||
[FromQuery] string token
|
||||
)
|
||||
{
|
||||
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
|
||||
return Ok(new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||
Payload = payload
|
||||
});
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -119,10 +128,12 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload
|
||||
});
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ public static class FeaturesAuthServiceCollectionExtensions
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ public record RegistrationPayload(
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
||||
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -46,4 +46,4 @@ public class LoginHandler(
|
||||
|
||||
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Features.Auth.Queries.Login;
|
||||
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
||||
/// from the request body of <c>POST /api/auth/login</c>.
|
||||
/// </summary>
|
||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||
|
||||
@@ -17,4 +17,4 @@ public class LoginValidator : AbstractValidator<LoginQuery>
|
||||
|
||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
}
|
||||
|
||||
return await GetUserByIdAsync(userAccountId) ??
|
||||
throw new Exception("Failed to retrieve newly registered user.");
|
||||
return await GetUserByIdAsync(userAccountId)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -92,9 +92,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(
|
||||
string email
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -114,9 +112,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(
|
||||
string username
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -136,9 +132,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -158,10 +152,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task RotateCredentialAsync(
|
||||
Guid userAccountId,
|
||||
string newPasswordHash
|
||||
)
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -180,9 +171,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount?> GetUserByIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -207,15 +196,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// Thrown when the database command fails for a reason other than
|
||||
/// a duplicate verification record.
|
||||
/// </exception>
|
||||
public async Task<UserAccount?> ConfirmUserAccountAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
|
||||
{
|
||||
UserAccount? user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null) return null;
|
||||
if (user == null)
|
||||
return null;
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId)) return user;
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
return user;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -270,15 +259,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
||||
protected override UserAccount MapToEntity(
|
||||
DbDataReader reader
|
||||
)
|
||||
protected override UserAccount MapToEntity(DbDataReader reader)
|
||||
{
|
||||
return new UserAccount
|
||||
{
|
||||
@@ -292,9 +278,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
? null
|
||||
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"]
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,12 +292,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
UserCredential entity = new()
|
||||
{
|
||||
UserCredentialId = reader.GetGuid(
|
||||
reader.GetOrdinal("UserCredentialId")
|
||||
),
|
||||
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
||||
};
|
||||
|
||||
// Optional columns
|
||||
@@ -327,7 +309,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
"Timer",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
) ?? false;
|
||||
)
|
||||
?? false;
|
||||
|
||||
if (hasTimer)
|
||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
@@ -344,15 +327,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="command">The command to add the parameter to.</param>
|
||||
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ public interface IAuthRepository
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
);
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||
@@ -81,4 +79,4 @@ public interface IAuthRepository
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public enum TokenType
|
||||
RefreshToken,
|
||||
|
||||
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||
ConfirmationToken
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -32,11 +32,7 @@ public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Princ
|
||||
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
||||
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
||||
/// <param name="AccessToken">The newly issued access token.</param>
|
||||
public record RefreshTokenResult(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||
@@ -85,7 +81,8 @@ public interface ITokenService
|
||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||
string GenerateToken<T>(UserAccount user)
|
||||
where T : struct, Enum;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access token.
|
||||
@@ -114,4 +111,4 @@ public interface ITokenService
|
||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,25 +29,28 @@ public class TokenService : ITokenService
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||
/// </exception>
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IAuthRepository authRepository
|
||||
)
|
||||
public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_authRepository = authRepository;
|
||||
|
||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
_accessTokenSecret =
|
||||
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"ACCESS_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
_refreshTokenSecret =
|
||||
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"REFRESH_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
_confirmationTokenSecret =
|
||||
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,7 +62,12 @@ public class TokenService : ITokenService
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_accessTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,8 +78,15 @@ public class TokenService : ITokenService
|
||||
/// <returns>The signed refresh token string.</returns>
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.RefreshTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_refreshTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,8 +97,15 @@ public class TokenService : ITokenService
|
||||
/// <returns>The signed confirmation token string.</returns>
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_confirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,7 +119,8 @@ public class TokenService : ITokenService
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||
/// </exception>
|
||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||
public string GenerateToken<T>(UserAccount user)
|
||||
where T : struct, Enum
|
||||
{
|
||||
if (typeof(T) != typeof(TokenType))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
@@ -112,7 +135,7 @@ public class TokenService : ITokenService
|
||||
TokenType.AccessToken => GenerateAccessToken(user),
|
||||
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||
_ => throw new InvalidOperationException("Invalid token type")
|
||||
_ => throw new InvalidOperationException("Invalid token type"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,7 +212,11 @@ public class TokenService : ITokenService
|
||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||
/// </exception>
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(
|
||||
string token,
|
||||
string secret,
|
||||
string tokenType
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -199,7 +226,9 @@ public class TokenService : ITokenService
|
||||
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||
throw new UnauthorizedException(
|
||||
$"Invalid {tokenType} token: missing required claims"
|
||||
);
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
@@ -215,4 +244,4 @@ public class TokenService : ITokenService
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,24 +19,13 @@ public class CreateBreweryHandlerTests
|
||||
|
||||
private static CreateBreweryLocation ValidLocation()
|
||||
{
|
||||
return new CreateBreweryLocation(
|
||||
Guid.NewGuid(),
|
||||
"123 Main St",
|
||||
null,
|
||||
"12345",
|
||||
null
|
||||
);
|
||||
return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||
{
|
||||
CreateBreweryCommand command = new(
|
||||
Guid.NewGuid(),
|
||||
"MyBrew",
|
||||
"Desc",
|
||||
ValidLocation()
|
||||
);
|
||||
CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
@@ -68,4 +57,4 @@ public class CreateBreweryHandlerTests
|
||||
result.BreweryName.Should().Be("MyBrew");
|
||||
result.Location.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ public class DeleteBreweryHandlerTests
|
||||
|
||||
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +96,13 @@ public class UpdateBreweryHandlerTests
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
persisted!.Location.Should().NotBeNull();
|
||||
persisted.Location!.BreweryPostLocationId.Should().Be(locationCommand.BreweryPostLocationId);
|
||||
persisted
|
||||
.Location!.BreweryPostLocationId.Should()
|
||||
.Be(locationCommand.BreweryPostLocationId);
|
||||
persisted.Location.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||
persisted.Location.CityId.Should().Be(locationCommand.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1);
|
||||
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
||||
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||
<PackageReference Include="DbMocker" Version="1.26.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/>
|
||||
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -20,10 +20,12 @@ public class GetAllBreweriesHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweriesQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
@@ -35,13 +37,18 @@ public class GetAllBreweriesHandlerTests
|
||||
BreweryPost[] breweries = new[]
|
||||
{
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||
.ReturnsAsync(breweries);
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync(breweries);
|
||||
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweriesQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
result
|
||||
.Select(b => b.BreweryPostId)
|
||||
.Should()
|
||||
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ public class GetBreweryByIdHandlerTests
|
||||
public async Task Handle_ReturnsBrewery_WhenFound()
|
||||
{
|
||||
BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||
.ReturnsAsync(brewery);
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId)).ReturnsAsync(brewery);
|
||||
|
||||
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
||||
BreweryDto? result = await _handler.Handle(
|
||||
new GetBreweryByIdQuery(brewery.BreweryPostId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
||||
@@ -34,11 +36,13 @@ public class GetBreweryByIdHandlerTests
|
||||
public async Task Handle_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||
.ReturnsAsync((BreweryPost?)null);
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((BreweryPost?)null);
|
||||
|
||||
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
||||
BreweryDto? result = await _handler.Handle(
|
||||
new GetBreweryByIdQuery(id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,7 @@ public class BreweryRepositoryTests
|
||||
public async Task CreateAsync_ExecutesSuccessfully()
|
||||
{
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
||||
.ReturnsScalar(1);
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
|
||||
BreweryRepository repo = CreateRepo(conn);
|
||||
BreweryPost brewery = new()
|
||||
{
|
||||
@@ -97,12 +96,12 @@ public class BreweryRepositoryTests
|
||||
CityId = Guid.NewGuid(),
|
||||
AddressLine1 = "123 Main St",
|
||||
PostalCode = "12345",
|
||||
Coordinates = [0x00, 0x01]
|
||||
}
|
||||
Coordinates = [0x00, 0x01],
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
Func<Task> act = async () => await repo.CreateAsync(brewery);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ public record CreateBreweryCommand(
|
||||
string BreweryName,
|
||||
string Description,
|
||||
CreateBreweryLocation Location
|
||||
) : IRequest<BreweryDto>;
|
||||
) : IRequest<BreweryDto>;
|
||||
|
||||
@@ -18,7 +18,10 @@ public class CreateBreweryHandler(IBreweryRepository repository)
|
||||
/// <param name="request">The details of the brewery post to create.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <returns>The newly created brewery post.</returns>
|
||||
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
|
||||
public async Task<BreweryDto> Handle(
|
||||
CreateBreweryCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
BreweryPost entity = new()
|
||||
{
|
||||
@@ -34,11 +37,11 @@ public class CreateBreweryHandler(IBreweryRepository repository)
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates
|
||||
}
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.CreateAsync(entity);
|
||||
return entity.ToDto();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
/// </summary>
|
||||
public CreateBreweryValidator()
|
||||
{
|
||||
RuleFor(x => x.PostedById)
|
||||
.NotEmpty()
|
||||
.WithMessage("PostedById is required.");
|
||||
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
|
||||
|
||||
RuleFor(x => x.BreweryName)
|
||||
.NotEmpty()
|
||||
@@ -31,9 +29,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
.MaximumLength(512)
|
||||
.WithMessage("Description cannot exceed 512 characters.");
|
||||
|
||||
RuleFor(x => x.Location)
|
||||
.NotNull()
|
||||
.WithMessage("Location is required.");
|
||||
RuleFor(x => x.Location).NotNull().WithMessage("Location is required.");
|
||||
|
||||
RuleFor(x => x.Location.CityId)
|
||||
.NotEmpty()
|
||||
@@ -56,4 +52,4 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("Postal code cannot exceed 20 characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
/// <summary>
|
||||
/// Deletes a brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||
|
||||
@@ -14,4 +14,4 @@ public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||
{
|
||||
return repository.DeleteAsync(request.BreweryPostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@ public record UpdateBreweryCommand(
|
||||
string BreweryName,
|
||||
string Description,
|
||||
UpdateBreweryLocation? Location
|
||||
) : IRequest<BreweryDto>;
|
||||
) : IRequest<BreweryDto>;
|
||||
|
||||
@@ -19,7 +19,10 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||
/// <param name="request">The updated details of the brewery post.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <returns>The updated brewery post.</returns>
|
||||
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken)
|
||||
public async Task<BreweryDto> Handle(
|
||||
UpdateBreweryCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
BreweryPost entity = new()
|
||||
{
|
||||
@@ -38,11 +41,11 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates
|
||||
}
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.UpdateAsync(entity);
|
||||
return entity.ToDto();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
if (brewery is null)
|
||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = brewery
|
||||
});
|
||||
return Ok(
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = brewery,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,14 +61,19 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset)
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
IEnumerable<BreweryDto> breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
|
||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries
|
||||
});
|
||||
IEnumerable<BreweryDto> breweries = await mediator.Send(
|
||||
new GetAllBreweriesQuery(limit, offset)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -78,14 +85,19 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
/// <see cref="BreweryDto" />.
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||
[FromBody] CreateBreweryCommand command
|
||||
)
|
||||
{
|
||||
BreweryDto brewery = await mediator.Send(command);
|
||||
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = brewery
|
||||
});
|
||||
return Created(
|
||||
$"/api/brewery/{brewery.BreweryPostId}",
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = brewery,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -105,17 +117,22 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
/// when the route ID does not match the payload ID.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command)
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
||||
Guid id,
|
||||
[FromBody] UpdateBreweryCommand command
|
||||
)
|
||||
{
|
||||
if (command.BreweryPostId != id)
|
||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||
|
||||
BreweryDto brewery = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = brewery
|
||||
});
|
||||
return Ok(
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = brewery,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,4 +146,4 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new DeleteBreweryCommand(id));
|
||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ public static class FeaturesBreweriesServiceCollectionExtensions
|
||||
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +86,4 @@ public class BreweryDto
|
||||
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
|
||||
/// </summary>
|
||||
public BreweryLocationDto? Location { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ public static class BreweryDtoMapper
|
||||
AddressLine1 = brewery.Location.AddressLine1,
|
||||
AddressLine2 = brewery.Location.AddressLine2,
|
||||
PostalCode = brewery.Location.PostalCode,
|
||||
Coordinates = brewery.Location.Coordinates
|
||||
}
|
||||
Coordinates = brewery.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -12,9 +12,15 @@ namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||
{
|
||||
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken)
|
||||
public async Task<IEnumerable<BreweryDto>> Handle(
|
||||
GetAllBreweriesQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
IEnumerable<BreweryPost> breweries = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
IEnumerable<BreweryPost> breweries = await repository.GetAllAsync(
|
||||
request.Limit,
|
||||
request.Offset
|
||||
);
|
||||
return breweries.Select(b => b.ToDto());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of brewery posts.
|
||||
/// </summary>
|
||||
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
||||
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
||||
|
||||
@@ -12,9 +12,12 @@ namespace Features.Breweries.Queries.GetBreweryById;
|
||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||
{
|
||||
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken)
|
||||
public async Task<BreweryDto?> Handle(
|
||||
GetBreweryByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
|
||||
return brewery?.ToDto();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ namespace Features.Breweries.Queries.GetBreweryById;
|
||||
/// <summary>
|
||||
/// Retrieves a single brewery post by its unique identifier.
|
||||
/// </summary>
|
||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace Features.Breweries.Repository;
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
||||
: Repository<BreweryPost>(connectionFactory),
|
||||
IBreweryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
|
||||
@@ -29,7 +30,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
AddParameter(command, "@BreweryPostID", id);
|
||||
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
if (await reader.ReadAsync()) return MapToEntity(reader);
|
||||
if (await reader.ReadAsync())
|
||||
return MapToEntity(reader);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,7 +60,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
List<BreweryPost> breweries = new();
|
||||
|
||||
while (await reader.ReadAsync()) breweries.Add(MapToEntity(reader));
|
||||
while (await reader.ReadAsync())
|
||||
breweries.Add(MapToEntity(reader));
|
||||
|
||||
return breweries;
|
||||
}
|
||||
@@ -121,7 +124,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
command.CommandText = "USP_CreateBrewery";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
if (brewery.Location is null) throw new ArgumentException("Location must be provided when creating a brewery.");
|
||||
if (brewery.Location is null)
|
||||
throw new ArgumentException("Location must be provided when creating a brewery.");
|
||||
|
||||
AddParameter(command, "@BreweryName", brewery.BreweryName);
|
||||
AddParameter(command, "@Description", brewery.Description);
|
||||
@@ -195,7 +199,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
||||
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
|
||||
? null
|
||||
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
|
||||
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")),
|
||||
};
|
||||
brewery.Location = location;
|
||||
}
|
||||
@@ -215,15 +219,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="command">The command to add the parameter to.</param>
|
||||
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
|
||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,4 @@ public interface IBreweryRepository
|
||||
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
|
||||
Task CreateAsync(BreweryPost brewery);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ public class SendRegistrationEmailHandlerTests
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ public class SendResendConfirmationEmailHandlerTests
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Emails\Features.Emails.csproj"/>
|
||||
<ProjectReference Include="..\Features.Emails\Features.Emails.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -14,6 +14,10 @@ public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
{
|
||||
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
|
||||
return emailDispatcher.SendRegistrationEmailAsync(
|
||||
request.FirstName,
|
||||
request.Email,
|
||||
request.ConfirmationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@ namespace Features.Emails.Commands.SendResendConfirmationEmail;
|
||||
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
: IRequestHandler<SendResendConfirmationEmailCommand>
|
||||
{
|
||||
public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken)
|
||||
public Task Handle(
|
||||
SendResendConfirmationEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email,
|
||||
request.ConfirmationToken);
|
||||
return emailDispatcher.SendResendConfirmationEmailAsync(
|
||||
request.FirstName,
|
||||
request.Email,
|
||||
request.ConfirmationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ public static class FeaturesEmailsServiceCollectionExtensions
|
||||
services.AddScoped<IEmailDispatcher, EmailDispatcher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -29,39 +29,38 @@ public class EmailDispatcher(
|
||||
/// Builds a confirmation link from the given token, renders the registration welcome email template,
|
||||
/// and sends it to the newly created user.
|
||||
/// </summary>
|
||||
public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
|
||||
public async Task SendRegistrationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
string confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
string emailHtml =
|
||||
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(
|
||||
email,
|
||||
"Welcome to The Biergarten App!",
|
||||
emailHtml,
|
||||
true
|
||||
string emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a confirmation link from the given token, renders the resend-confirmation email template,
|
||||
/// and sends it to the user.
|
||||
/// </summary>
|
||||
public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken)
|
||||
public async Task SendResendConfirmationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
string confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
string emailHtml =
|
||||
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
string emailHtml = await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(
|
||||
email,
|
||||
@@ -70,4 +69,4 @@ public class EmailDispatcher(
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ public interface IEmailDispatcher
|
||||
/// <param name="confirmationToken">The confirmation token to embed in the confirmation link.</param>
|
||||
/// <returns>A task that completes once the email has been sent.</returns>
|
||||
Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,4 @@ public class UpdateUserHandlerTests
|
||||
|
||||
repoMock.Verify(r => r.UpdateAsync(user), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||
<PackageReference Include="Moq" Version="4.20.72"/>
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0"/>
|
||||
<PackageReference Include="DbMocker" Version="1.26.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj"/>
|
||||
<ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -15,9 +15,12 @@ public class GetAllUsersHandlerTests
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<UserAccount> result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None);
|
||||
IEnumerable<UserAccount> result = await handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ public class GetUserByIdHandlerTests
|
||||
UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
UserAccount result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None);
|
||||
UserAccount result = await _handler.Handle(
|
||||
new GetUserByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().Be(user);
|
||||
}
|
||||
@@ -34,8 +37,9 @@ public class GetUserByIdHandlerTests
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<UserAccount>> act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
Func<Task<UserAccount>> act = async () =>
|
||||
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,19 +98,14 @@ public class UserAccountRepositoryTests
|
||||
UserAccountRepository repo = CreateRepo(conn);
|
||||
List<UserAccount> results = (await repo.GetAllAsync(null, null)).ToList();
|
||||
results.Should().HaveCount(2);
|
||||
results
|
||||
.Select(r => r.Username)
|
||||
.Should()
|
||||
.BeEquivalentTo("a", "b");
|
||||
results.Select(r => r.Username).Should().BeEquivalentTo("a", "b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByUsername_ReturnsRow()
|
||||
{
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -179,4 +174,4 @@ public class UserAccountRepositoryTests
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("byemail");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Features.UserManagement.Commands.UpdateUser;
|
||||
/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward
|
||||
/// from <c>IUserService.UpdateAsync</c> as-is.
|
||||
/// </summary>
|
||||
public record UpdateUserCommand(UserAccount UserAccount) : IRequest;
|
||||
public record UpdateUserCommand(UserAccount UserAccount) : IRequest;
|
||||
|
||||
@@ -14,4 +14,4 @@ public class UpdateUserHandler(IUserAccountRepository repository)
|
||||
{
|
||||
return repository.UpdateAsync(request.UserAccount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,4 @@ public class UserController(IMediator mediator) : ControllerBase
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ public static class FeaturesUserManagementServiceCollectionExtensions
|
||||
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj"/>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -11,8 +11,11 @@ namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(GetAllUsersQuery request, CancellationToken cancellationToken)
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return repository.GetAllAsync(request.Limit, request.Offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
/// <summary>
|
||||
/// Retrieves a paginated list of user accounts.
|
||||
/// </summary>
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||
|
||||
@@ -13,11 +13,14 @@ public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<UserAccount> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
|
||||
public async Task<UserAccount> Handle(
|
||||
GetUserByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ namespace Features.UserManagement.Queries.GetUserById;
|
||||
/// <summary>
|
||||
/// Retrieves a single user account by its unique identifier.
|
||||
/// </summary>
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
|
||||
@@ -20,10 +20,7 @@ public interface IUserAccountRepository
|
||||
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
||||
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||
int? limit,
|
||||
int? offset
|
||||
);
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing user account's details.
|
||||
@@ -50,4 +47,4 @@ public interface IUserAccountRepository
|
||||
/// <param name="email">The email address to search for.</param>
|
||||
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
|
||||
Task<UserAccount?> GetByEmailAsync(string email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||
int? limit,
|
||||
int? offset
|
||||
)
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -61,7 +58,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
List<UserAccount> users = new();
|
||||
|
||||
while (await reader.ReadAsync()) users.Add(MapToEntity(reader));
|
||||
while (await reader.ReadAsync())
|
||||
users.Add(MapToEntity(reader));
|
||||
|
||||
return users;
|
||||
}
|
||||
@@ -111,9 +109,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="username">The username to search for.</param>
|
||||
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount?> GetByUsernameAsync(
|
||||
string username
|
||||
)
|
||||
public async Task<UserAccount?> GetByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -132,9 +128,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="email">The email address to search for.</param>
|
||||
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserAccount?> GetByEmailAsync(
|
||||
string email
|
||||
)
|
||||
public async Task<UserAccount?> GetByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
@@ -152,9 +146,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
||||
protected override UserAccount MapToEntity(
|
||||
DbDataReader reader
|
||||
)
|
||||
protected override UserAccount MapToEntity(DbDataReader reader)
|
||||
{
|
||||
return new UserAccount
|
||||
{
|
||||
@@ -168,9 +160,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
? null
|
||||
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"]
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,15 +171,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="command">The command to add the parameter to.</param>
|
||||
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user