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>
|
<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>
|
||||||
|
|||||||
@@ -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++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>;
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>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
|
/// <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>.
|
/// 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 = """
|
/// <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.BreweryPostID,
|
||||||
bp.PostedByID,
|
bp.PostedByID,
|
||||||
bp.BreweryName,
|
bp.BreweryName,
|
||||||
@@ -31,48 +42,31 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
bp.UpdatedAt,
|
bp.UpdatedAt,
|
||||||
bp.RowVersion,
|
bp.RowVersion,
|
||||||
bpl.BreweryPostLocationID,
|
bpl.BreweryPostLocationID,
|
||||||
bpl.CityID,
|
|
||||||
bpl.AddressLine1,
|
bpl.AddressLine1,
|
||||||
bpl.AddressLine2,
|
bpl.AddressLine2,
|
||||||
bpl.PostalCode,
|
bpl.PostalCode,
|
||||||
bpl.Coordinates,
|
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||||
|
c.CityID,
|
||||||
c.CityName,
|
c.CityName,
|
||||||
c.StateProvinceID,
|
sp.StateProvinceID,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceName,
|
||||||
sp.ISO3166_2,
|
sp.ISO3166_2,
|
||||||
sp.CountryID,
|
co.CountryID,
|
||||||
co.CountryName,
|
co.CountryName,
|
||||||
co.ISO3166_1
|
co.ISO3166_1
|
||||||
""";
|
FROM dbo.BreweryPost bp
|
||||||
|
|
||||||
/// <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.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
||||||
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
||||||
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
|
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
|
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 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,
|
||||||
|
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
|
ORDER BY bp.CreatedAt DESC
|
||||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||||
""";
|
""",
|
||||||
AddParameter(command, "@Offset", offset ?? 0);
|
MapBreweryRow,
|
||||||
AddParameter(command, "@Limit", limit ?? int.MaxValue);
|
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
|
||||||
|
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
);
|
||||||
List<BreweryPost> breweries = [];
|
|
||||||
|
|
||||||
while (await reader.ReadAsync())
|
|
||||||
breweries.Add(MapToEntity(reader));
|
|
||||||
|
|
||||||
return breweries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -405,100 +424,35 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
|
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
||||||
/// associated <see cref="BreweryPostLocation" /> if a location row was joined in.
|
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
|
||||||
|
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
|
||||||
|
/// brewery with no address on file), <paramref name="location" /> is a blank instance produced
|
||||||
|
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined
|
||||||
|
/// in, its City/StateProvince/Country chain was too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static BreweryPost MapToEntity(DbDataReader reader)
|
private static BreweryPost MapBreweryRow(
|
||||||
|
BreweryPost post,
|
||||||
|
BreweryPostLocation location,
|
||||||
|
City city,
|
||||||
|
StateProvince stateProvince,
|
||||||
|
Country country
|
||||||
|
)
|
||||||
{
|
{
|
||||||
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
if (location.BreweryPostLocationId == Guid.Empty)
|
||||||
int ordPostedById = reader.GetOrdinal("PostedById");
|
return post;
|
||||||
int ordBreweryName = reader.GetOrdinal("BreweryName");
|
|
||||||
int ordDescription = reader.GetOrdinal("Description");
|
|
||||||
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
|
||||||
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
|
||||||
int ordRowVersion = reader.GetOrdinal("RowVersion");
|
|
||||||
|
|
||||||
BreweryPost brewery = new()
|
stateProvince.CountryId = country.CountryId;
|
||||||
{
|
stateProvince.Country = country;
|
||||||
BreweryPostId = reader.GetGuid(ordBreweryPostId),
|
|
||||||
PostedById = reader.GetGuid(ordPostedById),
|
|
||||||
BreweryName = reader.GetString(ordBreweryName),
|
|
||||||
Description = reader.GetString(ordDescription),
|
|
||||||
CreatedAt = reader.GetDateTime(ordCreatedAt),
|
|
||||||
UpdatedAt = reader.IsDBNull(ordUpdatedAt)
|
|
||||||
? null
|
|
||||||
: reader.GetDateTime(ordUpdatedAt),
|
|
||||||
RowVersion = reader.IsDBNull(ordRowVersion)
|
|
||||||
? null
|
|
||||||
: reader.GetFieldValue<byte[]>(ordRowVersion),
|
|
||||||
};
|
|
||||||
|
|
||||||
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
city.StateProvinceId = stateProvince.StateProvinceId;
|
||||||
if (!reader.IsDBNull(ordLocationId))
|
city.StateProvince = stateProvince;
|
||||||
{
|
|
||||||
int ordCoordinates = reader.GetOrdinal("Coordinates");
|
|
||||||
int ordAddressLine2 = reader.GetOrdinal("AddressLine2");
|
|
||||||
|
|
||||||
brewery.Location = new BreweryPostLocation
|
location.BreweryPostId = post.BreweryPostId;
|
||||||
{
|
location.CityId = city.CityId;
|
||||||
BreweryPostLocationId = reader.GetGuid(ordLocationId),
|
location.City = city;
|
||||||
BreweryPostId = brewery.BreweryPostId,
|
|
||||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
|
||||||
City = MapCity(reader),
|
|
||||||
AddressLine1 =
|
|
||||||
reader.GetString(reader.GetOrdinal("AddressLine1")),
|
|
||||||
AddressLine2 = reader.IsDBNull(ordAddressLine2)
|
|
||||||
? null
|
|
||||||
: reader.GetString(ordAddressLine2),
|
|
||||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
|
||||||
Coordinates = reader.IsDBNull(ordCoordinates)
|
|
||||||
? null
|
|
||||||
: reader.GetFieldValue<byte[]>(ordCoordinates),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return brewery;
|
post.Location = location;
|
||||||
}
|
return post;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps the current row's <c>City</c> → <c>StateProvince</c> → <c>Country</c> columns. The schema
|
|
||||||
/// enforces <c>NOT NULL</c> foreign keys down this whole chain, so whenever a location row was
|
|
||||||
/// joined in (the caller only calls this when it was), these columns are always populated too.
|
|
||||||
/// </summary>
|
|
||||||
private static City MapCity(DbDataReader reader)
|
|
||||||
{
|
|
||||||
return new City
|
|
||||||
{
|
|
||||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
|
||||||
CityName = reader.GetString(reader.GetOrdinal("CityName")),
|
|
||||||
StateProvinceId =
|
|
||||||
reader.GetGuid(reader.GetOrdinal("StateProvinceId")),
|
|
||||||
StateProvince = new StateProvince
|
|
||||||
{
|
|
||||||
StateProvinceId =
|
|
||||||
reader.GetGuid(reader.GetOrdinal("StateProvinceId")),
|
|
||||||
StateProvinceName =
|
|
||||||
reader.GetString(reader.GetOrdinal("StateProvinceName")),
|
|
||||||
ISO3166_2 = reader.GetString(reader.GetOrdinal("ISO3166_2")),
|
|
||||||
CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")),
|
|
||||||
Country = new Country
|
|
||||||
{
|
|
||||||
CountryId = reader.GetGuid(reader.GetOrdinal("CountryId")),
|
|
||||||
CountryName =
|
|
||||||
reader.GetString(reader.GetOrdinal("CountryName")),
|
|
||||||
ISO3166_1 =
|
|
||||||
reader.GetString(reader.GetOrdinal("ISO3166_1")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddParameter(DbCommand command, string name,
|
|
||||||
object? value)
|
|
||||||
{
|
|
||||||
DbParameter p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 =
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
: 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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user