Refactor user registration process to use UserRegistrationDto;

implement user registration in seed app
This commit is contained in:
Aaron Po
2026-08-04 05:12:18 -04:00
parent d469c07250
commit c9a02dbedf
10 changed files with 186 additions and 144 deletions

View File

@@ -8,29 +8,29 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1"/> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1"/> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2"/> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9"/>
<PackageReference Include="dbup" Version="5.0.41"/>
<!-- <!--
remove this override once microsoft ships official fix to vulnerability remove this override once microsoft ships official fix to vulnerability
https: https:
--> -->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3"/> <PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj"/> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj"/> <ProjectReference
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj"/> Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj"/> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via <!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. --> ConnectionStrings.SqliteConnectionString at runtime. -->
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\seed.sqlite"/> <None CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -9,7 +9,7 @@ namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings public class ConnectionStrings
{ {
public static string SqlServerConnectionString => GetSqlServerConnectionString(); public static string SqlServerConnectionString => GetSqlServerConnectionString();
public static string SqliteConnectionString => "Data Source=SeedData/seed.sqlite"; public static string SqliteConnectionString => "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
private static string GetSqlServerConnectionString() private static string GetSqlServerConnectionString()
{ {

View File

@@ -4,6 +4,7 @@ using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData; using Database.Seed.PipelineData;
using Database.Seed.Sqlite; using Database.Seed.Sqlite;
using Features.Auth.DependencyInjection; using Features.Auth.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository; using Features.Breweries.Repository;
@@ -11,68 +12,109 @@ using Infrastructure.Sql;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using idunno.Password;
using Infrastructure.PasswordHashing;
using Domain.Entities;
IReadOnlyList<BreweryRecord> breweries; async static Task<int> main()
IReadOnlyList<UserRecord> users;
try
{ {
IReadOnlyList<BreweryRecord> breweries;
IReadOnlyList<UserRecord> users;
try
{
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
breweries = reader.ReadBreweryRecords(); breweries = reader.ReadBreweryRecords();
users = reader.ReadUserRecords(); users = reader.ReadUserRecords();
} }
catch (SqliteException ex) catch (SqliteException ex)
{ {
Console.ForegroundColor = ConsoleColor.Red; Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error opening database connection: {ex.Message}"); Console.WriteLine($"Error opening database connection: {ex.Message}");
Console.ResetColor(); Console.ResetColor();
return 1; return 1;
} }
ServiceCollection services = []; ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build()); services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>(); services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
services.AddFeaturesBreweries(); services.AddFeaturesBreweries();
services.AddFeaturesAuth(); services.AddFeaturesAuth();
using ServiceProvider provider = services.BuildServiceProvider(); using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository; IBreweryRepository breweryRepository;
IAuthRepository authRepository; IAuthRepository authRepository;
try IPasswordInfrastructure passwordInfrastructure;
{ try
{
breweryRepository = provider.GetRequiredService<IBreweryRepository>(); breweryRepository = provider.GetRequiredService<IBreweryRepository>();
authRepository = provider.GetRequiredService<IAuthRepository>(); authRepository = provider.GetRequiredService<IAuthRepository>();
} passwordInfrastructure = provider.GetRequiredService<IPasswordInfrastructure>();
catch (InvalidOperationException ex) }
{ catch (InvalidOperationException ex)
{
Console.ForegroundColor = ConsoleColor.Red; Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error configuring database connection: {ex.Message}"); Console.WriteLine($"Error configuring database connection: {ex.Message}");
Console.ResetColor(); Console.ResetColor();
return 1; return 1;
} }
Console.ForegroundColor = ConsoleColor.Green; Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine( Console.WriteLine(
$"Loaded {breweries.Count} breweries." $"Loaded {breweries.Count} breweries."
); );
Console.ResetColor(); Console.ResetColor();
for (int i = 0; i < breweries.Count; i++) for (int i = 0; i < breweries.Count; i++)
{ {
BreweryRecord brewery = breweries[i]; BreweryRecord brewery = breweries[i];
Console.WriteLine( Console.WriteLine(
$"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"); $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})");
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {users.Count} users.");
Console.ResetColor();
foreach (UserRecord user in users)
{
string username = user.User.Username;
string firstName = user.User.FirstName;
string lastName = user.User.LastName;
string email = user.Email;
DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth);
// Generate a password that is 64 characters long with 10 digits, 10 symbols,
// allowing upper and lower case letters, disallowing repeat characters.
string generatedPassword = PasswordGenerator.Generate(length: 64, numberOfDigits: 10, numberOfSymbols: 10);
string hashedPassword = passwordInfrastructure.Hash(generatedPassword);
UserRegistrationDto registrationDto = new(
username,
firstName,
lastName,
email,
dateOfBirth,
hashedPassword
);
try
{
UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto);
Console.WriteLine($"Created user: {createdUser.Username} ({createdUser.UserAccountId})");
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"Error creating user {username}: {ex.Message}");
Console.ResetColor();
}
}
return 0;
} }
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Loaded {users.Count} users.");
Console.ResetColor();
for (int i = 0; i < users.Count; i++) return await main();
{
UserRecord user = users[i];
Console.WriteLine($"{i + 1}:\t{user.User.FirstName} {user.User.LastName}\t ({user.Email})");
}
return 0;

View File

@@ -105,8 +105,15 @@ public sealed class PipelineSeedDataReader
private static IReadOnlyDictionary<long, City> ReadCities(SqliteConnection connection) private static IReadOnlyDictionary<long, City> ReadCities(SqliteConnection connection)
{ {
const string sql = """ const string sql = """
SELECT id, city, state_province, iso3166_2, country, iso3166_1, SELECT
local_languages_json, postal_code_country_format_regex, id,
city,
state_province,
iso3166_2,
country,
iso3166_1,
local_languages_json,
postal_code_country_format_regex,
postal_code_city_regex_json postal_code_city_regex_json
FROM cities; FROM cities;
"""; """;
@@ -115,25 +122,36 @@ public sealed class PipelineSeedDataReader
using SqliteDataReader reader = command.ExecuteReader(); using SqliteDataReader reader = command.ExecuteReader();
Dictionary<long, City> cities = []; Dictionary<long, City> cities = [];
const int cityNameBindIndex = 1;
const int stateProvinceBindIndex = 2;
const int iso31662BindIndex = 3;
const int countryBindIndex = 4;
const int iso31661BindIndex = 5;
const int localLanguagesBindIndex = 6;
const int countryFormatRegexBindIndex = 7;
const int cityRegexesBindIndex = 8;
while (reader.Read()) while (reader.Read())
cities[reader.GetInt64(0)] = new City cities[reader.GetInt64(0)] = new City
{ {
CityName = reader.GetString(1), CityName = reader.GetString(cityNameBindIndex),
StateProvince = reader.GetString(2), StateProvince = reader.GetString(stateProvinceBindIndex),
Iso31662 = reader.GetString(3), Iso31662 = reader.GetString(iso31662BindIndex),
Country = reader.GetString(4), Country = reader.GetString(countryBindIndex),
Iso31661 = reader.GetString(5), Iso31661 = reader.GetString(iso31661BindIndex),
LocalLanguages = DeserializeStringArray(reader.GetString(6)), LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)),
PostalCode = new PostalCodeSpec PostalCode = new PostalCodeSpec
{ {
CountryFormatRegex = reader.GetString(7), CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex),
CityRegexes = DeserializeStringArray(reader.GetString(8)) CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex))
} }
}; };
return cities; return cities;
} }
private static IReadOnlyList<string> DeserializeStringArray(string json) private static IReadOnlyList<string> DeserializeStringArray(string json)
{ {
return JsonSerializer.Deserialize<List<string>>(json) ?? []; return JsonSerializer.Deserialize<List<string>>(json) ?? [];

View File

@@ -68,6 +68,7 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
new UserRegistrationDto(
command.Username, command.Username,
command.FirstName, command.FirstName,
command.LastName, command.LastName,
@@ -76,6 +77,7 @@ public class RegisterUserHandlerTests
hashedPassword hashedPassword
) )
) )
)
.ReturnsAsync( .ReturnsAsync(
new UserAccount new UserAccount
{ {
@@ -139,15 +141,7 @@ public class RegisterUserHandlerTests
.WithMessage("Username or email already exists"); .WithMessage("Username or email already exists");
_authRepoMock.Verify( _authRepoMock.Verify(
x => x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Never Times.Never
); );
} }
@@ -192,12 +186,7 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
It.IsAny<string>(), It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
hashedPassword
) )
) )
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
@@ -215,12 +204,14 @@ public class RegisterUserHandlerTests
_authRepoMock.Verify( _authRepoMock.Verify(
x => x =>
x.RegisterUserAsync( x.RegisterUserAsync(
new UserRegistrationDto(
command.Username, command.Username,
command.FirstName, command.FirstName,
command.LastName, command.LastName,
command.Email, command.Email,
command.DateOfBirth, command.DateOfBirth,
hashedPassword hashedPassword
)
), ),
Times.Once Times.Once
); );
@@ -239,16 +230,7 @@ public class RegisterUserHandlerTests
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed"); _passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock _authRepoMock
.Setup(x => .Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync( .ReturnsAsync(
new UserAccount new UserAccount
{ {

View File

@@ -1,5 +1,6 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
using FluentAssertions; using FluentAssertions;
@@ -50,12 +51,14 @@ public class AuthRepositoryTests
AuthRepository repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync( UserAccount result = await repo.RegisterUserAsync(
new UserRegistrationDto(
"testuser", "testuser",
"Test", "Test",
"User", "User",
"test@example.com", "test@example.com",
new DateTime(1990, 1, 1), new DateTime(1990, 1, 1),
"hashedpassword123" "hashedpassword123"
)
); );
result.Should().NotBeNull(); result.Should().NotBeNull();

View File

@@ -38,12 +38,12 @@ public class RegisterUserHandler(
string hashed = passwordInfrastructure.Hash(request.Password); string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync( UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username, new UserRegistrationDto(request.Username,
request.FirstName, request.FirstName,
request.LastName, request.LastName,
request.Email, request.Email,
request.DateOfBirth, request.DateOfBirth,
hashed hashed)
); );
string accessToken = tokenService.GenerateAccessToken(createdUser); string accessToken = tokenService.GenerateAccessToken(createdUser);

View File

@@ -0,0 +1,10 @@
namespace Features.Auth.Dtos;
public record UserRegistrationDto(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string PasswordHash
);

View File

@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
using Infrastructure.Sql; using Infrastructure.Sql;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
@@ -35,12 +36,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception> /// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount> RegisterUserAsync( public async Task<UserAccount> RegisterUserAsync(
string username, UserRegistrationDto userRegistrationDto
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
) )
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -49,6 +45,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
command.CommandText = "USP_RegisterUser"; command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
var (username, firstName, lastName, email, dateOfBirth, passwordHash) =
userRegistrationDto;
AddParameter(command, "@Username", username); AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName); AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName); AddParameter(command, "@LastName", lastName);

View File

@@ -1,7 +1,7 @@
using Domain.Entities; using Domain.Entities;
using Features.Auth.Dtos;
namespace Features.Auth.Repository; namespace Features.Auth.Repository;
/// <summary> /// <summary>
/// Repository for authentication-related database operations including user registration and credential management. /// Repository for authentication-related database operations including user registration and credential management.
/// </summary> /// </summary>
@@ -11,20 +11,8 @@ public interface IAuthRepository
/// Registers a new user with account details and initial credential. /// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser /// Uses stored procedure: USP_RegisterUser
/// </summary> /// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<UserAccount> RegisterUserAsync( Task<UserAccount> RegisterUserAsync(
string username, UserRegistrationDto userRegistrationDto
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
); );
/// <summary> /// <summary>