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 }
);
}