diff --git a/src/Core/API/API.Core/Controllers/AuthController.cs b/src/Core/API/API.Core/Controllers/AuthController.cs
index fb0ed13..7243141 100644
--- a/src/Core/API/API.Core/Controllers/AuthController.cs
+++ b/src/Core/API/API.Core/Controllers/AuthController.cs
@@ -3,7 +3,7 @@ using API.Core.Contracts.Common;
using Domain.Entities;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Mvc;
-using Service.Auth.Auth;
+using Service.Auth;
namespace API.Core.Controllers
{
diff --git a/src/Core/API/API.Core/Program.cs b/src/Core/API/API.Core/Program.cs
index 68866b5..ff70e6d 100644
--- a/src/Core/API/API.Core/Program.cs
+++ b/src/Core/API/API.Core/Program.cs
@@ -9,12 +9,12 @@ using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
-using Service.Auth.Auth;
using Service.UserManagement.User;
using API.Core.Contracts.Common;
using Infrastructure.Email;
using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering;
+using Service.Auth;
var builder = WebApplication.CreateBuilder(args);
diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile
index 887e7d7..f82f51b 100644
--- a/src/Core/API/API.Specs/Dockerfile
+++ b/src/Core/API/API.Specs/Dockerfile
@@ -20,6 +20,6 @@ FROM build AS final
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
-RUN mkdir -p /app/test-results
+RUN mkdir -p /app/test-results/api-specs
WORKDIR /src/API/API.Specs
-ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/test-results.trx"]
+ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/api-specs/results.trx"]
diff --git a/src/Core/Core.slnx b/src/Core/Core.slnx
index 19d646f..a088dcd 100644
--- a/src/Core/Core.slnx
+++ b/src/Core/Core.slnx
@@ -1,26 +1,27 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj
index 43384cb..88bebda 100644
--- a/src/Core/Database/Database.Seed/Database.Seed.csproj
+++ b/src/Core/Database/Database.Seed/Database.Seed.csproj
@@ -13,7 +13,7 @@
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
-
+
diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
index 8f6eea8..f06bb9e 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Dockerfile
@@ -11,6 +11,6 @@ WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests"
RUN dotnet build "./Infrastructure.Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
-RUN mkdir -p /app/test-results
+RUN mkdir -p /app/test-results/repository-tests
WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests
-ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]
+ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests/results.trx"]
diff --git a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
index a71f674..55a49a2 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
+++ b/src/Core/Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
@@ -6,28 +6,14 @@
false
Infrastructure.Repository.Tests
-
-
-
+
-
-
-
-
+
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs
index 4672852..83b26d7 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/AuthRepository.cs
@@ -88,9 +88,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
- return await reader.ReadAsync()
- ? MapToCredentialEntity(reader)
- : null;
+ return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
}
public async Task RotateCredentialAsync(
@@ -118,9 +116,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{
return new Domain.Entities.UserAccount
{
- UserAccountId = reader.GetGuid(
- reader.GetOrdinal("UserAccountId")
- ),
+ UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
@@ -129,9 +125,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
- DateOfBirth = reader.GetDateTime(
- reader.GetOrdinal("DateOfBirth")
- ),
+ DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
@@ -148,9 +142,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
UserCredentialId = reader.GetGuid(
reader.GetOrdinal("UserCredentialId")
),
- UserAccountId = reader.GetGuid(
- reader.GetOrdinal("UserAccountId")
- ),
+ UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
};
@@ -192,4 +184,4 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs
index 4a57360..f8472c1 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Auth/IAuthRepository.cs
@@ -33,9 +33,7 @@ public interface IAuthRepository
///
/// Email address to search for
/// UserAccount if found, null otherwise
- Task GetUserByEmailAsync(
- string email
- );
+ Task GetUserByEmailAsync(string email);
///
/// Retrieves a user account by username (typically used for login).
@@ -43,9 +41,7 @@ public interface IAuthRepository
///
/// Username to search for
/// UserAccount if found, null otherwise
- Task GetUserByUsernameAsync(
- string username
- );
+ Task GetUserByUsernameAsync(string username);
///
/// Retrieves the active (non-revoked) credential for a user account.
@@ -64,4 +60,4 @@ public interface IAuthRepository
/// ID of the user account
/// New hashed password
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
index c38e1f4..4748809 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj
@@ -6,7 +6,7 @@
Infrastructure.Repository
-
+
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs
index 30672d9..2419f56 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Repository.cs
@@ -14,4 +14,4 @@ public abstract class Repository(ISqlConnectionFactory connectionFactory)
}
protected abstract T MapToEntity(DbDataReader reader);
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs
index 8d5bf62..dcd3652 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs
@@ -46,4 +46,4 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration)
{
return new SqlConnection(_connectionString);
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs
index 40a6eed..2a7ac18 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/ISqlConnectionFactory.cs
@@ -5,4 +5,4 @@ namespace Infrastructure.Repository.Sql;
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs
index 27daeff..cab4e4e 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/Sql/SqlConnectionStringHelper.cs
@@ -58,4 +58,4 @@ public static class SqlConnectionStringHelper
{
return BuildConnectionString("master");
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs
index 774f825..2a48851 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/IUserAccountRepository.cs
@@ -9,8 +9,6 @@ public interface IUserAccountRepository
);
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
- Task GetByUsernameAsync(
- string username
- );
+ Task GetByUsernameAsync(string username);
Task GetByEmailAsync(string email);
-}
\ No newline at end of file
+}
diff --git a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs
index d07fa3c..8de64ba 100644
--- a/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs
+++ b/src/Core/Infrastructure/Infrastructure.Repository/UserAccount/UserAccountRepository.cs
@@ -8,9 +8,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository(connectionFactory),
IUserAccountRepository
{
- public async Task GetByIdAsync(
- Guid id
- )
+ public async Task GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
@@ -23,9 +21,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
- public async Task<
- IEnumerable
- > GetAllAsync(int? limit, int? offset)
+ public async Task> GetAllAsync(
+ int? limit,
+ int? offset
+ )
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
@@ -49,9 +48,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
return users;
}
- public async Task UpdateAsync(
- Domain.Entities.UserAccount userAccount
- )
+ public async Task UpdateAsync(Domain.Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
@@ -115,9 +112,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
{
return new Domain.Entities.UserAccount
{
- UserAccountId = reader.GetGuid(
- reader.GetOrdinal("UserAccountId")
- ),
+ UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
@@ -126,9 +121,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
- DateOfBirth = reader.GetDateTime(
- reader.GetOrdinal("DateOfBirth")
- ),
+ DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
@@ -146,4 +139,4 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
-}
\ No newline at end of file
+}
diff --git a/src/Core/Service/Service.Auth.Tests/LoginService.test.cs b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs
new file mode 100644
index 0000000..b266d1e
--- /dev/null
+++ b/src/Core/Service/Service.Auth.Tests/LoginService.test.cs
@@ -0,0 +1,238 @@
+using Domain.Entities;
+using Domain.Exceptions;
+using FluentAssertions;
+using Infrastructure.PasswordHashing;
+using Infrastructure.Repository.Auth;
+using Moq;
+
+namespace Service.Auth.Tests;
+
+public class LoginServiceTest
+{
+ private readonly Mock _authRepoMock;
+ private readonly Mock _passwordInfraMock;
+ private readonly LoginService _loginService;
+
+ public LoginServiceTest()
+ {
+ _authRepoMock = new Mock();
+ _passwordInfraMock = new Mock();
+ _loginService = new LoginService(
+ _authRepoMock.Object,
+ _passwordInfraMock.Object
+ );
+ }
+
+ // Happy path: login returns the user account with the same username -- successful login
+ [Fact]
+ public async Task LoginAsync_WithValidData_ReturnsUserAccountWithMatchingUsername()
+ {
+ // Arrange
+ const string username = "CogitoErgoSum";
+ var userAccountId = Guid.NewGuid();
+
+ UserAccount userAccount = new()
+ {
+ UserAccountId = userAccountId,
+ Username = username,
+ FirstName = "René",
+ LastName = "Descartes",
+ Email = "r.descartes@example.com",
+ DateOfBirth = new DateTime(1596, 03, 31),
+ };
+
+ UserCredential userCredential = new()
+ {
+ UserCredentialId = Guid.NewGuid(),
+ UserAccountId = userAccountId,
+ Hash = "some-hash",
+ 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(), It.IsAny()))
+ .Returns(true);
+
+ // Act
+ var result = await _loginService.LoginAsync(username, It.IsAny());
+
+ // Assert
+ result.Should().NotBeNull();
+ result.UserAccountId.Should().Be(userAccountId);
+ result.Username.Should().Be(username);
+
+ _authRepoMock.Verify(
+ x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
+ Times.Once
+ );
+
+ _authRepoMock.Verify(
+ x => x.GetUserByUsernameAsync(username),
+ Times.Once
+ );
+
+ _passwordInfraMock.Verify(
+ x => x.Verify(It.IsAny(), It.IsAny()),
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task LoginAsync_WithUnregisteredUsername_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ const string username = "de_beauvoir";
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(username))
+ .ReturnsAsync((UserAccount?)null);
+
+ // Act
+ var act = async () =>
+ await _loginService.LoginAsync(username, It.IsAny());
+
+ // Assert
+ await act.Should().ThrowAsync();
+
+ _authRepoMock.Verify(
+ x => x.GetUserByUsernameAsync(username),
+ Times.Once
+ );
+
+ _authRepoMock.Verify(
+ x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny()),
+ Times.Never
+ );
+
+ _passwordInfraMock.Verify(
+ x => x.Verify(It.IsAny(), It.IsAny()),
+ Times.Never
+ );
+ }
+
+ [Fact]
+ public async Task LoginAsync_WithNoActiveCredential_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ const string username = "BRussell";
+ var userAccountId = Guid.NewGuid();
+
+ UserAccount userAccount = new()
+ {
+ UserAccountId = userAccountId,
+ Username = username,
+ FirstName = "Bertrand",
+ LastName = "Russell",
+ Email = "b.russell@example.co.uk",
+ DateOfBirth = new DateTime(1872, 05, 18),
+ };
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(username))
+ .ReturnsAsync(userAccount);
+
+ _authRepoMock
+ .Setup(x =>
+ x.GetActiveCredentialByUserAccountIdAsync(userAccountId)
+ )
+ .ReturnsAsync((UserCredential?)null);
+
+ // Act
+ var act = async () =>
+ await _loginService.LoginAsync(username, It.IsAny());
+
+ // Assert
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("Invalid username or password.");
+
+ _authRepoMock.Verify(
+ x => x.GetUserByUsernameAsync(username),
+ Times.Once
+ );
+
+ _authRepoMock.Verify(
+ x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
+ Times.Once
+ );
+
+ _passwordInfraMock.Verify(
+ x => x.Verify(It.IsAny(), It.IsAny()),
+ Times.Never
+ );
+ }
+
+ [Fact]
+ public async Task LoginAsync_WithIncorrectPassword_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ const string username = "RCarnap";
+ var userAccountId = Guid.NewGuid();
+
+ UserAccount userAccount = new()
+ {
+ UserAccountId = userAccountId,
+ Username = username,
+ FirstName = "Rudolf",
+ LastName = "Carnap",
+ Email = "r.carnap@example.de",
+ DateOfBirth = new DateTime(1891, 05, 18),
+ };
+
+ UserCredential userCredential = new()
+ {
+ UserCredentialId = Guid.NewGuid(),
+ UserAccountId = userAccountId,
+ Hash = "hashed-password",
+ 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(), It.IsAny()))
+ .Returns(false);
+
+ // Act
+ var act = async () =>
+ await _loginService.LoginAsync(username, It.IsAny());
+
+ // Assert
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("Invalid username or password.");
+
+ _authRepoMock.Verify(
+ x => x.GetUserByUsernameAsync(username),
+ Times.Once
+ );
+
+ _authRepoMock.Verify(
+ x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
+ Times.Once
+ );
+
+ _passwordInfraMock.Verify(
+ x => x.Verify(It.IsAny(), It.IsAny()),
+ Times.Once
+ );
+ }
+}
diff --git a/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs
new file mode 100644
index 0000000..2b909bc
--- /dev/null
+++ b/src/Core/Service/Service.Auth.Tests/RegisterService.test.cs
@@ -0,0 +1,501 @@
+using Domain.Entities;
+using Domain.Exceptions;
+using FluentAssertions;
+using Infrastructure.Email;
+using Infrastructure.Email.Templates.Rendering;
+using Infrastructure.PasswordHashing;
+using Infrastructure.Repository.Auth;
+using Moq;
+
+namespace Service.Auth.Tests;
+
+public class RegisterServiceTest
+{
+ private readonly Mock _authRepoMock;
+ private readonly Mock _passwordInfraMock;
+ private readonly Mock _emailProviderMock;
+ private readonly Mock _emailTemplateProviderMock;
+ private readonly RegisterService _registerService;
+
+ public RegisterServiceTest()
+ {
+ _authRepoMock = new Mock();
+ _passwordInfraMock = new Mock();
+ _emailProviderMock = new Mock();
+ _emailTemplateProviderMock = new Mock();
+
+ _registerService = new RegisterService(
+ _authRepoMock.Object,
+ _passwordInfraMock.Object,
+ _emailProviderMock.Object,
+ _emailTemplateProviderMock.Object
+ );
+ }
+
+ [Fact]
+ public async Task RegisterAsync_WithValidData_CreatesUserAndSendsEmail()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "newuser",
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+
+ const string password = "SecurePassword123!";
+ const string hashedPassword = "hashed_password_value";
+ var expectedUserId = Guid.NewGuid();
+ const string expectedEmailHtml = "Welcome!";
+
+ // Mock: No existing user
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
+ .ReturnsAsync((UserAccount?)null);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(userAccount.Email))
+ .ReturnsAsync((UserAccount?)null);
+
+ // Mock: Password hashing
+ _passwordInfraMock
+ .Setup(x => x.Hash(password))
+ .Returns(hashedPassword);
+
+ // Mock: User registration
+ _authRepoMock
+ .Setup(x =>
+ x.RegisterUserAsync(
+ userAccount.Username,
+ userAccount.FirstName,
+ userAccount.LastName,
+ userAccount.Email,
+ userAccount.DateOfBirth,
+ hashedPassword
+ )
+ )
+ .ReturnsAsync(
+ new UserAccount
+ {
+ UserAccountId = expectedUserId,
+ Username = userAccount.Username,
+ FirstName = userAccount.FirstName,
+ LastName = userAccount.LastName,
+ Email = userAccount.Email,
+ DateOfBirth = userAccount.DateOfBirth,
+ CreatedAt = DateTime.UtcNow,
+ }
+ );
+
+ // Mock: Email template rendering
+ _emailTemplateProviderMock
+ .Setup(x =>
+ x.RenderUserRegisteredEmailAsync(
+ userAccount.FirstName,
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(expectedEmailHtml);
+
+ // Mock: Email sending
+ _emailProviderMock
+ .Setup(x =>
+ x.SendAsync(
+ userAccount.Email,
+ "Welcome to The Biergarten App!",
+ expectedEmailHtml,
+ true
+ )
+ )
+ .Returns(Task.CompletedTask);
+
+ // Act
+ var result = await _registerService.RegisterAsync(userAccount, password);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.UserAccountId.Should().Be(expectedUserId);
+ result.Username.Should().Be(userAccount.Username);
+ result.Email.Should().Be(userAccount.Email);
+
+ // Verify all mocks were called as expected
+ _authRepoMock.Verify(
+ x => x.GetUserByUsernameAsync(userAccount.Username),
+ Times.Once
+ );
+ _authRepoMock.Verify(
+ x => x.GetUserByEmailAsync(userAccount.Email),
+ Times.Once
+ );
+ _passwordInfraMock.Verify(x => x.Hash(password), Times.Once);
+ _authRepoMock.Verify(
+ x =>
+ x.RegisterUserAsync(
+ userAccount.Username,
+ userAccount.FirstName,
+ userAccount.LastName,
+ userAccount.Email,
+ userAccount.DateOfBirth,
+ hashedPassword
+ ),
+ Times.Once
+ );
+ _emailTemplateProviderMock.Verify(
+ x =>
+ x.RenderUserRegisteredEmailAsync(
+ userAccount.FirstName,
+ It.IsAny()
+ ),
+ Times.Once
+ );
+ _emailProviderMock.Verify(
+ x =>
+ x.SendAsync(
+ userAccount.Email,
+ "Welcome to The Biergarten App!",
+ expectedEmailHtml,
+ true
+ ),
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task RegisterAsync_WithExistingUsername_ThrowsConflictException()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "existinguser",
+ FirstName = "Jane",
+ LastName = "Smith",
+ Email = "jane.smith@example.com",
+ DateOfBirth = new DateTime(1995, 5, 15),
+ };
+ var password = "Password123!";
+
+ var existingUser = new UserAccount
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "existinguser",
+ FirstName = "Existing",
+ LastName = "User",
+ Email = "existing@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
+ .ReturnsAsync(existingUser);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(userAccount.Email))
+ .ReturnsAsync((UserAccount?)null);
+
+ // Act
+ var act = async () => await _registerService.RegisterAsync(userAccount, password);
+
+ // Assert
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("Username or email already exists");
+
+ // Verify that registration was never called
+ _authRepoMock.Verify(
+ x =>
+ x.RegisterUserAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ ),
+ Times.Never
+ );
+
+ // Verify email was never sent
+ _emailProviderMock.Verify(
+ x =>
+ x.SendAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ ),
+ Times.Never
+ );
+ }
+
+ [Fact]
+ public async Task RegisterAsync_WithExistingEmail_ThrowsConflictException()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "newuser",
+ FirstName = "Jane",
+ LastName = "Smith",
+ Email = "existing@example.com",
+ DateOfBirth = new DateTime(1995, 5, 15),
+ };
+ var password = "Password123!";
+
+ var existingUser = new UserAccount
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "otheruser",
+ FirstName = "Existing",
+ LastName = "User",
+ Email = "existing@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
+ .ReturnsAsync((UserAccount?)null);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(userAccount.Email))
+ .ReturnsAsync(existingUser);
+
+ // Act
+ var act = async () => await _registerService.RegisterAsync(userAccount, password);
+
+ // Assert
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("Username or email already exists");
+
+ _authRepoMock.Verify(
+ x =>
+ x.RegisterUserAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ ),
+ Times.Never
+ );
+ }
+
+ [Fact]
+ public async Task RegisterAsync_PasswordIsHashed_BeforeStoringInDatabase()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "secureuser",
+ FirstName = "Secure",
+ LastName = "User",
+ Email = "secure@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+ var plainPassword = "PlainPassword123!";
+ var hashedPassword = "hashed_secure_password";
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _passwordInfraMock
+ .Setup(x => x.Hash(plainPassword))
+ .Returns(hashedPassword);
+
+ _authRepoMock
+ .Setup(x =>
+ x.RegisterUserAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ hashedPassword
+ )
+ )
+ .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
+
+ _emailTemplateProviderMock
+ .Setup(x =>
+ x.RenderUserRegisteredEmailAsync(
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync("");
+
+ // Act
+ await _registerService.RegisterAsync(userAccount, plainPassword);
+
+ // Assert
+ _passwordInfraMock.Verify(x => x.Hash(plainPassword), Times.Once);
+ _authRepoMock.Verify(
+ x =>
+ x.RegisterUserAsync(
+ userAccount.Username,
+ userAccount.FirstName,
+ userAccount.LastName,
+ userAccount.Email,
+ userAccount.DateOfBirth,
+ hashedPassword
+ ), // Verify hashed password is used
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task RegisterAsync_EmailConfirmationLink_ContainsUserEmail()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "testuser",
+ FirstName = "Test",
+ LastName = "User",
+ Email = "test@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+ var password = "Password123!";
+ string? capturedConfirmationLink = null;
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _passwordInfraMock
+ .Setup(x => x.Hash(It.IsAny()))
+ .Returns("hashed");
+
+ _authRepoMock
+ .Setup(x =>
+ x.RegisterUserAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(
+ new UserAccount
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = userAccount.Username,
+ FirstName = userAccount.FirstName,
+ LastName = userAccount.LastName,
+ Email = userAccount.Email,
+ DateOfBirth = userAccount.DateOfBirth,
+ }
+ );
+
+ _emailTemplateProviderMock
+ .Setup(x =>
+ x.RenderUserRegisteredEmailAsync(
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .Callback(
+ (_, link) => capturedConfirmationLink = link
+ )
+ .ReturnsAsync("");
+
+ // Act
+ await _registerService.RegisterAsync(userAccount, password);
+
+ // Assert
+ capturedConfirmationLink.Should().NotBeNull();
+ capturedConfirmationLink
+ .Should()
+ .Contain(Uri.EscapeDataString(userAccount.Email));
+ }
+
+ [Fact]
+ public async Task RegisterAsync_WhenEmailSendingFails_ExceptionPropagates()
+ {
+ // Arrange
+ var userAccount = new UserAccount
+ {
+ Username = "testuser",
+ FirstName = "Test",
+ LastName = "User",
+ Email = "test@example.com",
+ DateOfBirth = new DateTime(1990, 1, 1),
+ };
+ var password = "Password123!";
+
+ _authRepoMock
+ .Setup(x => x.GetUserByUsernameAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _authRepoMock
+ .Setup(x => x.GetUserByEmailAsync(It.IsAny()))
+ .ReturnsAsync((UserAccount?)null);
+
+ _passwordInfraMock
+ .Setup(x => x.Hash(It.IsAny()))
+ .Returns("hashed");
+
+ _authRepoMock
+ .Setup(x =>
+ x.RegisterUserAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync(
+ new UserAccount
+ {
+ UserAccountId = Guid.NewGuid(),
+ Email = userAccount.Email,
+ }
+ );
+
+ _emailTemplateProviderMock
+ .Setup(x =>
+ x.RenderUserRegisteredEmailAsync(
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ReturnsAsync("");
+
+ _emailProviderMock
+ .Setup(x =>
+ x.SendAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ThrowsAsync(
+ new InvalidOperationException("SMTP server unavailable")
+ );
+
+ // Act
+ var act = async () => await _registerService.RegisterAsync(userAccount, password);
+
+ // Assert
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("SMTP server unavailable");
+ }
+}
diff --git a/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj b/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj
new file mode 100644
index 0000000..75f637d
--- /dev/null
+++ b/src/Core/Service/Service.Auth.Tests/Service.Auth.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Service.Auth.Tests
+ Linux
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Core/Service/Service.Auth/Auth/ILoginService.cs b/src/Core/Service/Service.Auth/ILoginService.cs
similarity index 68%
rename from src/Core/Service/Service.Auth/Auth/ILoginService.cs
rename to src/Core/Service/Service.Auth/ILoginService.cs
index 9f9e022..f0bbf84 100644
--- a/src/Core/Service/Service.Auth/Auth/ILoginService.cs
+++ b/src/Core/Service/Service.Auth/ILoginService.cs
@@ -1,7 +1,6 @@
-using System.Threading.Tasks;
using Domain.Entities;
-namespace Service.Auth.Auth;
+namespace Service.Auth;
public interface ILoginService
{
diff --git a/src/Core/Service/Service.Auth/Auth/IRegisterService.cs b/src/Core/Service/Service.Auth/IRegisterService.cs
similarity index 70%
rename from src/Core/Service/Service.Auth/Auth/IRegisterService.cs
rename to src/Core/Service/Service.Auth/IRegisterService.cs
index 0cd5743..efb05b1 100644
--- a/src/Core/Service/Service.Auth/Auth/IRegisterService.cs
+++ b/src/Core/Service/Service.Auth/IRegisterService.cs
@@ -1,7 +1,6 @@
-using System.Threading.Tasks;
using Domain.Entities;
-namespace Service.Auth.Auth;
+namespace Service.Auth;
public interface IRegisterService
{
diff --git a/src/Core/Service/Service.Auth/Auth/LoginService.cs b/src/Core/Service/Service.Auth/LoginService.cs
similarity index 94%
rename from src/Core/Service/Service.Auth/Auth/LoginService.cs
rename to src/Core/Service/Service.Auth/LoginService.cs
index 754a5a4..2cb0902 100644
--- a/src/Core/Service/Service.Auth/Auth/LoginService.cs
+++ b/src/Core/Service/Service.Auth/LoginService.cs
@@ -1,10 +1,9 @@
-using System.Threading.Tasks;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
-namespace Service.Auth.Auth;
+namespace Service.Auth;
public class LoginService(
IAuthRepository authRepo,
diff --git a/src/Core/Service/Service.Auth/Auth/RegisterService.cs b/src/Core/Service/Service.Auth/RegisterService.cs
similarity index 95%
rename from src/Core/Service/Service.Auth/Auth/RegisterService.cs
rename to src/Core/Service/Service.Auth/RegisterService.cs
index 643876b..3554005 100644
--- a/src/Core/Service/Service.Auth/Auth/RegisterService.cs
+++ b/src/Core/Service/Service.Auth/RegisterService.cs
@@ -1,13 +1,11 @@
-using System.Threading.Tasks;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Email;
-using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
-namespace Service.Auth.Auth;
+namespace Service.Auth;
public class RegisterService(
IAuthRepository authRepo,