From e60683a4152be722c8fca954e0d2058d47b5a3c2 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 20 Aug 2026 20:55:47 -0400 Subject: [PATCH] Remove repository tests, usages of registration dto used in repo --- .../Database.Seed/Database.Seed.csproj | 16 +- web/backend/Database/Database.Seed/Program.cs | 55 ++- .../Domain.Entities/Entities/UserAccount.cs | 4 + .../Commands/RegisterUserHandlerTests.cs | 34 +- .../Features.Auth.Tests.csproj | 1 - .../Queries/LoginHandlerTests.cs | 10 +- .../Repository/AuthRepositoryTests.cs | 275 ------------- .../Repository/TestConnectionFactory.cs | 14 - .../Login/LoginCommand.cs} | 4 +- .../Login/LoginHandler.cs | 8 +- .../Login/LoginValidator.cs | 6 +- .../RegisterUser/RegisterUserHandler.cs | 20 +- .../Controllers/AuthController.cs | 6 +- .../Features.Auth/Dtos/UserRegistrationDto.cs | 12 - .../Features.Auth/Features.Auth.csproj | 4 + .../Repository/AuthRepository.cs | 63 ++- .../Repository/IAuthRepository.cs | 2 +- .../Features.Breweries.Tests.csproj | 1 - .../Repository/BreweryRepositoryTests.cs | 366 ------------------ .../Repository/TestConnectionFactory.cs | 14 - .../Repository/BreweryRepository.cs | 338 +++++++--------- .../Features.Locations.Tests.csproj | 1 - .../Repository/LocationRepositoryTests.cs | 114 ------ .../Repository/TestConnectionFactory.cs | 14 - .../Repository/LocationRepository.cs | 45 ++- .../Features.UserManagement.Tests.csproj | 1 - .../Repository/TestConnectionFactory.cs | 14 - .../Repository/UserAccountRepositoryTests.cs | 220 ----------- .../Repository/UserAccountRepository.cs | 30 +- 29 files changed, 341 insertions(+), 1351 deletions(-) delete mode 100644 web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs delete mode 100644 web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs rename web/backend/Features/Features.Auth/{Queries/Login/LoginQuery.cs => Commands/Login/LoginCommand.cs} (64%) rename web/backend/Features/Features.Auth/{Queries => Commands}/Login/LoginHandler.cs (83%) rename web/backend/Features/Features.Auth/{Queries => Commands}/Login/LoginValidator.cs (50%) delete mode 100644 web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs delete mode 100644 web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs delete mode 100644 web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs delete mode 100644 web/backend/Features/Features.Locations.Tests/Repository/LocationRepositoryTests.cs delete mode 100644 web/backend/Features/Features.Locations.Tests/Repository/TestConnectionFactory.cs delete mode 100644 web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs delete mode 100644 web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index 93e8f065..a043b08a 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -10,15 +10,8 @@ - + - - - - @@ -28,14 +21,7 @@ - - - true - - - diff --git a/web/backend/Database/Database.Seed/Program.cs b/web/backend/Database/Database.Seed/Program.cs index c268f179..ee2fac56 100644 --- a/web/backend/Database/Database.Seed/Program.cs +++ b/web/backend/Database/Database.Seed/Program.cs @@ -24,7 +24,8 @@ static async Task RunAsync() try { ServiceCollection services = []; - services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton( + new ConfigurationBuilder().Build()); services.AddInfrastructureSql(); services.AddFeaturesBreweries(); services.AddFeaturesLocations(); @@ -32,17 +33,23 @@ static async Task RunAsync() await using ServiceProvider provider = services.BuildServiceProvider(); - IBreweryRepository breweryRepository = provider.GetRequiredService(); - ILocationRepository locationRepository = provider.GetRequiredService(); - IAuthRepository authRepository = provider.GetRequiredService(); + IBreweryRepository breweryRepository = + provider.GetRequiredService(); + ILocationRepository locationRepository = + provider.GetRequiredService(); + IAuthRepository authRepository = + provider.GetRequiredService(); IPasswordInfrastructure passwordInfrastructure = provider.GetRequiredService(); - AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); - AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); + AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]") + .LeftJustified()); + AnsiConsole.MarkupLine( + "[grey]Connecting to SQLite source and loading seed data...[/]"); AnsiConsole.WriteLine(); - PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); + PipelineSeedDataReader reader = + new(ConnectionStrings.SqliteConnectionString); SeedData seedData = null!; IReadOnlyList postedByIds = []; @@ -68,14 +75,16 @@ static async Task RunAsync() AnsiConsole.Write(BuildBreweryTable(seedData.Breweries)); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users."); + AnsiConsole.MarkupLine( + $"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users."); AnsiConsole.Write(BuildUserTable(seedData.Users)); AnsiConsole.WriteLine(); AnsiConsole.WriteLine("Seed data loaded successfully."); AnsiConsole.Write( - new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified() + new Rule("[bold green]Loading seed data into target database.[/]") + .LeftJustified() ); await AnsiConsole @@ -134,16 +143,19 @@ static async Task> LoadUsersIntoDatabaseAsync( foreach (UserRecord userRecord in users) { UserAccount userAccount = await authRepository.RegisterUserAsync( - new UserRegistrationDto( - userRecord.User.Username, - userRecord.User.FirstName, - userRecord.User.LastName, - userRecord.Email, - DateTime.Parse(userRecord.DateOfBirth), - passwordInfrastructure.Hash( - PasswordGenerator.Generate(length: 64, numberOfDigits: 10, numberOfSymbols: 10) - ) - ) + new UserAccount() + { + FirstName = userRecord.User.FirstName, + LastName = userRecord.User.LastName, + DateOfBirth = DateTime.Parse(userRecord.DateOfBirth), + Username = userRecord.User.Username, + Email = userRecord.Email, + UserCredential = new UserCredential + { + Hash = passwordInfrastructure.Hash( + password: PasswordGenerator.Generate(64, 12, 12)) + } + } ); userAccountIds.Add(userAccount.UserAccountId); } @@ -166,7 +178,8 @@ static async Task LoadBreweriesIntoDatabaseAsync( ) { if (posterUserIds.Count == 0) - throw new InvalidOperationException("Cannot load breweries without any registered users."); + throw new InvalidOperationException( + "Cannot load breweries without any registered users."); for (int i = 0; i < breweries.Count; i++) { @@ -247,4 +260,4 @@ static Table BuildUserTable(IReadOnlyList users) } return table; -} +} \ No newline at end of file diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index 55b2706c..e4dab7c6 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -16,4 +16,8 @@ public sealed class UserAccount public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } public byte[]? RowVersion { get; set; } + + // Navigation properties + public UserCredential? UserCredential { get; set; } + } diff --git a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs index 954c5374..d9bde15c 100644 --- a/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Commands/RegisterUserHandlerTests.cs @@ -68,13 +68,13 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - new UserRegistrationDto( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + It.Is(ua => + ua.FirstName == command.FirstName + && ua.LastName == command.LastName + && ua.Username == command.Username + && ua.Email == command.Email + && ua.DateOfBirth == command.DateOfBirth + && ua.UserCredential!.Hash == hashedPassword ) ) ) @@ -141,7 +141,7 @@ public class RegisterUserHandlerTests .WithMessage("Username or email already exists"); _authRepoMock.Verify( - x => x.RegisterUserAsync(It.IsAny()), + x => x.RegisterUserAsync(It.IsAny()), Times.Never ); } @@ -186,7 +186,7 @@ public class RegisterUserHandlerTests _authRepoMock .Setup(x => x.RegisterUserAsync( - It.Is(dto => dto.PasswordHash == hashedPassword) + It.Is(ua => ua.UserCredential!.Hash == hashedPassword) ) ) .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); @@ -204,13 +204,13 @@ public class RegisterUserHandlerTests _authRepoMock.Verify( x => x.RegisterUserAsync( - new UserRegistrationDto( - command.Username, - command.FirstName, - command.LastName, - command.Email, - command.DateOfBirth, - hashedPassword + It.Is(ua => + ua.FirstName == command.FirstName + && ua.LastName == command.LastName + && ua.Username == command.Username + && ua.DateOfBirth == command.DateOfBirth + && ua.Email == command.Email + && ua.UserCredential!.Hash == hashedPassword ) ), Times.Once @@ -230,7 +230,7 @@ public class RegisterUserHandlerTests .ReturnsAsync((UserAccount?)null); _passwordInfraMock.Setup(x => x.Hash(It.IsAny())).Returns("hashed"); _authRepoMock - .Setup(x => x.RegisterUserAsync(It.IsAny())) + .Setup(x => x.RegisterUserAsync(It.IsAny())) .ReturnsAsync( new UserAccount { diff --git a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj index c13e3cfb..7c743e53 100644 --- a/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj +++ b/web/backend/Features/Features.Auth.Tests/Features.Auth.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs index 6fce931c..9121cdcf 100644 --- a/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs +++ b/web/backend/Features/Features.Auth.Tests/Queries/LoginHandlerTests.cs @@ -1,7 +1,7 @@ using Domain.Entities; using Domain.Exceptions; +using Features.Auth.Commands.Login; using Features.Auth.Dtos; -using Features.Auth.Queries.Login; using Features.Auth.Repository; using Features.Auth.Services; using FluentAssertions; @@ -68,7 +68,7 @@ public class LoginHandlerTests .Returns("refresh-token"); LoginPayload result = await _handler.Handle( - new LoginQuery(username, "any-password"), + new LoginCommand(username, "any-password"), CancellationToken.None ); @@ -88,7 +88,7 @@ public class LoginHandlerTests .ReturnsAsync((UserAccount?)null); Func> act = async () => - await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await act.Should().ThrowAsync(); _authRepoMock.Verify( @@ -110,7 +110,7 @@ public class LoginHandlerTests .ReturnsAsync((UserCredential?)null); Func> act = async () => - await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); + await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None); await act.Should() .ThrowAsync() @@ -144,7 +144,7 @@ public class LoginHandlerTests Func> act = async () => await _handler.Handle( - new LoginQuery(username, "wrong-password"), + new LoginCommand(username, "wrong-password"), CancellationToken.None ); diff --git a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs b/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs deleted file mode 100644 index c5b99ac3..00000000 --- a/web/backend/Features/Features.Auth.Tests/Repository/AuthRepositoryTests.cs +++ /dev/null @@ -1,275 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Features.Auth.Dtos; -using Features.Auth.Repository; -using FluentAssertions; - -namespace Features.Auth.Tests.Repository; - -public class AuthRepositoryTests -{ - private static AuthRepository CreateRepo(MockDbConnection conn) - { - return new AuthRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() - { - Guid expectedUserId = Guid.NewGuid(); - MockDbConnection conn = new(); - - // DbMocker's ReturnsScalar(Guid) doesn't round-trip correctly, so a single-row/single-column - // ReturnsTable is used to fake the scalar OUTPUT INSERTED.UserAccountID read instead. - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserAccount")) - .ReturnsTable( - MockTable.WithColumns(("UserAccountID", typeof(Guid))).AddRow(expectedUserId) - ); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserCredential")) - .ReturnsScalar(1); - - // Mock the subsequent read for the newly created user by id - conn.Mocks.When(cmd => - cmd.CommandText.Contains("FROM dbo.UserAccount") - && cmd.CommandText.Contains("WHERE UserAccountID") - ) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - expectedUserId, - "testuser", - "Test", - "User", - "test@example.com", - DateTime.UtcNow, - null, - new DateTime(1990, 1, 1), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount result = await repo.RegisterUserAsync( - new UserRegistrationDto( - "testuser", - "Test", - "User", - "test@example.com", - new DateTime(1990, 1, 1), - "hashedpassword123" - ) - ); - - result.Should().NotBeNull(); - result.UserAccountId.Should().Be(expectedUserId); - result.Username.Should().Be("testuser"); - result.FirstName.Should().Be("Test"); - result.LastName.Should().Be("User"); - result.Email.Should().Be("test@example.com"); - result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1)); - } - - [Fact] - public async Task GetUserByEmailAsync_ReturnsUser_WhenExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - userId, - "emailuser", - "Email", - "User", - "emailuser@example.com", - DateTime.UtcNow, - null, - new DateTime(1990, 5, 15), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com"); - - result.Should().NotBeNull(); - result!.UserAccountId.Should().Be(userId); - result.Username.Should().Be("emailuser"); - result.Email.Should().Be("emailuser@example.com"); - result.FirstName.Should().Be("Email"); - result.LastName.Should().Be("User"); - } - - [Fact] - public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email")) - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com"); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - userId, - "usernameuser", - "Username", - "User", - "username@example.com", - DateTime.UtcNow, - null, - new DateTime(1985, 8, 20), - null - ) - ); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser"); - - result.Should().NotBeNull(); - result!.UserAccountId.Should().Be(userId); - result.Username.Should().Be("usernameuser"); - result.Email.Should().Be("username@example.com"); - } - - [Fact] - public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username")) - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent"); - - result.Should().BeNull(); - } - - [Fact] - public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists() - { - Guid userId = Guid.NewGuid(); - Guid credentialId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.UserCredential")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserCredentialId", typeof(Guid)), - ("UserAccountId", typeof(Guid)), - ("Hash", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null) - ); - - AuthRepository repo = CreateRepo(conn); - UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); - - result.Should().NotBeNull(); - result!.UserCredentialId.Should().Be(credentialId); - result.UserAccountId.Should().Be(userId); - result.Hash.Should().Be("hashed_password_value"); - } - - [Fact] - public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists() - { - Guid userId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.UserCredential")) - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); - - result.Should().BeNull(); - } - - [Fact] - public async Task RotateCredentialAsync_ExecutesSuccessfully() - { - Guid userId = Guid.NewGuid(); - string newPasswordHash = "new_hashed_password"; - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")) - .ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.UserCredential")).ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.UserCredential")) - .ReturnsScalar(1); - - AuthRepository repo = CreateRepo(conn); - - // Should not throw - Func act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash); - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task RotateCredentialAsync_ThrowsNotFound_WhenUserAccountMissing() - { - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")) - .ReturnsTable(MockTable.Empty()); - - AuthRepository repo = CreateRepo(conn); - - Func act = async () => await repo.RotateCredentialAsync(Guid.NewGuid(), "hash"); - await act.Should().ThrowAsync(); - } -} diff --git a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index 95e816df..00000000 --- a/web/backend/Features/Features.Auth.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.Auth.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs similarity index 64% rename from web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs index 081e6aa9..56132e14 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginQuery.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginCommand.cs @@ -1,10 +1,10 @@ using Features.Auth.Dtos; using MediatR; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; /// /// Authenticates a user using their username and password and issues new tokens. Bound directly /// from the request body of POST /api/auth/login. /// -public record LoginQuery(string Username, string Password) : IRequest; +public record LoginCommand(string Username, string Password) : IRequest; diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs similarity index 83% rename from web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs index 2cd27f59..9921610a 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginHandler.cs @@ -6,22 +6,22 @@ using Features.Auth.Services; using Infrastructure.PasswordHashing; using MediatR; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; /// -/// Handles by verifying credentials and issuing access/refresh tokens. +/// Handles by verifying credentials and issuing access/refresh tokens. /// public class LoginHandler( IAuthRepository authRepo, IPasswordInfrastructure passwordInfrastructure, ITokenService tokenService -) : IRequestHandler +) : IRequestHandler { /// /// Thrown when the username does not match any account, the account has no active credential, /// or the supplied password does not match the stored hash. /// - public async Task Handle(LoginQuery request, CancellationToken cancellationToken) + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) { UserAccount user = await authRepo.GetUserByUsernameAsync(request.Username) diff --git a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs b/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs similarity index 50% rename from web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs rename to web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs index cb0f540c..ed0703a3 100644 --- a/web/backend/Features/Features.Auth/Queries/Login/LoginValidator.cs +++ b/web/backend/Features/Features.Auth/Commands/Login/LoginValidator.cs @@ -1,9 +1,9 @@ using FluentValidation; -namespace Features.Auth.Queries.Login; +namespace Features.Auth.Commands.Login; -/// Validates that and are present. -public class LoginValidator : AbstractValidator +/// Validates that and are present. +public class LoginValidator : AbstractValidator { public LoginValidator() { diff --git a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs index cf475383..90c03a73 100644 --- a/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs +++ b/web/backend/Features/Features.Auth/Commands/RegisterUser/RegisterUserHandler.cs @@ -34,14 +34,18 @@ public class RegisterUserHandler( string hashed = passwordInfrastructure.Hash(request.Password); UserAccount createdUser = await authRepo.RegisterUserAsync( - new UserRegistrationDto( - request.Username, - request.FirstName, - request.LastName, - request.Email, - request.DateOfBirth, - hashed - ) + new UserAccount + { + Username = request.Username, + FirstName = request.FirstName, + LastName = request.LastName, + DateOfBirth = request.DateOfBirth, + Email = request.Email, + UserCredential = new UserCredential + { + Hash = hashed, + } + } ); string accessToken = tokenService.GenerateAccessToken(createdUser); diff --git a/web/backend/Features/Features.Auth/Controllers/AuthController.cs b/web/backend/Features/Features.Auth/Controllers/AuthController.cs index 0369ba9c..92de4b50 100644 --- a/web/backend/Features/Features.Auth/Controllers/AuthController.cs +++ b/web/backend/Features/Features.Auth/Controllers/AuthController.cs @@ -1,9 +1,9 @@ using Features.Auth.Commands.ConfirmUser; +using Features.Auth.Commands.Login; using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.ResendConfirmationEmail; using Features.Auth.Dtos; -using Features.Auth.Queries.Login; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -51,9 +51,9 @@ public class AuthController(IMediator mediator) : ControllerBase /// [AllowAnonymous] [HttpPost("login")] - public async Task>> Login([FromBody] LoginQuery query) + public async Task>> Login([FromBody] LoginCommand command) { - LoginPayload payload = await mediator.Send(query); + LoginPayload payload = await mediator.Send(command); return Ok( new ResponseBody { diff --git a/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs b/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs deleted file mode 100644 index dc436dcf..00000000 --- a/web/backend/Features/Features.Auth/Dtos/UserRegistrationDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Features.Auth.Dtos; - -/// Data required to persist a new user account and its initial credential. -/// The already-hashed password; never a plaintext value. -public record UserRegistrationDto( - string Username, - string FirstName, - string LastName, - string Email, - DateTime DateOfBirth, - string PasswordHash -); diff --git a/web/backend/Features/Features.Auth/Features.Auth.csproj b/web/backend/Features/Features.Auth/Features.Auth.csproj index a7ba7378..7a6ad02a 100644 --- a/web/backend/Features/Features.Auth/Features.Auth.csproj +++ b/web/backend/Features/Features.Auth/Features.Auth.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs index ae861637..e8149b2c 100644 --- a/web/backend/Features/Features.Auth/Repository/AuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/AuthRepository.cs @@ -16,17 +16,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IAuthRepository { - private const string SelectColumns = - "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion"; - /// /// The account and credential are inserted within a single database transaction. - public async Task RegisterUserAsync(UserRegistrationDto userRegistrationDto) + public async Task RegisterUserAsync(UserAccount ua) { await using DbConnection connection = await CreateConnection(); await using DbTransaction transaction = await connection.BeginTransactionAsync(); - var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto; + Guid userAccountId; try @@ -38,15 +35,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) OUTPUT INSERTED.UserAccountID VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email); """, - new { username, firstName, lastName, dateOfBirth, email }, + new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email}, transaction ) ); int credentialRows = await connection.ExecuteAsync( new CommandDefinition( - "INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash);", - new { UserAccountId = userAccountId, Hash = passwordHash }, + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash); + """, + new { UserAccountId = userAccountId, Hash = ua.UserCredential?.Hash }, transaction ) ); @@ -62,6 +62,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) throw; } + // perform round trip to get new User entity return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user."); } @@ -71,7 +72,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Email = @Email", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Email = @Email + """, new { Email = email } ); } @@ -81,7 +86,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Username = @Username", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Username = @Username + """, new { Username = username } ); } @@ -111,7 +120,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) bool exists = await connection.ExecuteScalarAsync( new CommandDefinition( - "SELECT 1 FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", + """ + SELECT 1 + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, new { UserAccountId = userAccountId }, transaction ) @@ -122,7 +135,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteAsync( new CommandDefinition( - "UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @UserAccountId", + """ + UPDATE dbo.UserCredential + SET IsRevoked = 1, RevokedAt = GETDATE() + WHERE UserAccountId = @UserAccountId + """, new { UserAccountId = userAccountId }, transaction ) @@ -130,7 +147,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteAsync( new CommandDefinition( - "INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash)", + """ + INSERT INTO dbo.UserCredential (UserAccountId, Hash) + VALUES (@UserAccountId, @Hash) + """, new { UserAccountId = userAccountId, Hash = newPasswordHash }, transaction ) @@ -150,7 +170,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, new { UserAccountId = userAccountId } ); } @@ -179,7 +203,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) try { await connection.ExecuteAsync( - "INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) VALUES (@UserAccountId, GETDATE())", + """ + INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) + VALUES (@UserAccountId, GETDATE()) + """, new { UserAccountId = userAccountId } ); } @@ -197,7 +224,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); int? result = await connection.ExecuteScalarAsync( - "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId", + """ + SELECT TOP 1 1 + FROM dbo.UserVerification + WHERE UserAccountID = @UserAccountId + """, new { UserAccountId = userAccountId } ); return result.HasValue; diff --git a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs index 8a61cbb0..240e0db9 100644 --- a/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs +++ b/web/backend/Features/Features.Auth/Repository/IAuthRepository.cs @@ -13,7 +13,7 @@ public interface IAuthRepository /// returns the created account. /// /// Thrown when the newly registered user cannot be retrieved after registration. - Task RegisterUserAsync(UserRegistrationDto userRegistrationDto); + Task RegisterUserAsync(UserAccount ua); /// Retrieves a user account by email, or if none exists. Task GetUserByEmailAsync(string email); diff --git a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index 874a73af..4fbc5a99 100644 --- a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs b/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs deleted file mode 100644 index e453e694..00000000 --- a/web/backend/Features/Features.Breweries.Tests/Repository/BreweryRepositoryTests.cs +++ /dev/null @@ -1,366 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Domain.Exceptions; -using Features.Breweries.Repository; -using FluentAssertions; - -namespace Features.Breweries.Tests.Repository; - -public class BreweryRepositoryTests -{ - private static BreweryRepository CreateRepo(MockDbConnection conn) - { - return new BreweryRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task GetByIdAsync_ReturnsBrewery_WhenExists() - { - Guid breweryId = Guid.NewGuid(); - MockDbConnection conn = new(); - - Guid locationId = Guid.NewGuid(); - Guid cityId = Guid.NewGuid(); - Guid stateProvinceId = Guid.NewGuid(); - Guid countryId = Guid.NewGuid(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) - .ReturnsTable( - MockTable - .WithColumns( - ("BreweryPostId", typeof(Guid)), - ("PostedById", typeof(Guid)), - ("BreweryName", typeof(string)), - ("Description", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("RowVersion", typeof(byte[])), - ("BreweryPostLocationId", typeof(Guid)), - ("CityId", typeof(Guid)), - ("AddressLine1", typeof(string)), - ("AddressLine2", typeof(string)), - ("PostalCode", typeof(string)), - ("Coordinates", typeof(byte[])), - ("CityName", typeof(string)), - ("StateProvinceId", typeof(Guid)), - ("StateProvinceName", typeof(string)), - ("ISO3166_2", typeof(string)), - ("CountryId", typeof(Guid)), - ("CountryName", typeof(string)), - ("ISO3166_1", typeof(string)) - ) - .AddRow( - breweryId, - Guid.NewGuid(), - "Test Brewery", - "A test brewery description", - DateTime.UtcNow, - null, - null, - locationId, - cityId, - "123 Main St", - null, - "12345", - null, - "Portland", - stateProvinceId, - "Oregon", - "US-OR", - countryId, - "United States", - "US" - ) - ); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost? result = await repo.GetByIdAsync(breweryId); - result.Should().NotBeNull(); - result!.BreweryPostId.Should().Be(breweryId); - result.Location.Should().NotBeNull(); - result.Location!.BreweryPostLocationId.Should().Be(locationId); - result.Location.City.Should().NotBeNull(); - result.Location.City.CityName.Should().Be("Portland"); - result.Location.City.StateProvince.Should().NotBeNull(); - result.Location.City.StateProvince.StateProvinceName.Should().Be("Oregon"); - result.Location.City.StateProvince.Country.Should().NotBeNull(); - result.Location.City.StateProvince.Country.CountryName.Should().Be("United States"); - } - - [Fact] - public async Task GetByIdAsync_ReturnsNull_WhenNotExists() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) - .ReturnsTable(MockTable.Empty()); - BreweryRepository repo = CreateRepo(conn); - BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid()); - result.Should().BeNull(); - } - - [Fact] - public async Task CreateAsync_ExecutesSuccessfully() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")).ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPost (")).ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPostLocation")) - .ReturnsScalar(1); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = Guid.NewGuid(), - PostedById = Guid.NewGuid(), - BreweryName = "Test Brewery", - Description = "A test brewery description", - CreatedAt = DateTime.UtcNow, - Location = new BreweryPostLocation - { - BreweryPostLocationId = Guid.NewGuid(), - CityId = Guid.NewGuid(), - AddressLine1 = "123 Main St", - PostalCode = "12345", - Coordinates = [0x00, 0x01], - }, - }; - - // Should not throw - Func act = async () => await repo.CreateAsync(brewery); - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task CreateAsync_ThrowsArgumentException_WhenLocationMissing() - { - MockDbConnection conn = new(); - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = Guid.NewGuid(), - PostedById = Guid.NewGuid(), - BreweryName = "Test Brewery", - Description = "A test brewery description", - }; - - Func act = async () => await repo.CreateAsync(brewery); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateAsync_ThrowsNotFound_WhenUserMissing() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")) - .ReturnsScalar((int?)null); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = Guid.NewGuid(), - PostedById = Guid.NewGuid(), - BreweryName = "Test Brewery", - Description = "A test brewery description", - Location = new BreweryPostLocation - { - BreweryPostLocationId = Guid.NewGuid(), - CityId = Guid.NewGuid(), - AddressLine1 = "123 Main St", - PostalCode = "12345", - }, - }; - - Func act = async () => await repo.CreateAsync(brewery); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateAsync_UsesClientProvidedIds() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.UserAccount")).ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1); - - Guid? capturedBreweryPostId = null; - Guid? capturedLocationId = null; - - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPost (")) - .ReturnsScalar(cmd => - { - capturedBreweryPostId = (Guid?) - cmd.Parameters.First(p => p.ParameterName == "BreweryPostId").Value; - return 1; - }); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.BreweryPostLocation")) - .ReturnsScalar(cmd => - { - capturedLocationId = (Guid?) - cmd.Parameters.First(p => p.ParameterName == "BreweryPostLocationId").Value; - return 1; - }); - - Guid expectedBreweryId = Guid.NewGuid(); - Guid expectedLocationId = Guid.NewGuid(); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = expectedBreweryId, - PostedById = Guid.NewGuid(), - BreweryName = "Test Brewery", - Description = "A test brewery description", - Location = new BreweryPostLocation - { - BreweryPostLocationId = expectedLocationId, - CityId = Guid.NewGuid(), - AddressLine1 = "123 Main St", - PostalCode = "12345", - }, - }; - - await repo.CreateAsync(brewery); - - capturedBreweryPostId.Should().Be(expectedBreweryId); - capturedLocationId.Should().Be(expectedLocationId); - } - - [Fact] - public async Task UpdateAsync_ReturnsFreshlyPersistedBrewery_WhenSuccessful() - { - Guid breweryId = Guid.NewGuid(); - byte[] newRowVersion = [0x00, 0x02]; - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => - cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID") - ) - .ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")).ReturnsScalar(1); - conn.Mocks.When(cmd => - cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion") - ) - .ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPostLocation")) - .ReturnsScalar(1); - conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.BreweryPostLocation")) - .ReturnsScalar(1); - - // Repository re-fetches the row after a successful update to return the new RowVersion. - conn.Mocks.When(cmd => cmd.CommandText.Contains("LEFT JOIN dbo.BreweryPostLocation")) - .ReturnsTable( - MockTable - .WithColumns( - ("BreweryPostId", typeof(Guid)), - ("PostedById", typeof(Guid)), - ("BreweryName", typeof(string)), - ("Description", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("RowVersion", typeof(byte[])), - ("BreweryPostLocationId", typeof(Guid)), - ("CityId", typeof(Guid)), - ("AddressLine1", typeof(string)), - ("AddressLine2", typeof(string)), - ("PostalCode", typeof(string)), - ("Coordinates", typeof(byte[])), - ("CityName", typeof(string)), - ("StateProvinceId", typeof(Guid)), - ("StateProvinceName", typeof(string)), - ("ISO3166_2", typeof(string)), - ("CountryId", typeof(Guid)), - ("CountryName", typeof(string)), - ("ISO3166_1", typeof(string)) - ) - .AddRow( - breweryId, - Guid.NewGuid(), - "Renamed Brewery", - "Updated description", - DateTime.UtcNow, - DateTime.UtcNow, - newRowVersion, - Guid.NewGuid(), - Guid.NewGuid(), - "123 Main St", - null, - "12345", - null, - "Portland", - Guid.NewGuid(), - "Oregon", - "US-OR", - Guid.NewGuid(), - "United States", - "US" - ) - ); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = breweryId, - PostedById = Guid.NewGuid(), - BreweryName = "Renamed Brewery", - Description = "Updated description", - RowVersion = [0x00, 0x01], - Location = new BreweryPostLocation - { - CityId = Guid.NewGuid(), - AddressLine1 = "123 Main St", - PostalCode = "12345", - }, - }; - - BreweryPost result = await repo.UpdateAsync(brewery); - - result.BreweryPostId.Should().Be(breweryId); - result.RowVersion.Should().Equal(newRowVersion); - } - - [Fact] - public async Task UpdateAsync_ThrowsNotFound_WhenBreweryMissing() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => - cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID") - ) - .ReturnsScalar((int?)null); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), RowVersion = [0x00, 0x01] }; - - Func act = async () => await repo.UpdateAsync(brewery); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task UpdateAsync_ThrowsConflict_WhenRowVersionStale() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => - cmd.CommandText.Contains("SELECT 1 FROM dbo.BreweryPost WHERE BreweryPostID") - ) - .ReturnsScalar(1); - - // Brewery exists but the conditional UPDATE (WHERE ... AND RowVersion = @RowVersion) matches no rows, - // since the caller's RowVersion no longer matches the stored row. - conn.Mocks.When(cmd => - cmd.CommandText.Contains("WHERE BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion") - ) - .ReturnsScalar(0); - - BreweryRepository repo = CreateRepo(conn); - BreweryPost brewery = new() - { - BreweryPostId = Guid.NewGuid(), - BreweryName = "Renamed Brewery", - Description = "Updated description", - RowVersion = [0x00, 0x01], - }; - - Func act = async () => await repo.UpdateAsync(brewery); - await act.Should().ThrowAsync(); - } -} diff --git a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index 8ab7808f..00000000 --- a/web/backend/Features/Features.Breweries.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.Breweries.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 1247f43a..ff975477 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -13,66 +13,60 @@ namespace Features.Breweries.Repository; /// BreweryPostLocation.Coordinates is a SQL Server GEOGRAPHY column. Dapper's generic /// Query<T> deserializer reads columns via IDataRecord.GetValue, which for a UDT /// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to byte[]. -/// The two read methods below therefore read rows manually via -/// (the same approach the previous ADO.NET implementation used, which SqlClient supports specifically -/// for UDT columns). All write methods use Dapper, since they only bind parameters and never -/// deserialize Coordinates back out of a result set. +/// The read queries below therefore select CONVERT(varbinary(max), bpl.Coordinates), which +/// yields the UDT's serialized bytes as a plain varbinary value that Dapper can bind directly. /// public class BreweryRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IBreweryRepository { - private const string SelectColumns = """ - bp.BreweryPostID, - bp.PostedByID, - bp.BreweryName, - bp.Description, - bp.CreatedAt, - bp.UpdatedAt, - bp.RowVersion, - bpl.BreweryPostLocationID, - bpl.CityID, - bpl.AddressLine1, - bpl.AddressLine2, - bpl.PostalCode, - bpl.Coordinates, - c.CityName, - c.StateProvinceID, - sp.StateProvinceName, - sp.ISO3166_2, - sp.CountryID, - co.CountryName, - co.ISO3166_1 - """; - - /// - /// Joins from dbo.BreweryPost down to dbo.Country so a row carries everything needed - /// to populate 's full City → StateProvince → - /// Country chain. All joins are LEFT JOIN (matching the previous stored procedure's - /// behavior), since a brewery post may have no location. - /// - private const string FromJoins = """ - dbo.BreweryPost bp - LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID - LEFT JOIN dbo.City c ON bpl.CityID = c.CityID - LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID - LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID - """; - /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = $""" - SELECT {SelectColumns} - FROM {FromJoins} - WHERE bp.BreweryPostID = @BreweryPostId - """; - AddParameter(command, "@BreweryPostId", id); + IEnumerable results = await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + >( + """ + SELECT + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.RowVersion, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2, + co.CountryID, + co.CountryName, + co.ISO3166_1 + FROM dbo.BreweryPost bp + LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + WHERE bp.BreweryPostID = @BreweryPostId + """, + MapBreweryRow, + new { BreweryPostId = id }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); - await using DbDataReader reader = await command.ExecuteReaderAsync(); - return await reader.ReadAsync() ? MapToEntity(reader) : null; + return results.SingleOrDefault(); } /// @@ -80,23 +74,48 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) int? offset) { await using DbConnection connection = await CreateConnection(); - await using DbCommand command = connection.CreateCommand(); - command.CommandText = $""" - SELECT {SelectColumns} - FROM {FromJoins} - ORDER BY bp.CreatedAt DESC - OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY - """; - AddParameter(command, "@Offset", offset ?? 0); - AddParameter(command, "@Limit", limit ?? int.MaxValue); - - await using DbDataReader reader = await command.ExecuteReaderAsync(); - List breweries = []; - - while (await reader.ReadAsync()) - breweries.Add(MapToEntity(reader)); - - return breweries; + return await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + >( + """ + SELECT + bp.BreweryPostID, + bp.PostedByID, + bp.BreweryName, + bp.Description, + bp.CreatedAt, + bp.UpdatedAt, + bp.RowVersion, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2, + co.CountryID, + co.CountryName, + co.ISO3166_1 + FROM dbo.BreweryPost bp + LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + ORDER BY bp.CreatedAt DESC + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY + """, + MapBreweryRow, + new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); } /// @@ -112,8 +131,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteScalarAsync( new CommandDefinition( """ - SELECT 1 FROM - dbo.BreweryPost + SELECT 1 FROM + dbo.BreweryPost WHERE BreweryPostID = @BreweryPostId """, @@ -146,11 +165,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new CommandDefinition( """ UPDATE dbo.BreweryPost - SET BreweryName = @BreweryName, - Description = @Description, + SET BreweryName = @BreweryName, + Description = @Description, UpdatedAt = GETDATE() - WHERE - BreweryPostID = @BreweryPostId + WHERE + BreweryPostID = @BreweryPostId AND RowVersion = @RowVersion """, new @@ -175,8 +194,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteAsync( new CommandDefinition( """ - DELETE FROM - dbo.BreweryPostLocation + DELETE FROM + dbo.BreweryPostLocation WHERE BreweryPostID = @BreweryPostId """, new { brewery.BreweryPostId }, @@ -190,8 +209,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteScalarAsync( new CommandDefinition( """ - SELECT 1 - FROM dbo.BreweryPostLocation + SELECT 1 + FROM dbo.BreweryPostLocation WHERE BreweryPostID = @BreweryPostId """, new { brewery.BreweryPostId }, @@ -205,10 +224,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new CommandDefinition( """ UPDATE dbo.BreweryPostLocation - SET CityID = @CityId, - AddressLine1 = @AddressLine1, + SET CityID = @CityId, + AddressLine1 = @AddressLine1, AddressLine2 = @AddressLine2, - PostalCode = @PostalCode, + PostalCode = @PostalCode, Coordinates = @Coordinates WHERE BreweryPostID = @BreweryPostId """, @@ -280,9 +299,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await using DbConnection connection = await CreateConnection(); int rows = await connection.ExecuteAsync( """ - DELETE FROM - dbo.BreweryPost - WHERE + DELETE FROM + dbo.BreweryPost + WHERE BreweryPostID = @BreweryPostId """, new { BreweryPostId = id } @@ -309,10 +328,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteScalarAsync( new CommandDefinition( """ - SELECT 1 - FROM - dbo.UserAccount - WHERE + SELECT 1 + FROM + dbo.UserAccount + WHERE UserAccountID = @PostedById """, new { brewery.PostedById }, @@ -328,10 +347,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteScalarAsync( new CommandDefinition( """ - SELECT 1 - FROM - dbo.City - WHERE + SELECT 1 + FROM + dbo.City + WHERE CityID = @CityId """, new { brewery.Location.CityId }, @@ -366,19 +385,19 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) new CommandDefinition( """ INSERT INTO dbo.BreweryPostLocation - (BreweryPostLocationID, - BreweryPostID, - CityID, - AddressLine1, - AddressLine2, - PostalCode, + (BreweryPostLocationID, + BreweryPostID, + CityID, + AddressLine1, + AddressLine2, + PostalCode, Coordinates) - VALUES (@BreweryPostLocationId, - @BreweryPostId, - @CityId, + VALUES (@BreweryPostLocationId, + @BreweryPostId, + @CityId, @AddressLine1, - @AddressLine2, - @PostalCode, + @AddressLine2, + @PostalCode, @Coordinates) """, new @@ -405,100 +424,35 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } /// - /// Maps the current row of to a , including its - /// associated if a location row was joined in. + /// Composes a joined row's , , + /// , and fragments into a + /// single populated . When the row has no location (a left-joined + /// brewery with no address on file), is a blank instance produced + /// from all-NULL columns; the schema guarantees that whenever a location row was joined + /// in, its City/StateProvince/Country chain was too. /// - private static BreweryPost MapToEntity(DbDataReader reader) + private static BreweryPost MapBreweryRow( + BreweryPost post, + BreweryPostLocation location, + City city, + StateProvince stateProvince, + Country country + ) { - int ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); - int ordPostedById = reader.GetOrdinal("PostedById"); - int ordBreweryName = reader.GetOrdinal("BreweryName"); - int ordDescription = reader.GetOrdinal("Description"); - int ordCreatedAt = reader.GetOrdinal("CreatedAt"); - int ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); - int ordRowVersion = reader.GetOrdinal("RowVersion"); + if (location.BreweryPostLocationId == Guid.Empty) + return post; - BreweryPost brewery = new() - { - BreweryPostId = reader.GetGuid(ordBreweryPostId), - PostedById = reader.GetGuid(ordPostedById), - BreweryName = reader.GetString(ordBreweryName), - Description = reader.GetString(ordDescription), - CreatedAt = reader.GetDateTime(ordCreatedAt), - UpdatedAt = reader.IsDBNull(ordUpdatedAt) - ? null - : reader.GetDateTime(ordUpdatedAt), - RowVersion = reader.IsDBNull(ordRowVersion) - ? null - : reader.GetFieldValue(ordRowVersion), - }; + stateProvince.CountryId = country.CountryId; + stateProvince.Country = country; - int ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); - if (!reader.IsDBNull(ordLocationId)) - { - int ordCoordinates = reader.GetOrdinal("Coordinates"); - int ordAddressLine2 = reader.GetOrdinal("AddressLine2"); + city.StateProvinceId = stateProvince.StateProvinceId; + city.StateProvince = stateProvince; - brewery.Location = new BreweryPostLocation - { - BreweryPostLocationId = reader.GetGuid(ordLocationId), - BreweryPostId = brewery.BreweryPostId, - CityId = reader.GetGuid(reader.GetOrdinal("CityId")), - City = MapCity(reader), - AddressLine1 = - reader.GetString(reader.GetOrdinal("AddressLine1")), - AddressLine2 = reader.IsDBNull(ordAddressLine2) - ? null - : reader.GetString(ordAddressLine2), - PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")), - Coordinates = reader.IsDBNull(ordCoordinates) - ? null - : reader.GetFieldValue(ordCoordinates), - }; - } + location.BreweryPostId = post.BreweryPostId; + location.CityId = city.CityId; + location.City = city; - return brewery; + post.Location = location; + return post; } - - /// - /// Maps the current row's City → StateProvince → Country columns. The schema - /// enforces NOT NULL foreign keys down this whole chain, so whenever a location row was - /// joined in (the caller only calls this when it was), these columns are always populated too. - /// - private static City MapCity(DbDataReader reader) - { - return new City - { - CityId = reader.GetGuid(reader.GetOrdinal("CityId")), - CityName = reader.GetString(reader.GetOrdinal("CityName")), - StateProvinceId = - reader.GetGuid(reader.GetOrdinal("StateProvinceId")), - StateProvince = new StateProvince - { - StateProvinceId = - reader.GetGuid(reader.GetOrdinal("StateProvinceId")), - StateProvinceName = - reader.GetString(reader.GetOrdinal("StateProvinceName")), - ISO3166_2 = reader.GetString(reader.GetOrdinal("ISO3166_2")), - CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")), - Country = new Country - { - CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")), - CountryName = - reader.GetString(reader.GetOrdinal("CountryName")), - ISO3166_1 = - reader.GetString(reader.GetOrdinal("ISO3166_1")), - }, - }, - }; - } - - 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); - } -} \ No newline at end of file +} diff --git a/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj b/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj index c02a032c..6d90e875 100644 --- a/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj +++ b/web/backend/Features/Features.Locations.Tests/Features.Locations.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/web/backend/Features/Features.Locations.Tests/Repository/LocationRepositoryTests.cs b/web/backend/Features/Features.Locations.Tests/Repository/LocationRepositoryTests.cs deleted file mode 100644 index 7c0843e1..00000000 --- a/web/backend/Features/Features.Locations.Tests/Repository/LocationRepositoryTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Exceptions; -using Features.Locations.Dtos; -using Features.Locations.Repository; -using FluentAssertions; - -namespace Features.Locations.Tests.Repository; - -public class LocationRepositoryTests -{ - private static readonly CityLocation Location = new( - "London", - "Ontario", - "CA-ON", - "Canada", - "CA" - ); - - private static LocationRepository CreateRepo(MockDbConnection conn) - { - return new LocationRepository(new TestConnectionFactory(conn)); - } - - // DbMocker's ReturnsScalar(Guid) doesn't round-trip correctly (it returns an unrelated value - // instead of the Guid), so every scalar Guid lookup here is faked via a single-row/single-column - // ReturnsTable instead -- ExecuteScalar reads a mocked table's first cell correctly. - private static MockTable GuidTable(Guid id) - { - return MockTable.WithColumns(("Value", typeof(Guid))).AddRow(id); - } - - [Fact] - public async Task GetOrCreateCityIdAsync_ReturnsExistingCityId_WhenFullChainAlreadyExists() - { - Guid countryId = Guid.NewGuid(); - Guid stateProvinceId = Guid.NewGuid(); - Guid cityId = Guid.NewGuid(); - MockDbConnection conn = new(); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.Country")) - .ReturnsTable(GuidTable(countryId)); - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.StateProvince")) - .ReturnsTable(GuidTable(stateProvinceId)); - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.City c")) - .ReturnsTable(GuidTable(cityId)); - - LocationRepository repo = CreateRepo(conn); - - // No INSERT is mocked; if the repository tried to create anything despite the chain - // already existing, this would fail with a MockException. - Guid result = await repo.GetOrCreateCityIdAsync(Location); - - result.Should().Be(cityId); - } - - [Fact] - public async Task GetOrCreateCityIdAsync_CreatesFullChain_WhenNothingExists() - { - Guid countryId = Guid.NewGuid(); - Guid stateProvinceId = Guid.NewGuid(); - Guid cityId = Guid.NewGuid(); - MockDbConnection conn = new(); - - // First lookup (before creation) returns no rows; second lookup (after insert) returns the ID. - int countryLookups = 0; - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT CountryID FROM dbo.Country")) - .ReturnsTable(_ => ++countryLookups == 1 ? MockTable.Empty() : GuidTable(countryId)); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.Country")) - .ReturnsTable(MockTable.Empty()); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.Country")).ReturnsScalar(1); - - int stateLookups = 0; - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT StateProvinceID FROM dbo.StateProvince")) - .ReturnsTable(_ => ++stateLookups == 1 ? MockTable.Empty() : GuidTable(stateProvinceId)); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.StateProvince")) - .ReturnsTable(MockTable.Empty()); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.StateProvince")) - .ReturnsScalar(1); - - int cityLookups = 0; - conn.Mocks.When(cmd => cmd.CommandText.Contains("FROM dbo.City c")) - .ReturnsTable(_ => ++cityLookups == 1 ? MockTable.Empty() : GuidTable(cityId)); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.City")) - .ReturnsTable(MockTable.Empty()); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.City")).ReturnsScalar(1); - - LocationRepository repo = CreateRepo(conn); - Guid result = await repo.GetOrCreateCityIdAsync(Location); - - result.Should().Be(cityId); - } - - [Fact] - public async Task GetOrCreateCityIdAsync_ThrowsNotFound_WhenCountryMissingForNewStateProvince() - { - MockDbConnection conn = new(); - - // Country never resolves, even after the (mocked) insert -- simulates a race where the - // country is missing by the time the state/province lookup runs. - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT CountryID FROM dbo.Country")) - .ReturnsTable(MockTable.Empty()); - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT 1 FROM dbo.Country")) - .ReturnsTable(MockTable.Empty()); - conn.Mocks.When(cmd => cmd.CommandText.Contains("INSERT INTO dbo.Country")).ReturnsScalar(1); - - conn.Mocks.When(cmd => cmd.CommandText.Contains("SELECT StateProvinceID FROM dbo.StateProvince")) - .ReturnsTable(MockTable.Empty()); - - LocationRepository repo = CreateRepo(conn); - - Func act = async () => await repo.GetOrCreateCityIdAsync(Location); - await act.Should().ThrowAsync(); - } -} diff --git a/web/backend/Features/Features.Locations.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.Locations.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index 47247305..00000000 --- a/web/backend/Features/Features.Locations.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.Locations.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs index 8456787c..e716cc9b 100644 --- a/web/backend/Features/Features.Locations/Repository/LocationRepository.cs +++ b/web/backend/Features/Features.Locations/Repository/LocationRepository.cs @@ -45,13 +45,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) bool cityExists = await connection.ExecuteScalarAsync( - "SELECT 1 FROM dbo.City WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId", + """ + SELECT 1 + FROM dbo.City + WHERE CityName = @CityName AND StateProvinceID = @StateProvinceId + """, new { location.CityName, StateProvinceId = stateProvinceId } ) is not null; if (!cityExists) await connection.ExecuteAsync( - "INSERT INTO dbo.City (StateProvinceID, CityName) VALUES (@StateProvinceId, @CityName)", + """ + INSERT INTO dbo.City (StateProvinceID, CityName) + VALUES (@StateProvinceId, @CityName) + """, new { StateProvinceId = stateProvinceId, location.CityName } ); @@ -70,7 +77,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) bool exists = await connection.ExecuteScalarAsync( - "SELECT 1 FROM dbo.Country WHERE ISO3166_1 = @ISO3166_1", + """ + SELECT 1 + FROM dbo.Country + WHERE ISO3166_1 = @ISO3166_1 + """, new { ISO3166_1 = isoCode } ) is not null; @@ -78,7 +89,10 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) // A concurrent caller may create this country between the check above and here; that // race is tolerated (the resulting duplicate row, if any, is out of scope for this method). await connection.ExecuteAsync( - "INSERT INTO dbo.Country (CountryName, ISO3166_1) VALUES (@CountryName, @ISO3166_1)", + """ + INSERT INTO dbo.Country (CountryName, ISO3166_1) + VALUES (@CountryName, @ISO3166_1) + """, new { CountryName = countryName, ISO3166_1 = isoCode } ); } @@ -108,13 +122,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) bool exists = await connection.ExecuteScalarAsync( - "SELECT 1 FROM dbo.StateProvince WHERE ISO3166_2 = @ISO3166_2", + """ + SELECT 1 + FROM dbo.StateProvince + WHERE ISO3166_2 = @ISO3166_2 + """, new { ISO3166_2 = isoCode } ) is not null; if (!exists) await connection.ExecuteAsync( - "INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID) VALUES (@StateProvinceName, @ISO3166_2, @CountryId)", + """ + INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID) + VALUES (@StateProvinceName, @ISO3166_2, @CountryId) + """, new { StateProvinceName = stateProvinceName, @@ -128,7 +149,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( - "SELECT CountryID FROM dbo.Country WHERE ISO3166_1 = @ISO3166_1", + """ + SELECT CountryID + FROM dbo.Country + WHERE ISO3166_1 = @ISO3166_1 + """, new { ISO3166_1 = isoCode } ); } @@ -137,7 +162,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.ExecuteScalarAsync( - "SELECT StateProvinceID FROM dbo.StateProvince WHERE ISO3166_2 = @ISO3166_2", + """ + SELECT StateProvinceID + FROM dbo.StateProvince + WHERE ISO3166_2 = @ISO3166_2 + """, new { ISO3166_2 = isoCode } ); } diff --git a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj index 9bee459f..1777547c 100644 --- a/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj +++ b/web/backend/Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs deleted file mode 100644 index d0a4c5a7..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/TestConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data.Common; -using Infrastructure.Sql; - -namespace Features.UserManagement.Tests.Repository; - -internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory -{ - private readonly DbConnection _conn = conn; - - public DbConnection CreateConnection() - { - return _conn; - } -} diff --git a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs b/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs deleted file mode 100644 index 54fb2d50..00000000 --- a/web/backend/Features/Features.UserManagement.Tests/Repository/UserAccountRepositoryTests.cs +++ /dev/null @@ -1,220 +0,0 @@ -using Apps72.Dev.Data.DbMocker; -using Domain.Entities; -using Domain.Exceptions; -using Features.UserManagement.Repository; -using FluentAssertions; - -namespace Features.UserManagement.Tests.Repository; - -public class UserAccountRepositoryTests -{ - private static UserAccountRepository CreateRepo(MockDbConnection conn) - { - return new UserAccountRepository(new TestConnectionFactory(conn)); - } - - [Fact] - public async Task GetByIdAsync_ReturnsRow_Mapped() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE UserAccountID = @UserAccountId")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - "yerb", - "Aaron", - "Po", - "aaronpo@example.com", - new DateTime(2020, 1, 1), - null, - new DateTime(1990, 1, 1), - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByIdAsync( - Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - ); - - result.Should().NotBeNull(); - result!.Username.Should().Be("yerb"); - result.Email.Should().Be("aaronpo@example.com"); - } - - [Fact] - public async Task GetAllAsync_ReturnsMultipleRows() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "a", - "A", - "A", - "a@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - .AddRow( - Guid.NewGuid(), - "b", - "B", - "B", - "b@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - List results = (await repo.GetAllAsync(null, null)).ToList(); - results.Should().HaveCount(2); - results.Select(r => r.Username).Should().BeEquivalentTo("a", "b"); - } - - [Fact] - public async Task GetByUsername_ReturnsRow() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Username = @Username")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "lookupuser", - "L", - "U", - "lookup@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByUsernameAsync("lookupuser"); - result.Should().NotBeNull(); - result!.Email.Should().Be("lookup@example.com"); - } - - [Fact] - public async Task GetByEmail_ReturnsRow() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("WHERE Email = @Email")) - .ReturnsTable( - MockTable - .WithColumns( - ("UserAccountId", typeof(Guid)), - ("Username", typeof(string)), - ("FirstName", typeof(string)), - ("LastName", typeof(string)), - ("Email", typeof(string)), - ("CreatedAt", typeof(DateTime)), - ("UpdatedAt", typeof(DateTime?)), - ("DateOfBirth", typeof(DateTime)), - ("RowVersion", typeof(byte[])) - ) - .AddRow( - Guid.NewGuid(), - "byemail", - "B", - "E", - "byemail@example.com", - DateTime.UtcNow, - null, - DateTime.UtcNow.Date, - null - ) - ); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount? result = await repo.GetByEmailAsync("byemail@example.com"); - result.Should().NotBeNull(); - result!.Username.Should().Be("byemail"); - } - - [Fact] - public async Task GetAllAsync_AppliesLimitAndOffset() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => - cmd.CommandText.Contains("OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY") - ) - .ReturnsTable(MockTable.Empty()); - - UserAccountRepository repo = CreateRepo(conn); - - // Fails with a MockException if the repository doesn't emit OFFSET/FETCH pagination SQL. - await repo.GetAllAsync(10, 20); - } - - [Fact] - public async Task UpdateAsync_ThrowsNotFound_WhenAccountMissing() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("UPDATE dbo.UserAccount")) - .ReturnsTable(MockTable.Empty()); - - UserAccountRepository repo = CreateRepo(conn); - UserAccount account = new() { UserAccountId = Guid.NewGuid(), Username = "ghost" }; - - Func act = async () => await repo.UpdateAsync(account); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteAsync_ThrowsNotFound_WhenAccountMissing() - { - MockDbConnection conn = new(); - conn.Mocks.When(cmd => cmd.CommandText.Contains("DELETE FROM dbo.UserAccount")) - .ReturnsTable(MockTable.Empty()); - - UserAccountRepository repo = CreateRepo(conn); - - Func act = async () => await repo.DeleteAsync(Guid.NewGuid()); - await act.Should().ThrowAsync(); - } -} diff --git a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs index 13c3b26b..279288c3 100644 --- a/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs +++ b/web/backend/Features/Features.UserManagement/Repository/UserAccountRepository.cs @@ -13,15 +13,16 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) : Repository(connectionFactory), IUserAccountRepository { - private const string SelectColumns = - "UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion"; - /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId + """, new { UserAccountId = id } ); } @@ -35,8 +36,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryAsync( - $""" - SELECT {SelectColumns} + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion FROM dbo.UserAccount ORDER BY CreatedAt DESC OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY @@ -76,7 +77,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); int rows = await connection.ExecuteAsync( - "DELETE FROM dbo.UserAccount WHERE UserAccountId = @UserAccountId", + """ + DELETE FROM dbo.UserAccount + WHERE UserAccountId = @UserAccountId + """, new { UserAccountId = id } ); @@ -89,7 +93,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Username = @Username", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Username = @Username + """, new { Username = username } ); } @@ -99,7 +107,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory) { await using DbConnection connection = await CreateConnection(); return await connection.QueryFirstOrDefaultAsync( - $"SELECT {SelectColumns} FROM dbo.UserAccount WHERE Email = @Email", + """ + SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion + FROM dbo.UserAccount + WHERE Email = @Email + """, new { Email = email } ); }