code style cleanup

This commit is contained in:
Aaron Po
2026-06-20 13:55:17 -04:00
parent 0c3b0e99e8
commit 5b882ac51c
167 changed files with 3711 additions and 3522 deletions

View File

@@ -1,10 +1,10 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Shared.Contracts;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication;
@@ -36,56 +36,47 @@ public class JwtAuthenticationHandler(
/// token fails validation.
/// </remarks>
/// <returns>
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/>
/// A successful <see cref="AuthenticateResult" /> containing the validated
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"];
}
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret)) secret = configuration["Jwt:SecretKey"];
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
if (string.IsNullOrWhiteSpace(secret)) return AuthenticateResult.Fail("JWT secret is not configured");
// Check if Authorization header exists
if (
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
out StringValues authHeaderValue
)
)
{
return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString();
string authHeader = authHeaderValue.ToString();
if (
!authHeader.StartsWith(
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
{
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
}
var token = authHeader.Substring("Bearer ".Length).Trim();
string token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token,
secret
);
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
@@ -106,7 +97,7 @@ public class JwtAuthenticationHandler(
Response.ContentType = "application/json";
Response.StatusCode = 401;
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
ResponseBody response = new() { Message = "Unauthorized: Invalid or missing authentication token" };
await Response.WriteAsJsonAsync(response);
}
}
@@ -115,4 +106,6 @@ public class JwtAuthenticationHandler(
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
public class JwtAuthenticationOptions : AuthenticationSchemeOptions
{
}

View File

@@ -1,13 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers
{
namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
@@ -24,4 +25,3 @@ namespace API.Core.Controllers
return NotFound(new { message = "Route not found." });
}
}
}

View File

@@ -1,7 +1,7 @@
using System.Security.Claims;
using Shared.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers;
@@ -24,14 +24,14 @@ public class ProtectedController : ControllerBase
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value;
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
Payload = new { userId, username }
}
);
}

View File

@@ -1,11 +1,11 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using Shared.Contracts;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core;
@@ -24,25 +24,65 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
/// <remarks>
/// Maps exception types to responses as follows:
/// <list type="bullet">
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item>
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item>
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item>
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item>
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item>
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item>
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item>
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></item>
/// <item>
/// <description>
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// validation error messages.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
/// </item>
/// </list>
/// </remarks>
/// <param name="context">The context for the unhandled exception, including the exception itself and the result to populate.</param>
/// <param name="context">
/// The context for the unhandled exception, including the exception itself and the result to
/// populate.
/// </param>
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
var errors = fluentValidationException
case ValidationException fluentValidationException:
Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
@@ -60,7 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message }
)
{
StatusCode = 409,
StatusCode = 409
};
context.ExceptionHandled = true;
break;
@@ -70,7 +110,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message }
)
{
StatusCode = 404,
StatusCode = 404
};
context.ExceptionHandled = true;
break;
@@ -80,7 +120,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message }
)
{
StatusCode = 401,
StatusCode = 401
};
context.ExceptionHandled = true;
break;
@@ -90,7 +130,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message }
)
{
StatusCode = 403,
StatusCode = 403
};
context.ExceptionHandled = true;
break;
@@ -100,7 +140,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = "A database error occurred." }
)
{
StatusCode = 503,
StatusCode = 503
};
context.ExceptionHandled = true;
break;
@@ -110,7 +150,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
new ResponseBody { Message = ex.Message }
)
{
StatusCode = 400,
StatusCode = 400
};
context.ExceptionHandled = true;
break;
@@ -119,11 +159,11 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
context.Result = new ObjectResult(
new ResponseBody
{
Message = "An unexpected error occurred",
Message = "An unexpected error occurred"
}
)
{
StatusCode = 500,
StatusCode = 500
};
context.ExceptionHandled = true;
break;

View File

@@ -14,13 +14,10 @@ using Infrastructure.Jwt;
using Infrastructure.Sql;
using Shared.Application.Behaviors;
var builder = WebApplication.CreateBuilder(args);
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
})
builder.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
@@ -54,10 +51,7 @@ builder.Services.AddHealthChecks();
// Configure logging for container output
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
{
builder.Logging.AddDebug();
}
if (!builder.Environment.IsProduction()) builder.Logging.AddDebug();
// Configure Dependency Injection -------------------------------------------------------------------------------------
@@ -88,7 +82,7 @@ builder
builder.Services.AddAuthorization();
var app = builder.Build();
WebApplication app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
@@ -106,7 +100,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Application is shutting down gracefully...");

View File

@@ -2,6 +2,7 @@ Feature: User Account Confirmation
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account

View File

@@ -20,7 +20,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);
@@ -39,7 +39,7 @@ public class MockEmailDispatcher : IEmailDispatcher
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);

View File

@@ -24,7 +24,7 @@ public class MockEmailProvider : IEmailProvider
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);
@@ -45,7 +45,7 @@ public class MockEmailProvider : IEmailProvider
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow,
SentAt = DateTime.UtcNow
}
);

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json;
using API.Specs;
using FluentAssertions;
using Reqnroll;
@@ -15,14 +15,11 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
{
return client;
}
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client)) return client;
var factory = scenario.TryGetValue<TestApiFactory>(
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out var f
out TestApiFactory? f
)
? f
: new TestApiFactory();
@@ -46,19 +43,19 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody
)
{
var client = GetClient();
HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
{
Content = new StringContent(
jsonBody,
System.Text.Encoding.UTF8,
Encoding.UTF8,
"application/json"
),
)
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
@@ -70,13 +67,13 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string url
)
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
HttpClient client = GetClient();
HttpRequestMessage requestMessage = new(
new HttpMethod(method),
url
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +83,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(expected);
@@ -96,7 +93,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode);
@@ -109,20 +106,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
@@ -157,20 +154,20 @@ public class ApiGeneralSteps(ScenarioContext scenario)
)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<string>(ResponseBodyKey, out string? responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
@@ -195,7 +192,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
string? actualValue = value.GetString();
actualValue
.Should()
.Contain(

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
using System.Collections.Generic;
using API.Specs.Mocks;
using Features.Emails.Services;
using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace API.Specs
{
namespace API.Specs;
public class TestApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -18,29 +16,22 @@ namespace API.Specs
builder.ConfigureServices(services =>
{
// Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d =>
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
if (emailProviderDescriptor != null)
{
services.Remove(emailProviderDescriptor);
}
if (emailProviderDescriptor != null) services.Remove(emailProviderDescriptor);
services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email dispatcher with mock for testing
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
if (emailDispatcherDescriptor != null)
{
services.Remove(emailDispatcherDescriptor);
}
if (emailDispatcherDescriptor != null) services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Data;
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
@@ -12,6 +13,12 @@ namespace Database.Migrations;
/// </summary>
public static class Program
{
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
@@ -28,23 +35,23 @@ public static class Program
/// </exception>
private static string BuildConnectionString(string? databaseName = null)
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
string server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName
string dbName = databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
string user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
@@ -57,12 +64,6 @@ public static class Program
return builder.ConnectionString;
}
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console.
@@ -70,13 +71,13 @@ public static class Program
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations()
{
var upgrader = DeployChanges
UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful;
}
@@ -90,14 +91,14 @@ public static class Program
/// </returns>
private static bool ClearDatabase()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
try
{
myConn.Open();
// First, set the database to single user mode to close all connections
var setModeCommand = new SqlCommand(
SqlCommand setModeCommand = new(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn);
@@ -106,36 +107,34 @@ public static class Program
setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
}
// Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try
{
dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error dropping database: {ex}");
return false;
}
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error clearing database: {ex}");
return false;
}
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
if (myConn.State == ConnectionState.Open) myConn.Close();
}
return true;
}
@@ -147,31 +146,29 @@ public static class Program
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten]
""";
var myCommand = new SqlCommand(str, myConn);
SqlCommand myCommand = new(str, myConn);
try
{
myConn.Open();
myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error creating database: {ex}");
}
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
if (myConn.State == ConnectionState.Open) myConn.Close();
}
return true;
}
@@ -188,7 +185,7 @@ public static class Program
try
{
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true")
{
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -196,19 +193,17 @@ public static class Program
}
CreateDatabaseIfNotExists();
var success = DeployMigrations();
bool success = DeployMigrations();
if (success)
{
Console.WriteLine("Database migrations completed successfully.");
return 0;
}
else
{
Console.WriteLine("Database migrations failed.");
return 1;
}
}
catch (Exception ex)
{
Console.WriteLine("An error occurred during database migrations:");

View File

@@ -78,7 +78,8 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID);
----------------------------------------------------------------------------
@@ -110,7 +111,8 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);
----------------------------------------------------------------------------
@@ -140,7 +142,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID);
----------------------------------------------------------------------------
@@ -178,10 +181,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
@@ -216,13 +221,16 @@ CREATE TABLE UserFollow
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID)
CHECK (UserAccountID != FollowingID
)
);
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID);
----------------------------------------------------------------------------
@@ -274,7 +282,8 @@ CREATE TABLE StateProvince
REFERENCES Country (CountryID)
);
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID);
----------------------------------------------------------------------------
@@ -299,7 +308,8 @@ CREATE TABLE City
REFERENCES StateProvince (StateProvinceID)
);
CREATE NONCLUSTERED INDEX IX_City_StateProvince
CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID);
----------------------------------------------------------------------------
@@ -332,7 +342,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID);
----------------------------------------------------------------------------
@@ -373,10 +384,12 @@ CREATE TABLE BreweryPostLocation
REFERENCES City (CityID)
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
-- To assess when the time comes:
@@ -422,10 +435,12 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID);
----------------------------------------------------------------------------
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120)
);
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID);
----------------------------------------------------------------------------
@@ -543,10 +561,12 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID);
----------------------------------------------------------------------------
@@ -588,8 +608,10 @@ CREATE TABLE BeerPostComment
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
CREATE
OR
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER;
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
CREATE
OR
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;

View File

@@ -1,5 +1,6 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
CREATE
OR
ALTER PROCEDURE usp_CreateUserAccount
(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR (64),
@@ -10,27 +11,24 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
DECLARE
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(
Username,
(Username,
FirstName,
LastName,
DateOfBirth,
Email
)
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES
(
@Username,
@FirstName,
@LastName,
@DateOfBirth,
@Email
@Username, @FirstName, @LastName, @DateOfBirth, @Email
);
SELECT @UserAccountId = UserAccountID FROM @Inserted;
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +1,24 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
CREATE
OR
ALTER PROCEDURE usp_DeleteUserAccount
(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
SET
NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR('UserAccount with the specified ID does not exist.', 16,
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE FROM UserAccount
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,7 +1,10 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
CREATE
OR
ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR (128)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
CREATE
OR
ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR (64)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
CREATE
OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@@ -19,7 +21,8 @@ BEGIN
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;

View File

@@ -1,17 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
CREATE
OR
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT
UserCredentialId,
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,15 +1,21 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
CREATE
OR
ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@@ -8,12 +10,16 @@ CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@@ -23,18 +29,22 @@ BEGIN
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF @UserAccountId_ IS NULL
IF
@UserAccountId_ IS NULL
BEGIN
THROW 50000, 'Failed to create user account.', 1;
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
BEGIN
THROW 50002, 'Failed to create user credential.', 1;
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;

View File

@@ -1,12 +1,17 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_

View File

@@ -1,17 +1,24 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @VerificationDateTime IS NULL
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification

View File

@@ -1,26 +1,36 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF @StateProvinceId IS NULL
BEGIN
THROW 50001, 'State/province does not exist', 1;
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW 50002, 'City already exists.', 1;
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City

View File

@@ -1,21 +1,26 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES
(@CountryName, @ISO3166_1);
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,27 +1,34 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF @CountryId IS NULL
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW 50001, 'Country does not exist', 1;
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES
(@StateProvinceName, @ISO3166_2, @CountryId);
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@@ -10,29 +12,38 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)

View File

@@ -1,15 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery
CREATE
OR
ALTER PROCEDURE dbo.USP_DeleteBrewery
@BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE FROM dbo.BreweryPost
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -1,10 +1,13 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
CREATE
OR
ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
@@ -23,6 +26,5 @@ BEGIN
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
CREATE
OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
CREATE
OR
ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@@ -11,26 +13,33 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF @CityID IS NOT NULL AND NOT EXISTS (SELECT 1
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
@@ -38,10 +47,12 @@ BEGIN
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF @CityID IS NULL
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE FROM dbo.BreweryPostLocation
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE IF EXISTS (SELECT 1

View File

@@ -22,7 +22,7 @@ internal class LocationSeeder : ISeeder
[
("Canada", "CA"),
("Mexico", "MX"),
("United States", "US"),
("United States", "US")
];
/// <summary>
@@ -134,7 +134,7 @@ internal class LocationSeeder : ISeeder
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
("Yucatán", "MX-YUC", "MX"),
("Zacatecas", "MX-ZAC", "MX"),
("Ciudad de México", "MX-CMX", "MX"),
("Ciudad de México", "MX-CMX", "MX")
];
/// <summary>
@@ -255,7 +255,7 @@ internal class LocationSeeder : ISeeder
("MX-COA", "Saltillo"),
("MX-BCS", "La Paz"),
("MX-NAY", "Tepic"),
("MX-ZAC", "Zacatecas"),
("MX-ZAC", "Zacatecas")
];
/// <summary>
@@ -266,28 +266,22 @@ internal class LocationSeeder : ISeeder
/// <returns>A task that completes when all locations have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
foreach (var (countryName, countryCode) in Countries)
{
foreach ((string countryName, string countryCode) in Countries)
await CreateCountryAsync(connection, countryName, countryCode);
}
foreach (
var (stateProvinceName, stateProvinceCode, countryCode) in States
(string stateProvinceName, string stateProvinceCode, string countryCode) in States
)
{
await CreateStateProvinceAsync(
connection,
stateProvinceName,
stateProvinceCode,
countryCode
);
}
foreach (var (stateProvinceCode, cityName) in Cities)
{
foreach ((string stateProvinceCode, string cityName) in Cities)
await CreateCityAsync(connection, cityName, stateProvinceCode);
}
}
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
@@ -300,7 +294,7 @@ internal class LocationSeeder : ISeeder
string countryCode
)
{
await using var command = new SqlCommand(
await using SqlCommand command = new(
"dbo.USP_CreateCountry",
connection
);
@@ -324,7 +318,7 @@ internal class LocationSeeder : ISeeder
string countryCode
)
{
await using var command = new SqlCommand(
await using SqlCommand command = new(
"dbo.USP_CreateStateProvince",
connection
);
@@ -350,7 +344,7 @@ internal class LocationSeeder : ISeeder
string stateProvinceCode
)
{
await using var command = new SqlCommand(
await using SqlCommand command = new(
"dbo.USP_CreateCity",
connection
);

View File

@@ -1,7 +1,5 @@
using Microsoft.Data.SqlClient;
using DbUp;
using System.Reflection;
using Database.Seed;
using Database.Seed;
using Microsoft.Data.SqlClient;
// Entry point for the database seeding utility. Connects to the target database
// (retrying on transient failures), then runs each registered ISeeder in order to
@@ -19,22 +17,22 @@ using Database.Seed;
/// </exception>
string BuildConnectionString()
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
string server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
string dbName = Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
string user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
string password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
string trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
@@ -50,7 +48,7 @@ string BuildConnectionString()
try
{
var connectionString = BuildConnectionString();
string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database...");
@@ -60,7 +58,6 @@ try
int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
connection = new SqlConnection(connectionString);
@@ -76,12 +73,8 @@ try
connection?.Dispose();
connection = null;
}
}
if (connection == null)
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
if (connection == null) throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
Console.WriteLine("Starting seeding...");
@@ -90,10 +83,10 @@ try
ISeeder[] seeders =
[
new LocationSeeder(),
new UserSeeder(),
new UserSeeder()
];
foreach (var seeder in seeders)
foreach (ISeeder seeder in seeders)
{
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection);

View File

@@ -123,7 +123,7 @@ internal class UserSeeder : ISeeder
("Zara", "Wilkinson"),
("Zaria", "Gibson"),
("Zion", "Watkins"),
("Zoie", "Armstrong"),
("Zoie", "Armstrong")
];
/// <summary>
@@ -136,8 +136,8 @@ internal class UserSeeder : ISeeder
/// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
var generator = new PasswordGenerator();
var rng = new Random();
PasswordGenerator generator = new();
Random rng = new();
int createdUsers = 0;
int createdCredentials = 0;
int createdVerifications = 0;
@@ -147,8 +147,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test";
const string lastName = "User";
const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
DateTime dob = new(1985, 03, 01);
string hash = GeneratePasswordHash("password");
await RegisterUserAsync(
connection,
@@ -160,24 +160,24 @@ internal class UserSeeder : ISeeder
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
foreach ((string firstName, string lastName) in SeedNames)
{
// prepare user fields
var username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng);
string username = $"{firstName[0]}.{lastName}";
string email = $"{firstName}.{lastName}@thebiergarten.app";
DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
64,
10,
10
);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
Guid id = await RegisterUserAsync(
connection,
username,
firstName,
@@ -224,7 +224,7 @@ internal class UserSeeder : ISeeder
string hash
)
{
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure;
@@ -235,7 +235,7 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return (Guid)result!;
@@ -252,12 +252,12 @@ internal class UserSeeder : ISeeder
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = 65536,
Iterations = 4,
Iterations = 4
};
byte[] hash = argon2.GetBytes(32);
@@ -278,9 +278,9 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return result is not null;
}
@@ -293,7 +293,7 @@ internal class UserSeeder : ISeeder
Guid userAccountId
)
{
await using var command = new SqlCommand(
await using SqlCommand command = new(
"dbo.USP_CreateUserVerification",
connection
);

View File

@@ -37,12 +37,14 @@ public class BreweryPost
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
/// <summary>
/// The associated <see cref="BreweryPostLocation"/> for this post, if one has been set. This is a one-to-one navigation property.
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
/// navigation property.
/// </summary>
public BreweryPostLocation? Location { get; set; }
}

View File

@@ -2,7 +2,8 @@ namespace Domain.Entities;
/// <summary>
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
/// deleted.
/// </summary>
public class BreweryPostLocation
{
@@ -12,7 +13,8 @@ public class BreweryPostLocation
public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="BreweryPost"/>. Maps to <c>BreweryPostID</c>; unique, enforcing a one-to-one relationship.
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid BreweryPostId { get; set; }
@@ -37,12 +39,14 @@ public class BreweryPostLocation
public Guid CityId { get; set; }
/// <summary>
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or <c>null</c> if not set.
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
/// <c>null</c> if not set.
/// </summary>
public byte[]? Coordinates { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -2,7 +2,8 @@ namespace Domain.Entities;
/// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
/// referenced by <see cref="UserCredential"/>, <see cref="UserVerification"/>, brewery posts, and other user-owned data.
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
/// data.
/// </summary>
public class UserAccount
{
@@ -47,7 +48,8 @@ public class UserAccount
public DateTime DateOfBirth { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -2,7 +2,8 @@ namespace Domain.Entities;
/// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is deleted.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
/// deleted.
/// </summary>
public class UserCredential
{
@@ -32,7 +33,8 @@ public class UserCredential
public string Hash { get; set; } = string.Empty;
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -13,7 +13,8 @@ public class UserVerification
public Guid UserVerificationId { get; set; }
/// <summary>
/// Foreign key referencing the verified <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>; unique, enforcing a one-to-one relationship.
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid UserAccountId { get; set; }
@@ -23,7 +24,8 @@ public class UserVerification
public DateTime VerificationDateTime { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -2,10 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
@@ -13,8 +14,8 @@ namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
public ConfirmUserHandlerTests()
{
@@ -25,30 +26,30 @@ public class ConfirmUserHandlerTests
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
{
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
return new ValidatedToken(userId, username, principal);
}
[Fact]
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
{
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string confirmationToken = "valid-confirmation-token";
var validatedToken = MakeValidatedToken(userId, username);
var userAccount = new UserAccount { UserAccountId = userId, Username = username };
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
var result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
ConfirmationPayload result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId);
@@ -63,7 +64,7 @@ public class ConfirmUserHandlerTests
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
}
@@ -71,7 +72,7 @@ public class ConfirmUserHandlerTests
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
{
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user";
@@ -80,7 +81,7 @@ public class ConfirmUserHandlerTests
.ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
Func<Task<ConfirmationPayload>> act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
}

View File

@@ -1,7 +1,8 @@
using Domain.Entities;
using FluentAssertions;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Dtos;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
@@ -11,16 +12,16 @@ public class RefreshTokenHandlerTests
[Fact]
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
{
var tokenServiceMock = new Mock<ITokenService>();
var handler = new RefreshTokenHandler(tokenServiceMock.Object);
var userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId, Username = "testuser" };
Mock<ITokenService> tokenServiceMock = new();
RefreshTokenHandler handler = new(tokenServiceMock.Object);
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
tokenServiceMock
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
var result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
LoginPayload result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("testuser");

View File

@@ -1,9 +1,10 @@
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using MediatR;
using Moq;
@@ -14,10 +15,10 @@ namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<IMediator> _mediatorMock;
private readonly RegisterUserHandler _handler;
public RegisterUserHandlerTests()
{
@@ -34,22 +35,25 @@ public class RegisterUserHandlerTests
);
}
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com") =>
new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com")
{
return new RegisterUserCommand(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
}
[Fact]
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{
var command = ValidCommand();
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value";
var expectedUserId = Guid.NewGuid();
Guid expectedUserId = Guid.NewGuid();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword))
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
command.DateOfBirth, hashedPassword))
.ReturnsAsync(new UserAccount
{
UserAccountId = expectedUserId,
@@ -58,14 +62,15 @@ public class RegisterUserHandlerTests
LastName = command.LastName,
Email = command.Email,
DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
});
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>())).Returns("confirmation-token");
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Returns("confirmation-token");
var result = await _handler.Handle(command, CancellationToken.None);
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId);
@@ -83,18 +88,19 @@ public class RegisterUserHandlerTests
[Fact]
public async Task Handle_WithExistingUsername_ThrowsConflictException()
{
var command = ValidCommand(username: "existinguser");
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(command, CancellationToken.None);
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
_authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<DateTime>(), It.IsAny<string>()),
Times.Never
);
}
@@ -102,13 +108,13 @@ public class RegisterUserHandlerTests
[Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException()
{
var command = ValidCommand(email: "existing@example.com");
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new() { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
var act = async () => await _handler.Handle(command, CancellationToken.None);
Func<Task<RegistrationPayload>> act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
}
@@ -116,7 +122,7 @@ public class RegisterUserHandlerTests
[Fact]
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{
var command = ValidCommand();
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
@@ -124,7 +130,8 @@ public class RegisterUserHandlerTests
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
@@ -134,7 +141,8 @@ public class RegisterUserHandlerTests
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
_authRepoMock.Verify(
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword),
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email,
command.DateOfBirth, hashedPassword),
Times.Once
);
}
@@ -142,14 +150,16 @@ public class RegisterUserHandlerTests
[Fact]
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
{
var command = ValidCommand();
RegisterUserCommand command = ValidCommand();
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
.ReturnsAsync(new UserAccount
{ UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
@@ -157,7 +167,7 @@ public class RegisterUserHandlerTests
.Setup(x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("smtp down"));
var result = await _handler.Handle(command, CancellationToken.None);
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.ConfirmationEmailSent.Should().BeFalse();
result.AccessToken.Should().Be("access-token");

View File

@@ -11,20 +11,21 @@ namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<IMediator> _mediatorMock = new();
private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
public ResendConfirmationEmailHandlerTests()
{
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object);
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object,
_mediatorMock.Object);
}
[Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{
var userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
@@ -43,7 +44,7 @@ public class ResendConfirmationEmailHandlerTests
[Fact]
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -57,8 +58,8 @@ public class ResendConfirmationEmailHandlerTests
[Fact]
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{
var userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId };
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);

View File

@@ -1,9 +1,10 @@
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using Moq;
@@ -12,9 +13,9 @@ namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly LoginHandler _handler;
public LoginHandlerTests()
{
@@ -28,24 +29,24 @@ public class LoginHandlerTests
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
const string username = "CogitoErgoSum";
var userAccountId = Guid.NewGuid();
Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount
UserAccount userAccount = new()
{
UserAccountId = userAccountId,
Username = username,
FirstName = "René",
LastName = "Descartes",
Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31),
DateOfBirth = new DateTime(1596, 03, 31)
};
var userCredential = new UserCredential
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "some-hash",
Expiry = DateTime.MaxValue,
Expiry = DateTime.MaxValue
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
@@ -54,7 +55,7 @@ public class LoginHandlerTests
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
var result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
LoginPayload result = await _handler.Handle(new LoginQuery(username, "any-password"), CancellationToken.None);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId);
@@ -69,7 +70,7 @@ public class LoginHandlerTests
const string username = "de_beauvoir";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
@@ -79,13 +80,14 @@ public class LoginHandlerTests
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{
const string username = "BRussell";
var userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
@@ -95,15 +97,15 @@ public class LoginHandlerTests
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{
const string username = "RCarnap";
var userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new() { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
Func<Task<LoginPayload>> act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
}

View File

@@ -1,19 +1,22 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Domain.Entities;
using Features.Auth.Repository;
using FluentAssertions;
namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTests
{
private static AuthRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
private static AuthRepository CreateRepo(MockDbConnection conn)
{
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{
var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsScalar(expectedUserId);
@@ -46,14 +49,14 @@ public class AuthRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync(
username: "testuser",
firstName: "Test",
lastName: "User",
email: "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123"
AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync(
"testuser",
"Test",
"User",
"test@example.com",
new DateTime(1990, 1, 1),
"hashedpassword123"
);
result.Should().NotBeNull();
@@ -68,8 +71,8 @@ public class AuthRepositoryTests
[Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
@@ -98,8 +101,8 @@ public class AuthRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -112,13 +115,13 @@ public class AuthRepositoryTests
[Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull();
}
@@ -126,8 +129,8 @@ public class AuthRepositoryTests
[Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
@@ -158,8 +161,8 @@ public class AuthRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -170,15 +173,15 @@ public class AuthRepositoryTests
[Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull();
}
@@ -186,9 +189,9 @@ public class AuthRepositoryTests
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{
var userId = Guid.NewGuid();
var credentialId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
@@ -211,8 +214,8 @@ public class AuthRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId);
@@ -223,16 +226,16 @@ public class AuthRepositoryTests
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull();
}
@@ -240,17 +243,17 @@ public class AuthRepositoryTests
[Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1);
var repo = CreateRepo(conn);
AuthRepository repo = CreateRepo(conn);
// Should not throw
var act = async () =>
Func<Task> act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
@@ -12,8 +12,8 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests
{
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceRefreshTests()
@@ -24,7 +24,8 @@ public class TokenServiceRefreshTests
// Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
@@ -36,28 +37,28 @@ public class TokenServiceRefreshTests
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
var userAccount = new UserAccount
UserAccount userAccount = new()
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
DateOfBirth = new DateTime(1990, 1, 1)
};
// Mock the validation of refresh token
@@ -75,7 +76,7 @@ public class TokenServiceRefreshTests
.ReturnsAsync(userAccount);
// Act
var result = await _tokenService.RefreshTokenAsync(refreshToken);
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert
result.Should().NotBeNull();
@@ -132,19 +133,19 @@ public class TokenServiceRefreshTests
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))

View File

@@ -1,10 +1,9 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
@@ -12,8 +11,8 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests
{
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceValidationTests()
@@ -24,7 +23,8 @@ public class TokenServiceValidationTests
// Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890");
_tokenService = new TokenService(
_tokenInfraMock.Object,
@@ -36,26 +36,26 @@ public class TokenServiceValidationTests
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
ValidatedToken result =
await _tokenService.ValidateAccessTokenAsync(token);
// Assert
@@ -70,26 +70,26 @@ public class TokenServiceValidationTests
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-refresh-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
ValidatedToken result =
await _tokenService.ValidateRefreshTokenAsync(token);
// Assert
@@ -102,26 +102,26 @@ public class TokenServiceValidationTests
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-confirmation-token";
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
var result =
ValidatedToken result =
await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert
@@ -172,14 +172,14 @@ public class TokenServiceValidationTests
const string token = "token-without-user-id";
// Claims without Sub (user ID)
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
@@ -196,18 +196,18 @@ public class TokenServiceValidationTests
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
var userId = Guid.NewGuid();
Guid userId = Guid.NewGuid();
const string token = "token-without-username";
// Claims without UniqueName (username)
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
@@ -228,15 +228,15 @@ public class TokenServiceValidationTests
const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format
var claims = new List<Claim>
List<Claim> claims = new()
{
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var claimsIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(claimsIdentity);
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -22,14 +23,11 @@ public class ConfirmUserHandler(
/// </exception>
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken)
{
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
if (user == null)
{
throw new UnauthorizedException("User account not found");
}
if (user == null) throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
}

View File

@@ -14,7 +14,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
{
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken,
result.AccessToken);
}
}

View File

@@ -6,12 +6,18 @@ namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
/// </summary>
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param>
/// <param name="Username">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param>
/// <param name="Password">
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
/// lowercase letter, a number, and a special character.
/// </param>
public record RegisterUserCommand(
string Username,
string FirstName,

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -31,9 +32,9 @@ public class RegisterUserHandler(
{
await ValidateUserDoesNotExist(request.Username, request.Email);
var hashed = passwordInfrastructure.Hash(request.Password);
string hashed = passwordInfrastructure.Hash(request.Password);
var createdUser = await authRepo.RegisterUserAsync(
UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username,
request.FirstName,
request.LastName,
@@ -42,16 +43,15 @@ public class RegisterUserHandler(
hashed
);
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
string accessToken = tokenService.GenerateAccessToken(createdUser);
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
}
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty,
false);
var emailSent = false;
bool emailSent = false;
try
{
await mediator.Send(
@@ -67,7 +67,8 @@ public class RegisterUserHandler(
// ignored
}
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent);
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken,
emailSent);
}
/// <exception cref="ConflictException">
@@ -75,12 +76,10 @@ public class RegisterUserHandler(
/// </exception>
private async Task ValidateUserDoesNotExist(string username, string email)
{
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
var existingEmail = await authRepo.GetUserByEmailAsync(email);
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists");
}
}
}

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
@@ -24,18 +25,12 @@ public class ResendConfirmationEmailHandler(
{
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken)
{
var user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null)
{
return; // Silent return to prevent user enumeration
}
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId))
{
return; // Already confirmed, no-op
}
if (await authRepository.IsUserVerifiedAsync(request.UserId)) return; // Already confirmed, no-op
var confirmationToken = tokenService.GenerateConfirmationToken(user);
string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken

View File

@@ -9,8 +9,8 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace Features.Auth.Controllers
{
namespace Features.Auth.Controllers;
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary>
@@ -39,11 +39,11 @@ namespace Features.Auth.Controllers
[FromBody] RegisterUserCommand command
)
{
var payload = await mediator.Send(command);
RegistrationPayload payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload>
{
Message = "User registered successfully.",
Payload = payload,
Payload = payload
});
}
@@ -60,11 +60,11 @@ namespace Features.Auth.Controllers
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{
var payload = await mediator.Send(query);
LoginPayload payload = await mediator.Send(query);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = payload,
Payload = payload
});
}
@@ -80,11 +80,11 @@ namespace Features.Auth.Controllers
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token)
{
var payload = await mediator.Send(new ConfirmUserCommand(token));
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload,
Payload = payload
});
}
@@ -118,12 +118,11 @@ namespace Features.Auth.Controllers
[FromBody] RefreshTokenCommand command
)
{
var payload = await mediator.Send(command);
LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = payload,
Payload = payload
});
}
}
}

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
@@ -11,7 +12,10 @@ namespace Features.Auth.Queries.Login;
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param>
/// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler(
IAuthRepository authRepo,
@@ -25,20 +29,20 @@ public class LoginHandler(
/// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
{
var user =
UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
var activeCred =
UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
}

View File

@@ -12,7 +12,7 @@ namespace Features.Auth.Repository;
/// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
: Repository<UserAccount>(connectionFactory),
IAuthRepository
{
/// <summary>
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <returns>The newly created UserAccount with generated ID</returns>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
public async Task<UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
string passwordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
@@ -56,30 +56,23 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value)
{
if (result is Guid g)
{
userAccountId = g;
}
else if (result is string s && Guid.TryParse(s, out var parsed))
{
else if (result is string s && Guid.TryParse(s, out Guid parsed))
userAccountId = parsed;
}
else if (result is byte[] bytes && bytes.Length == 16)
{
userAccountId = new Guid(bytes);
}
else
{
// Fallback: try to convert and parse string representation
try
{
var str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
string? str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
userAccountId = p;
}
catch
@@ -87,9 +80,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
userAccountId = Guid.Empty;
}
}
}
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
return await GetUserByIdAsync(userAccountId) ??
throw new Exception("Failed to retrieve newly registered user.");
}
/// <summary>
@@ -99,18 +92,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
public async Task<UserAccount?> GetUserByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -121,18 +114,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
public async Task<UserAccount?> GetUserByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -147,14 +140,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
}
@@ -170,8 +163,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
string newPasswordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
@@ -187,18 +180,18 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
public async Task<UserAccount?> GetUserByIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -210,25 +203,22 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception>
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than
/// a duplicate verification record.
/// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(
Guid userAccountId
)
{
var user = await GetUserByIdAsync(userAccountId);
if (user == null)
{
return null;
}
UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null) return null;
// Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId))
{
return user;
}
if (await IsUserVerifiedAsync(userAccountId)) return user;
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure;
@@ -256,15 +246,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value;
}
@@ -286,11 +276,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override Domain.Entities.UserAccount MapToEntity(
protected override UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
return new UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
@@ -304,7 +294,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
: (byte[])reader["Timer"]
};
}
@@ -316,21 +306,21 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
UserCredential entity = new()
{
UserCredentialId = reader.GetGuid(
reader.GetOrdinal("UserCredentialId")
),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
};
// Optional columns
var hasTimer =
bool hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<System.Data.DataRow>()
?.Rows.Cast<DataRow>()
.Any(r =>
string.Equals(
r["ColumnName"]?.ToString(),
@@ -340,11 +330,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
) ?? false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"];
}
return entity;
}
@@ -362,7 +350,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
object? value
)
{
var p = command.CreateParameter();
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);

View File

@@ -18,7 +18,7 @@ public interface IAuthRepository
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Entities.UserAccount> RegisterUserAsync(
Task<UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
@@ -33,7 +33,7 @@ public interface IAuthRepository
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email);
Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
@@ -41,7 +41,7 @@ public interface IAuthRepository
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username);
Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
@@ -66,14 +66,14 @@ public interface IAuthRepository
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary>
/// Retrieves a user account by ID.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.

View File

@@ -10,10 +10,12 @@ public enum TokenType
{
/// <summary>A short-lived token used to authorize API requests.</summary>
AccessToken,
/// <summary>A long-lived token used to obtain new access tokens.</summary>
RefreshToken,
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
ConfirmationToken,
ConfirmationToken
}
/// <summary>
@@ -43,8 +45,10 @@ public static class TokenServiceExpirationHours
{
/// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
public const double RefreshTokenHours = 504; // 21 days
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes
}

View File

@@ -1,5 +1,5 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
@@ -13,12 +13,11 @@ namespace Features.Auth.Services;
/// </summary>
public class TokenService : ITokenService
{
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly IAuthRepository _authRepository;
private readonly string _accessTokenSecret;
private readonly string _refreshTokenSecret;
private readonly IAuthRepository _authRepository;
private readonly string _confirmationTokenSecret;
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary>
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
@@ -39,13 +38,16 @@ public class TokenService : ITokenService
_authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set");
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set");
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set");
}
/// <summary>
@@ -56,7 +58,7 @@ public class TokenService : ITokenService
/// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user)
{
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
}
@@ -68,7 +70,7 @@ public class TokenService : ITokenService
/// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user)
{
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
}
@@ -80,7 +82,7 @@ public class TokenService : ITokenService
/// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user)
{
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
}
@@ -100,17 +102,17 @@ public class TokenService : ITokenService
if (typeof(T) != typeof(TokenType))
throw new InvalidOperationException("Invalid token type");
var tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
string tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
throw new InvalidOperationException("Invalid token type");
var tokenType = (TokenType)parsed;
TokenType tokenType = (TokenType)parsed;
return tokenType switch
{
TokenType.AccessToken => GenerateAccessToken(user),
TokenType.RefreshToken => GenerateRefreshToken(user),
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
_ => throw new InvalidOperationException("Invalid token type"),
_ => throw new InvalidOperationException("Invalid token type")
};
}
@@ -123,7 +125,9 @@ public class TokenService : ITokenService
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
{
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <summary>
/// Validates a refresh token against the refresh token secret.
@@ -134,7 +138,9 @@ public class TokenService : ITokenService
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
{
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <summary>
/// Validates a confirmation token against the confirmation token secret.
@@ -145,7 +151,31 @@ public class TokenService : ITokenService
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
{
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
}
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
string newAccess = GenerateAccessToken(user);
string newRefresh = GenerateRefreshToken(user);
return new RefreshTokenResult(user, newRefresh, newAccess);
}
/// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
@@ -163,15 +193,15 @@ public class TokenService : ITokenService
{
try
{
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
if (!Guid.TryParse(userIdClaim, out var userId))
if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
return new ValidatedToken(userId, usernameClaim, principal);
@@ -185,26 +215,4 @@ public class TokenService : ITokenService
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
}
}
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
var newAccess = GenerateAccessToken(user);
var newRefresh = GenerateRefreshToken(user);
return new RefreshTokenResult(user, newRefresh, newAccess);
}
}

View File

@@ -1,38 +1,41 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Commands;
public class CreateBreweryHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly CreateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public CreateBreweryHandlerTests()
{
_handler = new CreateBreweryHandler(_repoMock.Object);
}
private static CreateBreweryLocation ValidLocation() =>
new(
CityId: Guid.NewGuid(),
AddressLine1: "123 Main St",
AddressLine2: null,
PostalCode: "12345",
Coordinates: null
private static CreateBreweryLocation ValidLocation()
{
return new CreateBreweryLocation(
Guid.NewGuid(),
"123 Main St",
null,
"12345",
null
);
}
[Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
{
var command = new CreateBreweryCommand(
PostedById: Guid.NewGuid(),
BreweryName: "MyBrew",
Description: "Desc",
Location: ValidLocation()
CreateBreweryCommand command = new(
Guid.NewGuid(),
"MyBrew",
"Desc",
ValidLocation()
);
BreweryPost? persisted = null;
@@ -41,9 +44,9 @@ public class CreateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
var before = DateTime.UtcNow;
var result = await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow;
DateTime before = DateTime.UtcNow;
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);

View File

@@ -9,9 +9,9 @@ public class DeleteBreweryHandlerTests
[Fact]
public async Task Handle_DelegatesToRepository()
{
var repoMock = new Mock<IBreweryRepository>();
var handler = new DeleteBreweryHandler(repoMock.Object);
var id = Guid.NewGuid();
Mock<IBreweryRepository> repoMock = new();
DeleteBreweryHandler handler = new(repoMock.Object);
Guid id = Guid.NewGuid();
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);

View File

@@ -1,15 +1,15 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Commands;
public class UpdateBreweryHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly UpdateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public UpdateBreweryHandlerTests()
{
@@ -19,12 +19,12 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
{
var command = new UpdateBreweryCommand(
BreweryPostId: Guid.NewGuid(),
PostedById: Guid.NewGuid(),
BreweryName: "Renamed",
Description: "New description",
Location: null
UpdateBreweryCommand command = new(
Guid.NewGuid(),
Guid.NewGuid(),
"Renamed",
"New description",
null
);
BreweryPost? persisted = null;
@@ -33,9 +33,9 @@ public class UpdateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
var before = DateTime.UtcNow;
DateTime before = DateTime.UtcNow;
await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow;
DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
@@ -49,12 +49,12 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
{
var command = new UpdateBreweryCommand(
BreweryPostId: Guid.NewGuid(),
PostedById: Guid.NewGuid(),
BreweryName: "Name",
Description: "Description",
Location: null
UpdateBreweryCommand command = new(
Guid.NewGuid(),
Guid.NewGuid(),
"Name",
"Description",
null
);
BreweryPost? persisted = null;
@@ -71,20 +71,20 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
{
var locationCommand = new UpdateBreweryLocation(
BreweryPostLocationId: Guid.NewGuid(),
CityId: Guid.NewGuid(),
AddressLine1: "456 Oak Ave",
AddressLine2: "Suite 2",
PostalCode: "54321",
Coordinates: null
UpdateBreweryLocation locationCommand = new(
Guid.NewGuid(),
Guid.NewGuid(),
"456 Oak Ave",
"Suite 2",
"54321",
null
);
var command = new UpdateBreweryCommand(
BreweryPostId: Guid.NewGuid(),
PostedById: Guid.NewGuid(),
BreweryName: "Name",
Description: "Description",
Location: locationCommand
UpdateBreweryCommand command = new(
Guid.NewGuid(),
Guid.NewGuid(),
"Name",
"Description",
locationCommand
);
BreweryPost? persisted = null;

View File

@@ -1,15 +1,16 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Queries;
public class GetAllBreweriesHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetAllBreweriesHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetAllBreweriesHandlerTests()
{
@@ -22,7 +23,7 @@ public class GetAllBreweriesHandlerTests
_repoMock.Setup(r => r.GetAllAsync(10, 5))
.ReturnsAsync(Array.Empty<BreweryPost>());
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
result.Should().BeEmpty();
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
@@ -31,15 +32,15 @@ public class GetAllBreweriesHandlerTests
[Fact]
public async Task Handle_ReturnsAllBreweries_FromRepository()
{
var breweries = new[]
BreweryPost[] breweries = new[]
{
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }
};
_repoMock.Setup(r => r.GetAllAsync(null, null))
.ReturnsAsync(breweries);
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
IEnumerable<BreweryDto> result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
}

View File

@@ -1,15 +1,16 @@
using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetBreweryById;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Queries;
public class GetBreweryByIdHandlerTests
{
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetBreweryByIdHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetBreweryByIdHandlerTests()
{
@@ -19,11 +20,11 @@ public class GetBreweryByIdHandlerTests
[Fact]
public async Task Handle_ReturnsBrewery_WhenFound()
{
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
.ReturnsAsync(brewery);
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
result.Should().NotBeNull();
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
@@ -32,11 +33,11 @@ public class GetBreweryByIdHandlerTests
[Fact]
public async Task Handle_ReturnsNull_WhenNotFound()
{
var id = Guid.NewGuid();
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id))
.ReturnsAsync((BreweryPost?)null);
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
BreweryDto? result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
result.Should().BeNull();
}

View File

@@ -1,25 +1,27 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Features.Breweries.Repository;
using Domain.Entities;
using Features.Breweries.Repository;
using FluentAssertions;
namespace Features.Breweries.Tests.Repository;
public class BreweryRepositoryTests
{
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
private static BreweryRepository CreateRepo(MockDbConnection conn)
{
return new BreweryRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
{
var breweryId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid breweryId = Guid.NewGuid();
MockDbConnection conn = new();
// Repository calls the stored procedure
const string getByIdSql = "USP_GetBreweryById";
var locationId = Guid.NewGuid();
Guid locationId = Guid.NewGuid();
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
.ReturnsTable(
@@ -56,8 +58,8 @@ public class BreweryRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(breweryId);
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(breweryId);
result.Should().NotBeNull();
result!.BreweryPostId.Should().Be(breweryId);
result.Location.Should().NotBeNull();
@@ -67,23 +69,22 @@ public class BreweryRepositoryTests
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(Guid.NewGuid());
BreweryRepository repo = CreateRepo(conn);
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
result.Should().BeNull();
}
[Fact]
public async Task CreateAsync_ExecutesSuccessfully()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
.ReturnsScalar(1);
var repo = CreateRepo(conn);
var brewery = new BreweryPost
BreweryRepository repo = CreateRepo(conn);
BreweryPost brewery = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = Guid.NewGuid(),
@@ -101,7 +102,7 @@ public class BreweryRepositoryTests
};
// Should not throw
var act = async () => await repo.CreateAsync(brewery);
Func<Task> act = async () => await repo.CreateAsync(brewery);
await act.Should().NotThrowAsync();
}
}

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -20,7 +20,7 @@ public class CreateBreweryHandler(IBreweryRepository repository)
/// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
{
var entity = new BreweryPost
BreweryPost entity = new()
{
BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById,
@@ -34,8 +34,8 @@ public class CreateBreweryHandler(IBreweryRepository repository)
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
Coordinates = request.Location.Coordinates
}
};
await repository.CreateAsync(entity);

View File

@@ -10,6 +10,8 @@ namespace Features.Breweries.Commands.DeleteBrewery;
public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand>
{
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) =>
repository.DeleteAsync(request.BreweryPostId);
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{
return repository.DeleteAsync(request.BreweryPostId);
}
}

View File

@@ -21,14 +21,16 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken)
{
var entity = new BreweryPost
BreweryPost entity = new()
{
BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null ? null : new BreweryPostLocation
Location = request.Location is null
? null
: new BreweryPostLocation
{
BreweryPostLocationId = request.Location.BreweryPostLocationId,
BreweryPostId = request.BreweryPostId,
@@ -36,8 +38,8 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
Coordinates = request.Location.Coordinates
}
};
await repository.UpdateAsync(entity);

View File

@@ -37,14 +37,14 @@ public class BreweryController(IMediator mediator) : ControllerBase
[HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
{
var brewery = await mediator.Send(new GetBreweryByIdQuery(id));
BreweryDto? brewery = await mediator.Send(new GetBreweryByIdQuery(id));
if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery retrieved successfully.",
Payload = brewery,
Payload = brewery
});
}
@@ -61,11 +61,11 @@ public class BreweryController(IMediator mediator) : ControllerBase
[FromQuery] int? limit,
[FromQuery] int? offset)
{
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
IEnumerable<BreweryDto> breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
{
Message = "Breweries retrieved successfully.",
Payload = breweries,
Payload = breweries
});
}
@@ -73,23 +73,32 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// Creates a new brewery post.
/// </summary>
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>.</returns>
/// <returns>
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of the newly created
/// <see cref="BreweryDto" />.
/// </returns>
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
{
var brewery = await mediator.Send(command);
BreweryDto brewery = await mediator.Send(command);
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
{
Message = "Brewery created successfully.",
Payload = brewery,
Payload = brewery
});
}
/// <summary>
/// Updates an existing brewery post.
/// </summary>
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="command"/>'s ID.</param>
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param>
/// <param name="id">
/// The unique identifier of the brewery post from the route, which must match <paramref name="command" />
/// 's ID.
/// </param>
/// <param name="command">
/// The updated brewery details, including the posting user, name, description, and optional
/// location.
/// </param>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of the updated <see cref="BreweryDto" /> if the
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
@@ -101,11 +110,11 @@ public class BreweryController(IMediator mediator) : ControllerBase
if (command.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
var brewery = await mediator.Send(command);
BreweryDto brewery = await mediator.Send(command);
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery updated successfully.",
Payload = brewery,
Payload = brewery
});
}

View File

@@ -13,7 +13,9 @@ public static class BreweryDtoMapper
/// </summary>
/// <param name="brewery">The brewery post entity to map.</param>
/// <returns>The mapped <see cref="BreweryDto" />.</returns>
public static BreweryDto ToDto(this BreweryPost brewery) => new()
public static BreweryDto ToDto(this BreweryPost brewery)
{
return new BreweryDto
{
BreweryPostId = brewery.BreweryPostId,
PostedById = brewery.PostedById,
@@ -22,7 +24,9 @@ public static class BreweryDtoMapper
CreatedAt = brewery.CreatedAt,
UpdatedAt = brewery.UpdatedAt,
Timer = brewery.Timer,
Location = brewery.Location is null ? null : new BreweryLocationDto
Location = brewery.Location is null
? null
: new BreweryLocationDto
{
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
BreweryPostId = brewery.Location.BreweryPostId,
@@ -30,7 +34,8 @@ public static class BreweryDtoMapper
AddressLine1 = brewery.Location.AddressLine1,
AddressLine2 = brewery.Location.AddressLine2,
PostalCode = brewery.Location.PostalCode,
Coordinates = brewery.Location.Coordinates,
},
Coordinates = brewery.Location.Coordinates
}
};
}
}

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
@@ -13,7 +14,7 @@ public class GetAllBreweriesHandler(IBreweryRepository repository)
{
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken)
{
var breweries = await repository.GetAllAsync(request.Limit, request.Offset);
IEnumerable<BreweryPost> breweries = await repository.GetAllAsync(request.Limit, request.Offset);
return breweries.Select(b => b.ToDto());
}
}

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Breweries.Dtos;
using Features.Breweries.Repository;
using MediatR;
@@ -13,7 +14,7 @@ public class GetBreweryByIdHandler(IBreweryRepository repository)
{
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken)
{
var brewery = await repository.GetByIdAsync(request.BreweryPostId);
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
return brewery?.ToDto();
}
}

View File

@@ -1,3 +1,4 @@
using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Sql;
@@ -20,18 +21,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandType = System.Data.CommandType.StoredProcedure;
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandType = CommandType.StoredProcedure;
command.CommandText = "USP_GetBreweryById";
AddParameter(command, "@BreweryPostID", id);
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return MapToEntity(reader);
}
await using DbDataReader reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync()) return MapToEntity(reader);
return null;
}
@@ -46,10 +44,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetAllBreweries";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
@@ -57,13 +55,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var breweries = new List<BreweryPost>();
await using DbDataReader reader = await command.ExecuteReaderAsync();
List<BreweryPost> breweries = new();
while (await reader.ReadAsync())
{
breweries.Add(MapToEntity(reader));
}
while (await reader.ReadAsync()) breweries.Add(MapToEntity(reader));
return breweries;
}
@@ -77,10 +72,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_UpdateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
AddParameter(command, "@BreweryName", brewery.BreweryName);
@@ -103,10 +98,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_DeleteBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", id);
await command.ExecuteNonQueryAsync();
@@ -120,16 +115,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task CreateAsync(BreweryPost brewery)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandType = CommandType.StoredProcedure;
if (brewery.Location is null)
{
throw new ArgumentException("Location must be provided when creating a brewery.");
}
if (brewery.Location is null) throw new ArgumentException("Location must be provided when creating a brewery.");
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
@@ -140,7 +132,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
await command.ExecuteNonQueryAsync();
}
/// <summary>
@@ -152,15 +143,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
protected override BreweryPost MapToEntity(DbDataReader reader)
{
var brewery = new BreweryPost();
BreweryPost brewery = new();
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
var ordPostedById = reader.GetOrdinal("PostedById");
var ordBreweryName = reader.GetOrdinal("BreweryName");
var ordDescription = reader.GetOrdinal("Description");
var ordCreatedAt = reader.GetOrdinal("CreatedAt");
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
var ordTimer = reader.GetOrdinal("Timer");
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
int ordPostedById = reader.GetOrdinal("PostedById");
int ordBreweryName = reader.GetOrdinal("BreweryName");
int ordDescription = reader.GetOrdinal("Description");
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
int ordTimer = reader.GetOrdinal("Timer");
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
brewery.PostedById = reader.GetGuid(ordPostedById);
@@ -172,39 +163,39 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
// Read timer (varbinary/rowversion) robustly
if (reader.IsDBNull(ordTimer))
{
brewery.Timer = null;
}
else
{
try
{
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
}
catch
{
var length = reader.GetBytes(ordTimer, 0, null, 0, 0);
var buffer = new byte[length];
long length = reader.GetBytes(ordTimer, 0, null, 0, 0);
byte[] buffer = new byte[length];
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
brewery.Timer = buffer;
}
}
// Map BreweryPostLocation if columns are present
try
{
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
if (!reader.IsDBNull(ordLocationId))
{
var location = new BreweryPostLocation
BreweryPostLocation location = new()
{
BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")),
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2"))
? null
: reader.GetString(reader.GetOrdinal("AddressLine2")),
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
? null
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
};
brewery.Location = location;
}
@@ -230,7 +221,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
object? value
)
{
var p = command.CreateParameter();
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);

View File

@@ -10,9 +10,9 @@ public class SendRegistrationEmailHandlerTests
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
var dispatcherMock = new Mock<IEmailDispatcher>();
var handler = new SendRegistrationEmailHandler(dispatcherMock.Object);
var command = new SendRegistrationEmailCommand("Aaron", "aaron@example.com", "token-123");
Mock<IEmailDispatcher> dispatcherMock = new();
SendRegistrationEmailHandler handler = new(dispatcherMock.Object);
SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123");
await handler.Handle(command, CancellationToken.None);

View File

@@ -10,9 +10,9 @@ public class SendResendConfirmationEmailHandlerTests
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
var dispatcherMock = new Mock<IEmailDispatcher>();
var handler = new SendResendConfirmationEmailHandler(dispatcherMock.Object);
var command = new SendResendConfirmationEmailCommand("Aaron", "aaron@example.com", "token-456");
Mock<IEmailDispatcher> dispatcherMock = new();
SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object);
SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456");
await handler.Handle(command, CancellationToken.None);

View File

@@ -12,6 +12,8 @@ namespace Features.Emails.Commands.SendRegistrationEmail;
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendRegistrationEmailCommand>
{
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) =>
emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
{
return emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
}
}

View File

@@ -12,6 +12,9 @@ namespace Features.Emails.Commands.SendResendConfirmationEmail;
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
: IRequestHandler<SendResendConfirmationEmailCommand>
{
public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) =>
emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken)
{
return emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email,
request.ConfirmationToken);
}
}

View File

@@ -31,10 +31,10 @@ public class EmailDispatcher(
/// </summary>
public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
{
var confirmationLink =
string confirmationLink =
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
string emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
firstName,
confirmationLink
@@ -44,7 +44,7 @@ public class EmailDispatcher(
email,
"Welcome to The Biergarten App!",
emailHtml,
isHtml: true
true
);
}
@@ -54,10 +54,10 @@ public class EmailDispatcher(
/// </summary>
public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken)
{
var confirmationLink =
string confirmationLink =
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
string emailHtml =
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
firstName,
confirmationLink
@@ -67,7 +67,7 @@ public class EmailDispatcher(
email,
"Confirm Your Email - The Biergarten App",
emailHtml,
isHtml: true
true
);
}
}

View File

@@ -10,9 +10,9 @@ public class UpdateUserHandlerTests
[Fact]
public async Task Handle_DelegatesToRepository()
{
var repoMock = new Mock<IUserAccountRepository>();
var handler = new UpdateUserHandler(repoMock.Object);
var user = new UserAccount { UserAccountId = Guid.NewGuid() };
Mock<IUserAccountRepository> repoMock = new();
UpdateUserHandler handler = new(repoMock.Object);
UserAccount user = new() { UserAccountId = Guid.NewGuid() };
repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask);
await handler.Handle(new UpdateUserCommand(user), CancellationToken.None);

View File

@@ -1,7 +1,7 @@
using Domain.Entities;
using FluentAssertions;
using Features.UserManagement.Queries.GetAllUsers;
using Features.UserManagement.Repository;
using FluentAssertions;
using Moq;
namespace Features.UserManagement.Tests.Queries;
@@ -11,11 +11,11 @@ public class GetAllUsersHandlerTests
[Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository()
{
var repoMock = new Mock<IUserAccountRepository>();
var handler = new GetAllUsersHandler(repoMock.Object);
Mock<IUserAccountRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
var result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None);
IEnumerable<UserAccount> result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None);
result.Should().BeEmpty();
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);

View File

@@ -1,16 +1,16 @@
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Features.UserManagement.Queries.GetUserById;
using Features.UserManagement.Repository;
using FluentAssertions;
using Moq;
namespace Features.UserManagement.Tests.Queries;
public class GetUserByIdHandlerTests
{
private readonly Mock<IUserAccountRepository> _repoMock = new();
private readonly GetUserByIdHandler _handler;
private readonly Mock<IUserAccountRepository> _repoMock = new();
public GetUserByIdHandlerTests()
{
@@ -20,10 +20,10 @@ public class GetUserByIdHandlerTests
[Fact]
public async Task Handle_ReturnsUser_WhenFound()
{
var user = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "test" };
UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" };
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
var result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None);
UserAccount result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None);
result.Should().Be(user);
}
@@ -31,10 +31,10 @@ public class GetUserByIdHandlerTests
[Fact]
public async Task Handle_Throws_WhenNotFound()
{
var id = Guid.NewGuid();
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
var act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
Func<Task<UserAccount>> act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -1,18 +1,21 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Domain.Entities;
using Features.UserManagement.Repository;
using FluentAssertions;
namespace Features.UserManagement.Tests.Repository;
public class UserAccountRepositoryTests
{
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
private static UserAccountRepository CreateRepo(MockDbConnection conn)
{
return new UserAccountRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(
MockTable
@@ -40,8 +43,8 @@ public class UserAccountRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(
UserAccountRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetByIdAsync(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
);
@@ -53,7 +56,7 @@ public class UserAccountRepositoryTests
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(
MockTable
@@ -92,19 +95,19 @@ public class UserAccountRepositoryTests
)
);
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
UserAccountRepository repo = CreateRepo(conn);
List<UserAccount> results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results
.Select(r => r.Username)
.Should()
.BeEquivalentTo(new[] { "a", "b" });
.BeEquivalentTo("a", "b");
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
@@ -134,8 +137,8 @@ public class UserAccountRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
UserAccountRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
@@ -143,7 +146,7 @@ public class UserAccountRepositoryTests
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
@@ -171,8 +174,8 @@ public class UserAccountRepositoryTests
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
UserAccountRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}

View File

@@ -10,6 +10,8 @@ namespace Features.UserManagement.Commands.UpdateUser;
public class UpdateUserHandler(IUserAccountRepository repository)
: IRequestHandler<UpdateUserCommand>
{
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) =>
repository.UpdateAsync(request.UserAccount);
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
{
return repository.UpdateAsync(request.UserAccount);
}
}

View File

@@ -4,8 +4,8 @@ using Features.UserManagement.Queries.GetUserById;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Features.UserManagement.Controllers
{
namespace Features.UserManagement.Controllers;
/// <summary>
/// Provides read-only endpoints for retrieving user accounts.
/// </summary>
@@ -26,7 +26,7 @@ namespace Features.UserManagement.Controllers
[FromQuery] int? offset
)
{
var users = await mediator.Send(new GetAllUsersQuery(limit, offset));
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
return Ok(users);
}
@@ -38,8 +38,7 @@ namespace Features.UserManagement.Controllers
[HttpGet("{id:guid}")]
public async Task<ActionResult<UserAccount>> GetById(Guid id)
{
var user = await mediator.Send(new GetUserByIdQuery(id));
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
return Ok(user);
}
}
}

View File

@@ -11,6 +11,8 @@ namespace Features.UserManagement.Queries.GetAllUsers;
public class GetAllUsersHandler(IUserAccountRepository repository)
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
{
public Task<IEnumerable<UserAccount>> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) =>
repository.GetAllAsync(request.Limit, request.Offset);
public Task<IEnumerable<UserAccount>> Handle(GetAllUsersQuery request, CancellationToken cancellationToken)
{
return repository.GetAllAsync(request.Limit, request.Offset);
}
}

View File

@@ -15,7 +15,7 @@ public class GetUserByIdHandler(IUserAccountRepository repository)
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<UserAccount> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await repository.GetByIdAsync(request.UserAccountId);
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
return user;

View File

@@ -1,3 +1,5 @@
using Domain.Entities;
namespace Features.UserManagement.Repository;
/// <summary>
@@ -10,7 +12,7 @@ public interface IUserAccountRepository
/// </summary>
/// <param name="id">The unique identifier of the user account.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<UserAccount?> GetByIdAsync(Guid id);
/// <summary>
/// Retrieves all user accounts, optionally paginated.
@@ -18,7 +20,7 @@ public interface IUserAccountRepository
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
Task<IEnumerable<UserAccount>> GetAllAsync(
int? limit,
int? offset
);
@@ -27,7 +29,7 @@ public interface IUserAccountRepository
/// Updates an existing user account's details.
/// </summary>
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param>
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
Task UpdateAsync(UserAccount userAccount);
/// <summary>
/// Deletes a user account by its unique identifier.
@@ -40,12 +42,12 @@ public interface IUserAccountRepository
/// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<Domain.Entities.UserAccount?> GetByUsernameAsync(string username);
Task<UserAccount?> GetByUsernameAsync(string username);
/// <summary>
/// Retrieves a user account by email address.
/// </summary>
/// <param name="email">The email address to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
Task<Domain.Entities.UserAccount?> GetByEmailAsync(string email);
Task<UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -1,5 +1,6 @@
using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Sql;
namespace Features.UserManagement.Repository;
@@ -10,7 +11,7 @@ namespace Features.UserManagement.Repository;
/// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
: Repository<UserAccount>(connectionFactory),
IUserAccountRepository
{
/// <summary>
@@ -19,16 +20,16 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="id">The unique identifier of the user account.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id)
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -41,13 +42,13 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="Domain.Entities.UserAccount" /> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
public async Task<IEnumerable<UserAccount>> GetAllAsync(
int? limit,
int? offset
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
@@ -57,13 +58,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Entities.UserAccount>();
await using DbDataReader reader = await command.ExecuteReaderAsync();
List<UserAccount> users = new();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
while (await reader.ReadAsync()) users.Add(MapToEntity(reader));
return users;
}
@@ -74,10 +72,10 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// </summary>
/// <param name="userAccount">The user account containing updated values. Must have a valid <c>UserAccountId</c>.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(Domain.Entities.UserAccount userAccount)
public async Task UpdateAsync(UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
@@ -98,8 +96,8 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
@@ -113,18 +111,18 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="username">The username to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
public async Task<UserAccount?> GetByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -134,18 +132,18 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// <param name="email">The email address to search for.</param>
/// <returns>The matching <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<Domain.Entities.UserAccount?> GetByEmailAsync(
public async Task<UserAccount?> GetByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
@@ -154,11 +152,11 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override Domain.Entities.UserAccount MapToEntity(
protected override UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
return new UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
@@ -172,7 +170,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
: (byte[])reader["Timer"]
};
}
@@ -189,7 +187,7 @@ public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
object? value
)
{
var p = command.CreateParameter();
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);

View File

@@ -13,6 +13,5 @@
</tr>
@code {
[Parameter]
public string? FooterText { get; set; }
[Parameter] public string? FooterText { get; set; }
}

View File

@@ -1,5 +1,4 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
@@ -12,8 +11,13 @@
<title>Resend Confirmation - The Biergarten App</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
* {
font-family: Arial, sans-serif !important;
}
table {
border-collapse: collapse;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
@@ -31,7 +35,8 @@
<td align="center" style="padding:40px 10px;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
<tr><td>
<tr>
<td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
@@ -62,11 +67,13 @@
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:w="urn:schemas-microsoft-com:office:word"
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
<center
style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
Confirm Email Again
</center>
</v:roundrect>
@@ -109,9 +116,7 @@
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter] public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
[Parameter] public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -1,5 +1,4 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
@@ -12,8 +11,13 @@
<title>Welcome to The Biergarten App!</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
* {
font-family: Arial, sans-serif !important;
}
table {
border-collapse: collapse;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
@@ -69,9 +73,15 @@
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:250px;" arcsize="10%" stroke="f" fillcolor="#f59e0b">
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:w="urn:schemas-microsoft-com:office:word" href="@ConfirmationLink"
style="height:50px;v-text-anchor:middle;width:250px;" arcsize="10%" stroke="f"
fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:600;">Confirm Your Email</center>
<center
style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:600;">
Confirm Your Email
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
@@ -110,9 +120,7 @@
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter] public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
[Parameter] public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -1,6 +1,7 @@
using Infrastructure.Email.Templates.Mail;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Email.Templates.Rendering;
@@ -8,7 +9,10 @@ namespace Infrastructure.Email.Templates.Rendering;
/// <summary>
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
/// </summary>
/// <param name="serviceProvider">The service provider used to resolve dependencies for the Razor component rendering pipeline.</param>
/// <param name="serviceProvider">
/// The service provider used to resolve dependencies for the Razor component rendering
/// pipeline.
/// </param>
/// <param name="loggerFactory">The logger factory passed to the <see cref="HtmlRenderer" /> used to render components.</param>
public class EmailTemplateProvider(
IServiceProvider serviceProvider,
@@ -26,10 +30,10 @@ public class EmailTemplateProvider(
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
Dictionary<string, object?> parameters = new()
{
{ nameof(UserRegistration.Username), username },
{ nameof(UserRegistration.ConfirmationLink), confirmationLink },
{ nameof(UserRegistration.ConfirmationLink), confirmationLink }
};
return await RenderComponentAsync<UserRegistration>(parameters);
@@ -46,10 +50,10 @@ public class EmailTemplateProvider(
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
Dictionary<string, object?> parameters = new()
{
{ nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink }
};
return await RenderComponentAsync<ResendConfirmation>(parameters);
@@ -68,15 +72,15 @@ public class EmailTemplateProvider(
)
where TComponent : IComponent
{
await using var htmlRenderer = new HtmlRenderer(
await using HtmlRenderer htmlRenderer = new(
serviceProvider,
loggerFactory
);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
string html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var parameterView = ParameterView.FromDictionary(parameters);
var output = await htmlRenderer.RenderComponentAsync<TComponent>(
ParameterView parameterView = ParameterView.FromDictionary(parameters);
HtmlRootComponent output = await htmlRenderer.RenderComponentAsync<TComponent>(
parameterView
);

View File

@@ -10,13 +10,13 @@ namespace Infrastructure.Email;
/// </summary>
public class SmtpEmailProvider : IEmailProvider
{
private readonly string _host;
private readonly int _port;
private readonly string? _username;
private readonly string? _password;
private readonly bool _useSsl;
private readonly string _fromEmail;
private readonly string _fromName;
private readonly string _host;
private readonly string? _password;
private readonly int _port;
private readonly string? _username;
private readonly bool _useSsl;
/// <summary>
/// Initializes a new instance of <see cref="SmtpEmailProvider" />, reading SMTP configuration
@@ -25,13 +25,30 @@ public class SmtpEmailProvider : IEmailProvider
/// <remarks>
/// Reads the following environment variables:
/// <list type="bullet">
/// <item><description><c>SMTP_HOST</c> (required) - the SMTP server hostname.</description></item>
/// <item><description><c>SMTP_PORT</c> (optional, default "587") - the SMTP server port, must be a valid integer.</description></item>
/// <item><description><c>SMTP_USERNAME</c> (optional) - the username used for authentication.</description></item>
/// <item><description><c>SMTP_PASSWORD</c> (optional) - the password used for authentication.</description></item>
/// <item><description><c>SMTP_USE_SSL</c> (optional, default "true") - whether to use StartTls when connecting.</description></item>
/// <item><description><c>SMTP_FROM_EMAIL</c> (required) - the email address used as the sender.</description></item>
/// <item><description><c>SMTP_FROM_NAME</c> (optional, default "The Biergarten") - the display name used as the sender.</description></item>
/// <item>
/// <description><c>SMTP_HOST</c> (required) - the SMTP server hostname.</description>
/// </item>
/// <item>
/// <description><c>SMTP_PORT</c> (optional, default "587") - the SMTP server port, must be a valid integer.</description>
/// </item>
/// <item>
/// <description><c>SMTP_USERNAME</c> (optional) - the username used for authentication.</description>
/// </item>
/// <item>
/// <description><c>SMTP_PASSWORD</c> (optional) - the password used for authentication.</description>
/// </item>
/// <item>
/// <description><c>SMTP_USE_SSL</c> (optional, default "true") - whether to use StartTls when connecting.</description>
/// </item>
/// <item>
/// <description><c>SMTP_FROM_EMAIL</c> (required) - the email address used as the sender.</description>
/// </item>
/// <item>
/// <description>
/// <c>SMTP_FROM_NAME</c> (optional, default "The Biergarten") - the display name used as the
/// sender.
/// </description>
/// </item>
/// </list>
/// </remarks>
/// <exception cref="InvalidOperationException">
@@ -45,19 +62,17 @@ public class SmtpEmailProvider : IEmailProvider
"SMTP_HOST environment variable is not set"
);
var portString =
string portString =
Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
if (!int.TryParse(portString, out _port))
{
throw new InvalidOperationException(
$"SMTP_PORT '{portString}' is not a valid integer"
);
}
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
var useSslString =
string useSslString =
Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
_useSsl = bool.Parse(useSslString);
@@ -99,7 +114,10 @@ public class SmtpEmailProvider : IEmailProvider
/// <param name="subject">Email subject line</param>
/// <param name="body">Email body (HTML or plain text)</param>
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
/// <exception cref="InvalidOperationException">Thrown when connecting, authenticating, or sending via SMTP fails. The original exception is included as the inner exception.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when connecting, authenticating, or sending via SMTP fails. The
/// original exception is included as the inner exception.
/// </exception>
public async Task SendAsync(
IEnumerable<string> to,
string subject,
@@ -107,34 +125,27 @@ public class SmtpEmailProvider : IEmailProvider
bool isHtml = true
)
{
var message = new MimeMessage();
MimeMessage message = new();
message.From.Add(new MailboxAddress(_fromName, _fromEmail));
foreach (var recipient in to)
{
message.To.Add(MailboxAddress.Parse(recipient));
}
foreach (string recipient in to) message.To.Add(MailboxAddress.Parse(recipient));
message.Subject = subject;
var bodyBuilder = new BodyBuilder();
BodyBuilder bodyBuilder = new();
if (isHtml)
{
bodyBuilder.HtmlBody = body;
}
else
{
bodyBuilder.TextBody = body;
}
message.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
using SmtpClient client = new();
try
{
// Determine the SecureSocketOptions based on SSL setting
var secureSocketOptions = _useSsl
SecureSocketOptions secureSocketOptions = _useSsl
? SecureSocketOptions.StartTls
: SecureSocketOptions.None;
@@ -145,9 +156,7 @@ public class SmtpEmailProvider : IEmailProvider
!string.IsNullOrEmpty(_username)
&& !string.IsNullOrEmpty(_password)
)
{
await client.AuthenticateAsync(_username, _password);
}
await client.SendAsync(message);
await client.DisconnectAsync(true);

View File

@@ -28,6 +28,9 @@ public interface ITokenInfrastructure
/// <param name="token">The JWT string to validate.</param>
/// <param name="secret">The symmetric secret used to verify the token's signature.</param>
/// <returns>A <see cref="ClaimsPrincipal" /> representing the validated token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">Thrown when the token is invalid, expired, or fails validation.</exception>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is invalid, expired, or fails
/// validation.
/// </exception>
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
}

Some files were not shown because too many files have changed in this diff Show More