Remove repository tests, usages of registration dto used in repo

This commit is contained in:
Aaron Po
2026-08-20 20:55:47 -04:00
parent 1855041910
commit e60683a415
29 changed files with 341 additions and 1351 deletions

View File

@@ -10,15 +10,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="Spectre.Console" Version="0.57.2" /> <PackageReference Include="Spectre.Console" Version="0.57.2" />
<!--
remove this override once microsoft ships official fix to vulnerability
https:
-->
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -28,14 +21,7 @@
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
ConnectionStrings.SqliteConnectionString at runtime. -->
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" /> <None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -24,7 +24,8 @@ static async Task<int> RunAsync()
try try
{ {
ServiceCollection services = []; ServiceCollection services = [];
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build()); services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().Build());
services.AddInfrastructureSql(); services.AddInfrastructureSql();
services.AddFeaturesBreweries(); services.AddFeaturesBreweries();
services.AddFeaturesLocations(); services.AddFeaturesLocations();
@@ -32,17 +33,23 @@ static async Task<int> RunAsync()
await using ServiceProvider provider = services.BuildServiceProvider(); await using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository = provider.GetRequiredService<IBreweryRepository>(); IBreweryRepository breweryRepository =
ILocationRepository locationRepository = provider.GetRequiredService<ILocationRepository>(); provider.GetRequiredService<IBreweryRepository>();
IAuthRepository authRepository = provider.GetRequiredService<IAuthRepository>(); ILocationRepository locationRepository =
provider.GetRequiredService<ILocationRepository>();
IAuthRepository authRepository =
provider.GetRequiredService<IAuthRepository>();
IPasswordInfrastructure passwordInfrastructure = IPasswordInfrastructure passwordInfrastructure =
provider.GetRequiredService<IPasswordInfrastructure>(); provider.GetRequiredService<IPasswordInfrastructure>();
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); .LeftJustified());
AnsiConsole.MarkupLine(
"[grey]Connecting to SQLite source and loading seed data...[/]");
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); PipelineSeedDataReader reader =
new(ConnectionStrings.SqliteConnectionString);
SeedData seedData = null!; SeedData seedData = null!;
IReadOnlyList<Guid> postedByIds = []; IReadOnlyList<Guid> postedByIds = [];
@@ -68,14 +75,16 @@ static async Task<int> RunAsync()
AnsiConsole.Write(BuildBreweryTable(seedData.Breweries)); AnsiConsole.Write(BuildBreweryTable(seedData.Breweries));
AnsiConsole.WriteLine(); 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.Write(BuildUserTable(seedData.Users));
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
AnsiConsole.WriteLine("Seed data loaded successfully."); AnsiConsole.WriteLine("Seed data loaded successfully.");
AnsiConsole.Write( 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 await AnsiConsole
@@ -134,16 +143,19 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
foreach (UserRecord userRecord in users) foreach (UserRecord userRecord in users)
{ {
UserAccount userAccount = await authRepository.RegisterUserAsync( UserAccount userAccount = await authRepository.RegisterUserAsync(
new UserRegistrationDto( new UserAccount()
userRecord.User.Username, {
userRecord.User.FirstName, FirstName = userRecord.User.FirstName,
userRecord.User.LastName, LastName = userRecord.User.LastName,
userRecord.Email, DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
DateTime.Parse(userRecord.DateOfBirth), Username = userRecord.User.Username,
passwordInfrastructure.Hash( Email = userRecord.Email,
PasswordGenerator.Generate(length: 64, numberOfDigits: 10, numberOfSymbols: 10) UserCredential = new UserCredential
) {
) Hash = passwordInfrastructure.Hash(
password: PasswordGenerator.Generate(64, 12, 12))
}
}
); );
userAccountIds.Add(userAccount.UserAccountId); userAccountIds.Add(userAccount.UserAccountId);
} }
@@ -166,7 +178,8 @@ static async Task LoadBreweriesIntoDatabaseAsync(
) )
{ {
if (posterUserIds.Count == 0) 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++) for (int i = 0; i < breweries.Count; i++)
{ {

View File

@@ -16,4 +16,8 @@ public sealed class UserAccount
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public byte[]? RowVersion { get; set; } public byte[]? RowVersion { get; set; }
// Navigation properties
public UserCredential? UserCredential { get; set; }
} }

View File

@@ -68,13 +68,13 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
new UserRegistrationDto( It.Is<UserAccount>(ua =>
command.Username, ua.FirstName == command.FirstName
command.FirstName, && ua.LastName == command.LastName
command.LastName, && ua.Username == command.Username
command.Email, && ua.Email == command.Email
command.DateOfBirth, && ua.DateOfBirth == command.DateOfBirth
hashedPassword && ua.UserCredential!.Hash == hashedPassword
) )
) )
) )
@@ -141,7 +141,7 @@ public class RegisterUserHandlerTests
.WithMessage("Username or email already exists"); .WithMessage("Username or email already exists");
_authRepoMock.Verify( _authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()), x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
Times.Never Times.Never
); );
} }
@@ -186,7 +186,7 @@ public class RegisterUserHandlerTests
_authRepoMock _authRepoMock
.Setup(x => .Setup(x =>
x.RegisterUserAsync( x.RegisterUserAsync(
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword) It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
) )
) )
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() }); .ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
@@ -204,13 +204,13 @@ public class RegisterUserHandlerTests
_authRepoMock.Verify( _authRepoMock.Verify(
x => x =>
x.RegisterUserAsync( x.RegisterUserAsync(
new UserRegistrationDto( It.Is<UserAccount>(ua =>
command.Username, ua.FirstName == command.FirstName
command.FirstName, && ua.LastName == command.LastName
command.LastName, && ua.Username == command.Username
command.Email, && ua.DateOfBirth == command.DateOfBirth
command.DateOfBirth, && ua.Email == command.Email
hashedPassword && ua.UserCredential!.Hash == hashedPassword
) )
), ),
Times.Once Times.Once
@@ -230,7 +230,7 @@ public class RegisterUserHandlerTests
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed"); _passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock _authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>())) .Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
.ReturnsAsync( .ReturnsAsync(
new UserAccount new UserAccount
{ {

View File

@@ -13,7 +13,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" /> <PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,7 +1,7 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.Login;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions; using FluentAssertions;
@@ -68,7 +68,7 @@ public class LoginHandlerTests
.Returns("refresh-token"); .Returns("refresh-token");
LoginPayload result = await _handler.Handle( LoginPayload result = await _handler.Handle(
new LoginQuery(username, "any-password"), new LoginCommand(username, "any-password"),
CancellationToken.None CancellationToken.None
); );
@@ -88,7 +88,7 @@ public class LoginHandlerTests
.ReturnsAsync((UserAccount?)null); .ReturnsAsync((UserAccount?)null);
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>(); await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify( _authRepoMock.Verify(
@@ -110,7 +110,7 @@ public class LoginHandlerTests
.ReturnsAsync((UserCredential?)null); .ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None); await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should() await act.Should()
.ThrowAsync<UnauthorizedException>() .ThrowAsync<UnauthorizedException>()
@@ -144,7 +144,7 @@ public class LoginHandlerTests
Func<Task<LoginPayload>> act = async () => Func<Task<LoginPayload>> act = async () =>
await _handler.Handle( await _handler.Handle(
new LoginQuery(username, "wrong-password"), new LoginCommand(username, "wrong-password"),
CancellationToken.None CancellationToken.None
); );

View File

@@ -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<Task> 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<Task> act = async () => await repo.RotateCredentialAsync(Guid.NewGuid(), "hash");
await act.Should().ThrowAsync<Domain.Exceptions.NotFoundException>();
}
}

View File

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

View File

@@ -1,10 +1,10 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Commands.Login;
/// <summary> /// <summary>
/// Authenticates a user using their username and password and issues new tokens. Bound directly /// Authenticates a user using their username and password and issues new tokens. Bound directly
/// from the request body of <c>POST /api/auth/login</c>. /// from the request body of <c>POST /api/auth/login</c>.
/// </summary> /// </summary>
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>; public record LoginCommand(string Username, string Password) : IRequest<LoginPayload>;

View File

@@ -6,22 +6,22 @@ using Features.Auth.Services;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using MediatR; using MediatR;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Commands.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens. /// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
/// </summary> /// </summary>
public class LoginHandler( public class LoginHandler(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService ITokenService tokenService
) : IRequestHandler<LoginQuery, LoginPayload> ) : IRequestHandler<LoginCommand, LoginPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential, /// 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. /// or the supplied password does not match the stored hash.
/// </exception> /// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
{ {
UserAccount user = UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username) await authRepo.GetUserByUsernameAsync(request.Username)

View File

@@ -1,9 +1,9 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Commands.Login;
/// <summary>Validates that <see cref="LoginQuery.Username" /> and <see cref="LoginQuery.Password" /> are present.</summary> /// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
public class LoginValidator : AbstractValidator<LoginQuery> public class LoginValidator : AbstractValidator<LoginCommand>
{ {
public LoginValidator() public LoginValidator()
{ {

View File

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

View File

@@ -1,9 +1,9 @@
using Features.Auth.Commands.ConfirmUser; using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Commands.Login;
using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.RefreshToken;
using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.RegisterUser;
using Features.Auth.Commands.ResendConfirmationEmail; using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -51,9 +51,9 @@ public class AuthController(IMediator mediator) : ControllerBase
/// </remarks> /// </remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginCommand command)
{ {
LoginPayload payload = await mediator.Send(query); LoginPayload payload = await mediator.Send(command);
return Ok( return Ok(
new ResponseBody<LoginPayload> new ResponseBody<LoginPayload>
{ {

View File

@@ -1,12 +0,0 @@
namespace Features.Auth.Dtos;
/// <summary>Data required to persist a new user account and its initial credential.</summary>
/// <param name="PasswordHash">The already-hashed password; never a plaintext value.</param>
public record UserRegistrationDto(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string PasswordHash
);

View File

@@ -20,4 +20,8 @@
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Queries\" />
</ItemGroup>
</Project> </Project>

View File

@@ -16,17 +16,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IAuthRepository IAuthRepository
{ {
private const string SelectColumns =
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion";
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>The account and credential are inserted within a single database transaction.</remarks> /// <remarks>The account and credential are inserted within a single database transaction.</remarks>
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto) public async Task<UserAccount> RegisterUserAsync(UserAccount ua)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(); await using DbTransaction transaction = await connection.BeginTransactionAsync();
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
Guid userAccountId; Guid userAccountId;
try try
@@ -38,15 +35,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
OUTPUT INSERTED.UserAccountID OUTPUT INSERTED.UserAccountID
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email); VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
""", """,
new { username, firstName, lastName, dateOfBirth, email }, new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email},
transaction transaction
) )
); );
int credentialRows = await connection.ExecuteAsync( int credentialRows = await connection.ExecuteAsync(
new CommandDefinition( 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 transaction
) )
); );
@@ -62,6 +62,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
throw; throw;
} }
// perform round trip to get new User entity
return await GetUserByIdAsync(userAccountId) return await GetUserByIdAsync(userAccountId)
?? throw new Exception("Failed to retrieve newly registered user."); ?? throw new Exception("Failed to retrieve newly registered user.");
} }
@@ -71,7 +72,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { Email = email }
); );
} }
@@ -81,7 +86,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { Username = username }
); );
} }
@@ -111,7 +120,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
bool exists = bool exists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
"SELECT 1 FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId", """
SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId }, new { UserAccountId = userAccountId },
transaction transaction
) )
@@ -122,7 +135,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteAsync( await connection.ExecuteAsync(
new CommandDefinition( 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 }, new { UserAccountId = userAccountId },
transaction transaction
) )
@@ -130,7 +147,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteAsync( await connection.ExecuteAsync(
new CommandDefinition( 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 }, new { UserAccountId = userAccountId, Hash = newPasswordHash },
transaction transaction
) )
@@ -150,7 +170,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { UserAccountId = userAccountId }
); );
} }
@@ -179,7 +203,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
try try
{ {
await connection.ExecuteAsync( await connection.ExecuteAsync(
"INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) VALUES (@UserAccountId, GETDATE())", """
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
VALUES (@UserAccountId, GETDATE())
""",
new { UserAccountId = userAccountId } new { UserAccountId = userAccountId }
); );
} }
@@ -197,7 +224,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
int? result = await connection.ExecuteScalarAsync<int?>( int? result = await connection.ExecuteScalarAsync<int?>(
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId", """
SELECT TOP 1 1
FROM dbo.UserVerification
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId } new { UserAccountId = userAccountId }
); );
return result.HasValue; return result.HasValue;

View File

@@ -13,7 +13,7 @@ public interface IAuthRepository
/// returns the created account. /// returns the created account.
/// </summary> /// </summary>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception> /// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto); Task<UserAccount> RegisterUserAsync(UserAccount ua);
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary> /// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
Task<UserAccount?> GetUserByEmailAsync(string email); Task<UserAccount?> GetUserByEmailAsync(string email);

View File

@@ -13,7 +13,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" /> <PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -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<Task> 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<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().ThrowAsync<ArgumentException>();
}
[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<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task> act = async () => await repo.UpdateAsync(brewery);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task> act = async () => await repo.UpdateAsync(brewery);
await act.Should().ThrowAsync<ConflictException>();
}
}

View File

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

View File

@@ -13,66 +13,60 @@ namespace Features.Breweries.Repository;
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic /// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT /// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>. /// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The two read methods below therefore read rows manually via <see cref="DbDataReader.GetFieldValue{T}" /> /// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
/// (the same approach the previous ADO.NET implementation used, which SqlClient supports specifically /// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// for UDT columns). All write methods use Dapper, since they only bind parameters and never
/// deserialize <c>Coordinates</c> back out of a result set.
/// </remarks> /// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), : Repository<BreweryPost>(connectionFactory),
IBreweryRepository 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
""";
/// <summary>
/// Joins from <c>dbo.BreweryPost</c> down to <c>dbo.Country</c> so a row carries everything needed
/// to populate <see cref="BreweryPost.Location" />'s full <c>City</c> → <c>StateProvince</c> →
/// <c>Country</c> chain. All joins are <c>LEFT JOIN</c> (matching the previous stored procedure's
/// behavior), since a brewery post may have no location.
/// </summary>
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
""";
/// <inheritdoc/> /// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id) public async Task<BreweryPost?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); IEnumerable<BreweryPost> results = await connection.QueryAsync<
command.CommandText = $""" BreweryPost,
SELECT {SelectColumns} BreweryPostLocation,
FROM {FromJoins} City,
WHERE bp.BreweryPostID = @BreweryPostId StateProvince,
"""; Country,
AddParameter(command, "@BreweryPostId", id); 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 results.SingleOrDefault();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -80,23 +74,48 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
int? offset) int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand(); return await connection.QueryAsync<
command.CommandText = $""" BreweryPost,
SELECT {SelectColumns} BreweryPostLocation,
FROM {FromJoins} City,
ORDER BY bp.CreatedAt DESC StateProvince,
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY Country,
"""; BreweryPost
AddParameter(command, "@Offset", offset ?? 0); >(
AddParameter(command, "@Limit", limit ?? int.MaxValue); """
SELECT
await using DbDataReader reader = await command.ExecuteReaderAsync(); bp.BreweryPostID,
List<BreweryPost> breweries = []; bp.PostedByID,
bp.BreweryName,
while (await reader.ReadAsync()) bp.Description,
breweries.Add(MapToEntity(reader)); bp.CreatedAt,
bp.UpdatedAt,
return breweries; 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"
);
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -405,100 +424,35 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
/// <summary> /// <summary>
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its /// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// associated <see cref="BreweryPostLocation" /> if a location row was joined in. /// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), <paramref name="location" /> is a blank instance produced
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined
/// in, its City/StateProvince/Country chain was too.
/// </summary> /// </summary>
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"); if (location.BreweryPostLocationId == Guid.Empty)
int ordPostedById = reader.GetOrdinal("PostedById"); return post;
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");
BreweryPost brewery = new() stateProvince.CountryId = country.CountryId;
{ stateProvince.Country = country;
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<byte[]>(ordRowVersion),
};
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); city.StateProvinceId = stateProvince.StateProvinceId;
if (!reader.IsDBNull(ordLocationId)) city.StateProvince = stateProvince;
{
int ordCoordinates = reader.GetOrdinal("Coordinates");
int ordAddressLine2 = reader.GetOrdinal("AddressLine2");
brewery.Location = new BreweryPostLocation location.BreweryPostId = post.BreweryPostId;
{ location.CityId = city.CityId;
BreweryPostLocationId = reader.GetGuid(ordLocationId), location.City = city;
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<byte[]>(ordCoordinates),
};
}
return brewery; post.Location = location;
} return post;
/// <summary>
/// Maps the current row's <c>City</c> → <c>StateProvince</c> → <c>Country</c> columns. The schema
/// enforces <c>NOT NULL</c> 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.
/// </summary>
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);
} }
} }

View File

@@ -13,7 +13,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" /> <PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -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<Task> act = async () => await repo.GetOrCreateCityIdAsync(Location);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

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

View File

@@ -45,13 +45,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
bool cityExists = bool cityExists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
"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 } new { location.CityName, StateProvinceId = stateProvinceId }
) is not null; ) is not null;
if (!cityExists) if (!cityExists)
await connection.ExecuteAsync( 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 } new { StateProvinceId = stateProvinceId, location.CityName }
); );
@@ -70,7 +77,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
bool exists = bool exists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
"SELECT 1 FROM dbo.Country WHERE ISO3166_1 = @ISO3166_1", """
SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1
""",
new { ISO3166_1 = isoCode } new { ISO3166_1 = isoCode }
) is not null; ) 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 // 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). // race is tolerated (the resulting duplicate row, if any, is out of scope for this method).
await connection.ExecuteAsync( 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 } new { CountryName = countryName, ISO3166_1 = isoCode }
); );
} }
@@ -108,13 +122,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
bool exists = bool exists =
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
"SELECT 1 FROM dbo.StateProvince WHERE ISO3166_2 = @ISO3166_2", """
SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2
""",
new { ISO3166_2 = isoCode } new { ISO3166_2 = isoCode }
) is not null; ) is not null;
if (!exists) if (!exists)
await connection.ExecuteAsync( 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 new
{ {
StateProvinceName = stateProvinceName, StateProvinceName = stateProvinceName,
@@ -128,7 +149,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>( return await connection.ExecuteScalarAsync<Guid?>(
"SELECT CountryID FROM dbo.Country WHERE ISO3166_1 = @ISO3166_1", """
SELECT CountryID
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1
""",
new { ISO3166_1 = isoCode } new { ISO3166_1 = isoCode }
); );
} }
@@ -137,7 +162,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>( return await connection.ExecuteScalarAsync<Guid?>(
"SELECT StateProvinceID FROM dbo.StateProvince WHERE ISO3166_2 = @ISO3166_2", """
SELECT StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2
""",
new { ISO3166_2 = isoCode } new { ISO3166_2 = isoCode }
); );
} }

View File

@@ -13,7 +13,6 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" /> <PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

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

View File

@@ -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<UserAccount> 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<Task> act = async () => await repo.UpdateAsync(account);
await act.Should().ThrowAsync<NotFoundException>();
}
[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<Task> act = async () => await repo.DeleteAsync(Guid.NewGuid());
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -13,15 +13,16 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IUserAccountRepository IUserAccountRepository
{ {
private const string SelectColumns =
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion";
/// <inheritdoc /> /// <inheritdoc />
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { UserAccountId = id }
); );
} }
@@ -35,8 +36,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>( return await connection.QueryAsync<UserAccount>(
$""" """
SELECT {SelectColumns} SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount FROM dbo.UserAccount
ORDER BY CreatedAt DESC ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
@@ -76,7 +77,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
int rows = await connection.ExecuteAsync( int rows = await connection.ExecuteAsync(
"DELETE FROM dbo.UserAccount WHERE UserAccountId = @UserAccountId", """
DELETE FROM dbo.UserAccount
WHERE UserAccountId = @UserAccountId
""",
new { UserAccountId = id } new { UserAccountId = id }
); );
@@ -89,7 +93,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { Username = username }
); );
} }
@@ -99,7 +107,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( return await connection.QueryFirstOrDefaultAsync<UserAccount>(
$"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 } new { Email = email }
); );
} }