From c9a02dbedf89c6dabd1b0dc9d030883757ff4a0d Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Tue, 4 Aug 2026 05:12:18 -0400 Subject: [PATCH] Refactor user registration process to use UserRegistrationDto; implement user registration in seed app --- .../Database.Seed/Database.Seed.csproj | 22 +-- .../DatabaseHelpers/ConnectionStrings.cs | 4 +- web/backend/Database/Database.Seed/Program.cs | 150 +++++++++++------- .../Sqlite/PipelineSeedDataReader.cs | 42 +++-- .../Commands/RegisterUserHandlerTests.cs | 56 +++---- .../Repository/AuthRepositoryTests.cs | 15 +- .../RegisterUser/RegisterUserHandler.cs | 4 +- .../Features.Auth/Dtos/UserRegistrationDto.cs | 10 ++ .../Repository/AuthRepository.cs | 11 +- .../Repository/IAuthRepository.cs | 16 +- 10 files changed, 186 insertions(+), 144 deletions(-) create mode 100644 web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 4dc3954b..7764cd68 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -8,29 +8,29 @@ - - - - - + + + - + - - - - + + + + - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs index fa07f2cc..9cd535d8 100644 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs @@ -9,7 +9,7 @@ namespace Database.Seed.DatabaseHelpers; public class ConnectionStrings { 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() { @@ -44,4 +44,4 @@ public class ConnectionStrings return builder.ConnectionString; } -} \ No newline at end of file +} diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index 9442365d..8d06ae88 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -4,6 +4,7 @@ using Database.Seed.DatabaseHelpers; using Database.Seed.PipelineData; using Database.Seed.Sqlite; using Features.Auth.DependencyInjection; +using Features.Auth.Dtos; using Features.Auth.Repository; using Features.Breweries.DependencyInjection; using Features.Breweries.Repository; @@ -11,68 +12,109 @@ using Infrastructure.Sql; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using idunno.Password; +using Infrastructure.PasswordHashing; +using Domain.Entities; -IReadOnlyList breweries; -IReadOnlyList users; -try +async static Task main() { - PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); - breweries = reader.ReadBreweryRecords(); - users = reader.ReadUserRecords(); -} -catch (SqliteException ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error opening database connection: {ex.Message}"); - Console.ResetColor(); - return 1; -} + IReadOnlyList breweries; + IReadOnlyList users; + try + { + PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); + breweries = reader.ReadBreweryRecords(); + users = reader.ReadUserRecords(); + } + catch (SqliteException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error opening database connection: {ex.Message}"); + Console.ResetColor(); + return 1; + } -ServiceCollection services = []; -services.AddSingleton(new ConfigurationBuilder().Build()); -services.AddSingleton(); -services.AddFeaturesBreweries(); -services.AddFeaturesAuth(); + ServiceCollection services = []; + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton(); + services.AddFeaturesBreweries(); + services.AddFeaturesAuth(); -using ServiceProvider provider = services.BuildServiceProvider(); + using ServiceProvider provider = services.BuildServiceProvider(); -IBreweryRepository breweryRepository; -IAuthRepository authRepository; -try -{ - breweryRepository = provider.GetRequiredService(); - authRepository = provider.GetRequiredService(); -} -catch (InvalidOperationException ex) -{ - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error configuring database connection: {ex.Message}"); - Console.ResetColor(); - return 1; -} + IBreweryRepository breweryRepository; + IAuthRepository authRepository; + IPasswordInfrastructure passwordInfrastructure; + try + { + breweryRepository = provider.GetRequiredService(); + authRepository = provider.GetRequiredService(); + passwordInfrastructure = provider.GetRequiredService(); + } + catch (InvalidOperationException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error configuring database connection: {ex.Message}"); + Console.ResetColor(); + return 1; + } -Console.ForegroundColor = ConsoleColor.Green; -Console.WriteLine( - $"Loaded {breweries.Count} breweries." -); -Console.ResetColor(); - -for (int i = 0; i < breweries.Count; i++) -{ - BreweryRecord brewery = breweries[i]; + Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine( - $"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"); + $"Loaded {breweries.Count} breweries." + ); + Console.ResetColor(); + + for (int i = 0; i < breweries.Count; i++) + { + BreweryRecord brewery = breweries[i]; + Console.WriteLine( + $"{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++) -{ - UserRecord user = users[i]; - Console.WriteLine($"{i + 1}:\t{user.User.FirstName} {user.User.LastName}\t ({user.Email})"); -} - -return 0; \ No newline at end of file +return await main(); diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 9f0ca70f..d7b16939 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -105,9 +105,16 @@ public sealed class PipelineSeedDataReader private static IReadOnlyDictionary ReadCities(SqliteConnection connection) { const string sql = """ - SELECT id, city, state_province, iso3166_2, country, iso3166_1, - local_languages_json, postal_code_country_format_regex, - postal_code_city_regex_json + SELECT + id, + city, + state_province, + iso3166_2, + country, + iso3166_1, + local_languages_json, + postal_code_country_format_regex, + postal_code_city_regex_json FROM cities; """; @@ -115,27 +122,38 @@ public sealed class PipelineSeedDataReader using SqliteDataReader reader = command.ExecuteReader(); Dictionary 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()) cities[reader.GetInt64(0)] = new City { - CityName = reader.GetString(1), - StateProvince = reader.GetString(2), - Iso31662 = reader.GetString(3), - Country = reader.GetString(4), - Iso31661 = reader.GetString(5), - LocalLanguages = DeserializeStringArray(reader.GetString(6)), + CityName = reader.GetString(cityNameBindIndex), + StateProvince = reader.GetString(stateProvinceBindIndex), + Iso31662 = reader.GetString(iso31662BindIndex), + Country = reader.GetString(countryBindIndex), + Iso31661 = reader.GetString(iso31661BindIndex), + LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)), PostalCode = new PostalCodeSpec { - CountryFormatRegex = reader.GetString(7), - CityRegexes = DeserializeStringArray(reader.GetString(8)) + CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex), + CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)) } }; return cities; } + private static IReadOnlyList DeserializeStringArray(string json) { return JsonSerializer.Deserialize>(json) ?? []; } -} \ No newline at end of file +} diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs index 999c3c7e..954c5374 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -68,12 +68,14 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + new UserRegistrationDto( + command.Username, + command.FirstName, + command.LastName, + command.Email, + command.DateOfBirth, + hashedPassword + ) ) ) .ReturnsAsync( @@ -139,15 +141,7 @@ public class RegisterUserHandlerTests .WithMessage("Username or email already exists"); _authRepoMock.Verify( - x => - x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ), + x => x.RegisterUserAsync(It.IsAny()), Times.Never ); } @@ -192,12 +186,7 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - hashedPassword + It.Is(dto => dto.PasswordHash == hashedPassword) ) ) .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); @@ -215,12 +204,14 @@ public class RegisterUserHandlerTests _authRepoMock.Verify( x => x.RegisterUserAsync( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + new UserRegistrationDto( + command.Username, + command.FirstName, + command.LastName, + command.Email, + command.DateOfBirth, + hashedPassword + ) ), Times.Once ); @@ -239,16 +230,7 @@ public class RegisterUserHandlerTests .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() - ) - ) + .Setup(x => x.RegisterUserAsync(It.IsAny())) .ReturnsAsync( new UserAccount { diff --git a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs index 1bc7e702..297241c7 100644 --- a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs @@ -1,5 +1,6 @@ using Apps72.Dev.Data.DbMocker; using Domain.Entities; +using Features.Auth.Dtos; using Features.Auth.Repository; using FluentAssertions; @@ -50,12 +51,14 @@ public class AuthRepositoryTests AuthRepository repo = CreateRepo(conn); UserAccount result = await repo.RegisterUserAsync( - "testuser", - "Test", - "User", - "test@example.com", - new DateTime(1990, 1, 1), - "hashedpassword123" + new UserRegistrationDto( + "testuser", + "Test", + "User", + "test@example.com", + new DateTime(1990, 1, 1), + "hashedpassword123" + ) ); result.Should().NotBeNull(); diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index 0adb2990..d056894c 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -38,12 +38,12 @@ public class RegisterUserHandler( string hashed = passwordInfrastructure.Hash(request.Password); UserAccount createdUser = await authRepo.RegisterUserAsync( - request.Username, + new UserRegistrationDto(request.Username, request.FirstName, request.LastName, request.Email, request.DateOfBirth, - hashed + hashed) ); string accessToken = tokenService.GenerateAccessToken(createdUser); diff --git a/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs b/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs new file mode 100644 index 00000000..5a637567 --- /dev/null +++ b/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs @@ -0,0 +1,10 @@ +namespace Features.Auth.Dtos; + +public record UserRegistrationDto( + string Username, + string FirstName, + string LastName, + string Email, + DateTime DateOfBirth, + string PasswordHash +); diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs index 03fe1380..46f8aca7 100644 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -1,6 +1,7 @@ using System.Data; using System.Data.Common; using Domain.Entities; +using Features.Auth.Dtos; using Infrastructure.Sql; using Microsoft.Data.SqlClient; @@ -35,12 +36,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) /// Thrown when the newly registered user cannot be retrieved after registration. /// Thrown when the database command fails. public async Task RegisterUserAsync( - string username, - string firstName, - string lastName, - string email, - DateTime dateOfBirth, - string passwordHash + UserRegistrationDto userRegistrationDto ) { await using DbConnection connection = await CreateConnection(); @@ -49,6 +45,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) command.CommandText = "USP_RegisterUser"; command.CommandType = CommandType.StoredProcedure; + var (username, firstName, lastName, email, dateOfBirth, passwordHash) = + userRegistrationDto; + AddParameter(command, "@Username", username); AddParameter(command, "@FirstName", firstName); AddParameter(command, "@LastName", lastName); diff --git a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs index d405891b..6a2570fc 100644 --- a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs @@ -1,7 +1,7 @@ using Domain.Entities; +using Features.Auth.Dtos; namespace Features.Auth.Repository; - /// /// Repository for authentication-related database operations including user registration and credential management. /// @@ -11,20 +11,8 @@ public interface IAuthRepository /// Registers a new user with account details and initial credential. /// Uses stored procedure: USP_RegisterUser /// - /// Unique username for the user - /// User's first name - /// User's last name - /// User's email address - /// User's date of birth - /// Hashed password - /// The newly created UserAccount with generated ID Task RegisterUserAsync( - string username, - string firstName, - string lastName, - string email, - DateTime dateOfBirth, - string passwordHash + UserRegistrationDto userRegistrationDto ); ///