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++)
{ {
@@ -247,4 +260,4 @@ static Table BuildUserTable(IReadOnlyList<UserRecord> users)
} }
return table; return table;
} }

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/>
@@ -112,8 +131,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 FROM SELECT 1 FROM
dbo.BreweryPost dbo.BreweryPost
WHERE WHERE
BreweryPostID = @BreweryPostId BreweryPostID = @BreweryPostId
""", """,
@@ -146,11 +165,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition( new CommandDefinition(
""" """
UPDATE dbo.BreweryPost UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName, SET BreweryName = @BreweryName,
Description = @Description, Description = @Description,
UpdatedAt = GETDATE() UpdatedAt = GETDATE()
WHERE WHERE
BreweryPostID = @BreweryPostId BreweryPostID = @BreweryPostId
AND RowVersion = @RowVersion AND RowVersion = @RowVersion
""", """,
new new
@@ -175,8 +194,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteAsync( await connection.ExecuteAsync(
new CommandDefinition( new CommandDefinition(
""" """
DELETE FROM DELETE FROM
dbo.BreweryPostLocation dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
@@ -190,8 +209,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM dbo.BreweryPostLocation FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
@@ -205,10 +224,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition( new CommandDefinition(
""" """
UPDATE dbo.BreweryPostLocation UPDATE dbo.BreweryPostLocation
SET CityID = @CityId, SET CityID = @CityId,
AddressLine1 = @AddressLine1, AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2, AddressLine2 = @AddressLine2,
PostalCode = @PostalCode, PostalCode = @PostalCode,
Coordinates = @Coordinates Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
@@ -280,9 +299,9 @@ public class BreweryRepository(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 DELETE FROM
dbo.BreweryPost dbo.BreweryPost
WHERE WHERE
BreweryPostID = @BreweryPostId BreweryPostID = @BreweryPostId
""", """,
new { BreweryPostId = id } new { BreweryPostId = id }
@@ -309,10 +328,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM FROM
dbo.UserAccount dbo.UserAccount
WHERE WHERE
UserAccountID = @PostedById UserAccountID = @PostedById
""", """,
new { brewery.PostedById }, new { brewery.PostedById },
@@ -328,10 +347,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteScalarAsync<int?>( await connection.ExecuteScalarAsync<int?>(
new CommandDefinition( new CommandDefinition(
""" """
SELECT 1 SELECT 1
FROM FROM
dbo.City dbo.City
WHERE WHERE
CityID = @CityId CityID = @CityId
""", """,
new { brewery.Location.CityId }, new { brewery.Location.CityId },
@@ -366,19 +385,19 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition( new CommandDefinition(
""" """
INSERT INTO dbo.BreweryPostLocation INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, (BreweryPostLocationID,
BreweryPostID, BreweryPostID,
CityID, CityID,
AddressLine1, AddressLine1,
AddressLine2, AddressLine2,
PostalCode, PostalCode,
Coordinates) Coordinates)
VALUES (@BreweryPostLocationId, VALUES (@BreweryPostLocationId,
@BreweryPostId, @BreweryPostId,
@CityId, @CityId,
@AddressLine1, @AddressLine1,
@AddressLine2, @AddressLine2,
@PostalCode, @PostalCode,
@Coordinates) @Coordinates)
""", """,
new new
@@ -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 }
); );
} }