mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Remove repository tests, usages of registration dto used in repo
This commit is contained in:
@@ -10,15 +10,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||
<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" />
|
||||
|
||||
|
||||
<!--
|
||||
remove this override once microsoft ships official fix to vulnerability
|
||||
https:
|
||||
-->
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,14 +21,7 @@
|
||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -24,7 +24,8 @@ static async Task<int> RunAsync()
|
||||
try
|
||||
{
|
||||
ServiceCollection services = [];
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().Build());
|
||||
services.AddInfrastructureSql();
|
||||
services.AddFeaturesBreweries();
|
||||
services.AddFeaturesLocations();
|
||||
@@ -32,17 +33,23 @@ static async Task<int> RunAsync()
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IBreweryRepository breweryRepository = provider.GetRequiredService<IBreweryRepository>();
|
||||
ILocationRepository locationRepository = provider.GetRequiredService<ILocationRepository>();
|
||||
IAuthRepository authRepository = provider.GetRequiredService<IAuthRepository>();
|
||||
IBreweryRepository breweryRepository =
|
||||
provider.GetRequiredService<IBreweryRepository>();
|
||||
ILocationRepository locationRepository =
|
||||
provider.GetRequiredService<ILocationRepository>();
|
||||
IAuthRepository authRepository =
|
||||
provider.GetRequiredService<IAuthRepository>();
|
||||
IPasswordInfrastructure passwordInfrastructure =
|
||||
provider.GetRequiredService<IPasswordInfrastructure>();
|
||||
|
||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
|
||||
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
|
||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
|
||||
.LeftJustified());
|
||||
AnsiConsole.MarkupLine(
|
||||
"[grey]Connecting to SQLite source and loading seed data...[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
|
||||
PipelineSeedDataReader reader =
|
||||
new(ConnectionStrings.SqliteConnectionString);
|
||||
|
||||
SeedData seedData = null!;
|
||||
IReadOnlyList<Guid> postedByIds = [];
|
||||
@@ -68,14 +75,16 @@ static async Task<int> RunAsync()
|
||||
AnsiConsole.Write(BuildBreweryTable(seedData.Breweries));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
|
||||
AnsiConsole.MarkupLine(
|
||||
$"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
|
||||
AnsiConsole.Write(BuildUserTable(seedData.Users));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
AnsiConsole.WriteLine("Seed data loaded successfully.");
|
||||
|
||||
AnsiConsole.Write(
|
||||
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
|
||||
new Rule("[bold green]Loading seed data into target database.[/]")
|
||||
.LeftJustified()
|
||||
);
|
||||
|
||||
await AnsiConsole
|
||||
@@ -134,16 +143,19 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
foreach (UserRecord userRecord in users)
|
||||
{
|
||||
UserAccount userAccount = await authRepository.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
userRecord.User.Username,
|
||||
userRecord.User.FirstName,
|
||||
userRecord.User.LastName,
|
||||
userRecord.Email,
|
||||
DateTime.Parse(userRecord.DateOfBirth),
|
||||
passwordInfrastructure.Hash(
|
||||
PasswordGenerator.Generate(length: 64, numberOfDigits: 10, numberOfSymbols: 10)
|
||||
)
|
||||
)
|
||||
new UserAccount()
|
||||
{
|
||||
FirstName = userRecord.User.FirstName,
|
||||
LastName = userRecord.User.LastName,
|
||||
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
||||
Username = userRecord.User.Username,
|
||||
Email = userRecord.Email,
|
||||
UserCredential = new UserCredential
|
||||
{
|
||||
Hash = passwordInfrastructure.Hash(
|
||||
password: PasswordGenerator.Generate(64, 12, 12))
|
||||
}
|
||||
}
|
||||
);
|
||||
userAccountIds.Add(userAccount.UserAccountId);
|
||||
}
|
||||
@@ -166,7 +178,8 @@ static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
)
|
||||
{
|
||||
if (posterUserIds.Count == 0)
|
||||
throw new InvalidOperationException("Cannot load breweries without any registered users.");
|
||||
throw new InvalidOperationException(
|
||||
"Cannot load breweries without any registered users.");
|
||||
|
||||
for (int i = 0; i < breweries.Count; i++)
|
||||
{
|
||||
|
||||
@@ -16,4 +16,8 @@ public sealed class UserAccount
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public UserCredential? UserCredential { get; set; }
|
||||
|
||||
}
|
||||
|
||||
@@ -68,13 +68,13 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
It.Is<UserAccount>(ua =>
|
||||
ua.FirstName == command.FirstName
|
||||
&& ua.LastName == command.LastName
|
||||
&& ua.Username == command.Username
|
||||
&& ua.Email == command.Email
|
||||
&& ua.DateOfBirth == command.DateOfBirth
|
||||
&& ua.UserCredential!.Hash == hashedPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -141,7 +141,7 @@ public class RegisterUserHandlerTests
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
|
||||
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
|
||||
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
@@ -204,13 +204,13 @@ public class RegisterUserHandlerTests
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
It.Is<UserAccount>(ua =>
|
||||
ua.FirstName == command.FirstName
|
||||
&& ua.LastName == command.LastName
|
||||
&& ua.Username == command.Username
|
||||
&& ua.DateOfBirth == command.DateOfBirth
|
||||
&& ua.Email == command.Email
|
||||
&& ua.UserCredential!.Hash == hashedPassword
|
||||
)
|
||||
),
|
||||
Times.Once
|
||||
@@ -230,7 +230,7 @@ public class RegisterUserHandlerTests
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Queries.Login;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
@@ -68,7 +68,7 @@ public class LoginHandlerTests
|
||||
.Returns("refresh-token");
|
||||
|
||||
LoginPayload result = await _handler.Handle(
|
||||
new LoginQuery(username, "any-password"),
|
||||
new LoginCommand(username, "any-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ public class LoginHandlerTests
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<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>();
|
||||
_authRepoMock.Verify(
|
||||
@@ -110,7 +110,7 @@ public class LoginHandlerTests
|
||||
.ReturnsAsync((UserCredential?)null);
|
||||
|
||||
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>()
|
||||
@@ -144,7 +144,7 @@ public class LoginHandlerTests
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new LoginQuery(username, "wrong-password"),
|
||||
new LoginCommand(username, "wrong-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.Login;
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <summary>
|
||||
/// 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>.
|
||||
/// </summary>
|
||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
||||
public record LoginCommand(string Username, string Password) : IRequest<LoginPayload>;
|
||||
@@ -6,22 +6,22 @@ using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Queries.Login;
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <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>
|
||||
public class LoginHandler(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
ITokenService tokenService
|
||||
) : IRequestHandler<LoginQuery, LoginPayload>
|
||||
) : IRequestHandler<LoginCommand, LoginPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// 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.
|
||||
/// </exception>
|
||||
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
||||
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
UserAccount user =
|
||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||
@@ -1,9 +1,9 @@
|
||||
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>
|
||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
|
||||
public class LoginValidator : AbstractValidator<LoginCommand>
|
||||
{
|
||||
public LoginValidator()
|
||||
{
|
||||
@@ -34,14 +34,18 @@ public class RegisterUserHandler(
|
||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||
new UserRegistrationDto(
|
||||
request.Username,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Email,
|
||||
request.DateOfBirth,
|
||||
hashed
|
||||
)
|
||||
new UserAccount
|
||||
{
|
||||
Username = request.Username,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
Email = request.Email,
|
||||
UserCredential = new UserCredential
|
||||
{
|
||||
Hash = hashed,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Queries.Login;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -51,9 +51,9 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[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(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -20,4 +20,8 @@
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Queries\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -16,17 +16,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <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 DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
|
||||
|
||||
Guid userAccountId;
|
||||
|
||||
try
|
||||
@@ -38,15 +35,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
OUTPUT INSERTED.UserAccountID
|
||||
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||
""",
|
||||
new { username, firstName, lastName, dateOfBirth, email },
|
||||
new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email},
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
int credentialRows = await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash);",
|
||||
new { UserAccountId = userAccountId, Hash = passwordHash },
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Hash = ua.UserCredential?.Hash },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
@@ -62,6 +62,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
throw;
|
||||
}
|
||||
|
||||
// perform round trip to get new User entity
|
||||
return await GetUserByIdAsync(userAccountId)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
@@ -71,7 +72,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<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 }
|
||||
);
|
||||
}
|
||||
@@ -81,7 +86,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@@ -111,7 +120,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"SELECT 1 FROM dbo.UserAccount WHERE UserAccountID = @UserAccountId",
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
@@ -122,7 +135,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"UPDATE dbo.UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @UserAccountId",
|
||||
"""
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1, RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
@@ -130,7 +147,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"INSERT INTO dbo.UserCredential (UserAccountId, Hash) VALUES (@UserAccountId, @Hash)",
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash)
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Hash = newPasswordHash },
|
||||
transaction
|
||||
)
|
||||
@@ -150,7 +170,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<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 }
|
||||
);
|
||||
}
|
||||
@@ -179,7 +203,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime) VALUES (@UserAccountId, GETDATE())",
|
||||
"""
|
||||
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
|
||||
VALUES (@UserAccountId, GETDATE())
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
@@ -197,7 +224,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int? result = await connection.ExecuteScalarAsync<int?>(
|
||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId",
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
return result.HasValue;
|
||||
|
||||
@@ -13,7 +13,7 @@ public interface IAuthRepository
|
||||
/// returns the created account.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,27 @@ namespace Features.Breweries.Repository;
|
||||
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic
|
||||
/// <c>Query<T></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>.
|
||||
/// The two read methods below therefore read rows manually via <see cref="DbDataReader.GetFieldValue{T}" />
|
||||
/// (the same approach the previous ADO.NET implementation used, which SqlClient supports specifically
|
||||
/// for UDT columns). All write methods use Dapper, since they only bind parameters and never
|
||||
/// deserialize <c>Coordinates</c> back out of a result set.
|
||||
/// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
|
||||
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
|
||||
/// </remarks>
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory),
|
||||
IBreweryRepository
|
||||
{
|
||||
private const string SelectColumns = """
|
||||
/// <inheritdoc/>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
IEnumerable<BreweryPost> results = await connection.QueryAsync<
|
||||
BreweryPost,
|
||||
BreweryPostLocation,
|
||||
City,
|
||||
StateProvince,
|
||||
Country,
|
||||
BreweryPost
|
||||
>(
|
||||
"""
|
||||
SELECT
|
||||
bp.BreweryPostID,
|
||||
bp.PostedByID,
|
||||
bp.BreweryName,
|
||||
@@ -31,48 +42,31 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
bp.UpdatedAt,
|
||||
bp.RowVersion,
|
||||
bpl.BreweryPostLocationID,
|
||||
bpl.CityID,
|
||||
bpl.AddressLine1,
|
||||
bpl.AddressLine2,
|
||||
bpl.PostalCode,
|
||||
bpl.Coordinates,
|
||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||
c.CityID,
|
||||
c.CityName,
|
||||
c.StateProvinceID,
|
||||
sp.StateProvinceID,
|
||||
sp.StateProvinceName,
|
||||
sp.ISO3166_2,
|
||||
sp.CountryID,
|
||||
co.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
|
||||
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
|
||||
""";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM {FromJoins}
|
||||
WHERE bp.BreweryPostID = @BreweryPostId
|
||||
""";
|
||||
AddParameter(command, "@BreweryPostId", id);
|
||||
""",
|
||||
MapBreweryRow,
|
||||
new { BreweryPostId = id },
|
||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||
);
|
||||
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
return results.SingleOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -80,23 +74,48 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
SELECT {SelectColumns}
|
||||
FROM {FromJoins}
|
||||
return await connection.QueryAsync<
|
||||
BreweryPost,
|
||||
BreweryPostLocation,
|
||||
City,
|
||||
StateProvince,
|
||||
Country,
|
||||
BreweryPost
|
||||
>(
|
||||
"""
|
||||
SELECT
|
||||
bp.BreweryPostID,
|
||||
bp.PostedByID,
|
||||
bp.BreweryName,
|
||||
bp.Description,
|
||||
bp.CreatedAt,
|
||||
bp.UpdatedAt,
|
||||
bp.RowVersion,
|
||||
bpl.BreweryPostLocationID,
|
||||
bpl.AddressLine1,
|
||||
bpl.AddressLine2,
|
||||
bpl.PostalCode,
|
||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||
c.CityID,
|
||||
c.CityName,
|
||||
sp.StateProvinceID,
|
||||
sp.StateProvinceName,
|
||||
sp.ISO3166_2,
|
||||
co.CountryID,
|
||||
co.CountryName,
|
||||
co.ISO3166_1
|
||||
FROM dbo.BreweryPost bp
|
||||
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
||||
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
||||
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
|
||||
ORDER BY bp.CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""";
|
||||
AddParameter(command, "@Offset", offset ?? 0);
|
||||
AddParameter(command, "@Limit", limit ?? int.MaxValue);
|
||||
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
List<BreweryPost> breweries = [];
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
breweries.Add(MapToEntity(reader));
|
||||
|
||||
return breweries;
|
||||
""",
|
||||
MapBreweryRow,
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
|
||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -405,100 +424,35 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
|
||||
/// associated <see cref="BreweryPostLocation" /> if a location row was joined in.
|
||||
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
||||
/// <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>
|
||||
private static BreweryPost MapToEntity(DbDataReader reader)
|
||||
private static BreweryPost MapBreweryRow(
|
||||
BreweryPost post,
|
||||
BreweryPostLocation location,
|
||||
City city,
|
||||
StateProvince stateProvince,
|
||||
Country country
|
||||
)
|
||||
{
|
||||
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
||||
int ordPostedById = reader.GetOrdinal("PostedById");
|
||||
int ordBreweryName = reader.GetOrdinal("BreweryName");
|
||||
int ordDescription = reader.GetOrdinal("Description");
|
||||
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
||||
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
||||
int ordRowVersion = reader.GetOrdinal("RowVersion");
|
||||
if (location.BreweryPostLocationId == Guid.Empty)
|
||||
return post;
|
||||
|
||||
BreweryPost brewery = new()
|
||||
{
|
||||
BreweryPostId = reader.GetGuid(ordBreweryPostId),
|
||||
PostedById = reader.GetGuid(ordPostedById),
|
||||
BreweryName = reader.GetString(ordBreweryName),
|
||||
Description = reader.GetString(ordDescription),
|
||||
CreatedAt = reader.GetDateTime(ordCreatedAt),
|
||||
UpdatedAt = reader.IsDBNull(ordUpdatedAt)
|
||||
? null
|
||||
: reader.GetDateTime(ordUpdatedAt),
|
||||
RowVersion = reader.IsDBNull(ordRowVersion)
|
||||
? null
|
||||
: reader.GetFieldValue<byte[]>(ordRowVersion),
|
||||
};
|
||||
stateProvince.CountryId = country.CountryId;
|
||||
stateProvince.Country = country;
|
||||
|
||||
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
||||
if (!reader.IsDBNull(ordLocationId))
|
||||
{
|
||||
int ordCoordinates = reader.GetOrdinal("Coordinates");
|
||||
int ordAddressLine2 = reader.GetOrdinal("AddressLine2");
|
||||
city.StateProvinceId = stateProvince.StateProvinceId;
|
||||
city.StateProvince = stateProvince;
|
||||
|
||||
brewery.Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = reader.GetGuid(ordLocationId),
|
||||
BreweryPostId = brewery.BreweryPostId,
|
||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
||||
City = MapCity(reader),
|
||||
AddressLine1 =
|
||||
reader.GetString(reader.GetOrdinal("AddressLine1")),
|
||||
AddressLine2 = reader.IsDBNull(ordAddressLine2)
|
||||
? null
|
||||
: reader.GetString(ordAddressLine2),
|
||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
||||
Coordinates = reader.IsDBNull(ordCoordinates)
|
||||
? null
|
||||
: reader.GetFieldValue<byte[]>(ordCoordinates),
|
||||
};
|
||||
}
|
||||
location.BreweryPostId = post.BreweryPostId;
|
||||
location.CityId = city.CityId;
|
||||
location.City = city;
|
||||
|
||||
return brewery;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
post.Location = location;
|
||||
return post;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
bool cityExists =
|
||||
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 }
|
||||
) is not null;
|
||||
|
||||
if (!cityExists)
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO dbo.City (StateProvinceID, CityName) VALUES (@StateProvinceId, @CityName)",
|
||||
"""
|
||||
INSERT INTO dbo.City (StateProvinceID, CityName)
|
||||
VALUES (@StateProvinceId, @CityName)
|
||||
""",
|
||||
new { StateProvinceId = stateProvinceId, location.CityName }
|
||||
);
|
||||
|
||||
@@ -70,7 +77,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<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 }
|
||||
) is not null;
|
||||
|
||||
@@ -78,7 +89,10 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
// A concurrent caller may create this country between the check above and here; that
|
||||
// race is tolerated (the resulting duplicate row, if any, is out of scope for this method).
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO dbo.Country (CountryName, ISO3166_1) VALUES (@CountryName, @ISO3166_1)",
|
||||
"""
|
||||
INSERT INTO dbo.Country (CountryName, ISO3166_1)
|
||||
VALUES (@CountryName, @ISO3166_1)
|
||||
""",
|
||||
new { CountryName = countryName, ISO3166_1 = isoCode }
|
||||
);
|
||||
}
|
||||
@@ -108,13 +122,20 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<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 }
|
||||
) is not null;
|
||||
|
||||
if (!exists)
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID) VALUES (@StateProvinceName, @ISO3166_2, @CountryId)",
|
||||
"""
|
||||
INSERT INTO dbo.StateProvince (StateProvinceName, ISO3166_2, CountryID)
|
||||
VALUES (@StateProvinceName, @ISO3166_2, @CountryId)
|
||||
""",
|
||||
new
|
||||
{
|
||||
StateProvinceName = stateProvinceName,
|
||||
@@ -128,7 +149,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.ExecuteScalarAsync<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 }
|
||||
);
|
||||
}
|
||||
@@ -137,7 +162,11 @@ public class LocationRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,16 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IUserAccountRepository
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@@ -35,8 +36,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
$"""
|
||||
SELECT {SelectColumns}
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
@@ -76,7 +77,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int rows = await connection.ExecuteAsync(
|
||||
"DELETE FROM dbo.UserAccount WHERE UserAccountId = @UserAccountId",
|
||||
"""
|
||||
DELETE FROM dbo.UserAccount
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
|
||||
@@ -89,7 +93,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<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 }
|
||||
);
|
||||
}
|
||||
@@ -99,7 +107,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user