24 Commits

Author SHA1 Message Date
Aaron Po
ac97c03612 commit csharpier config update 2026-06-20 15:26:41 -04:00
Aaron Po
460b0dca9a update mailkit 2026-06-20 15:26:05 -04:00
Aaron Po
f7e3ace9c1 Format ./web/backend/Shared/Shared.Contracts 2026-06-20 15:14:54 -04:00
Aaron Po
95f4fb8b91 Format ./web/backend/Shared/Shared.Application 2026-06-20 15:14:53 -04:00
Aaron Po
1ebe17bf42 Format ./web/backend/API/API.Specs 2026-06-20 15:14:21 -04:00
Aaron Po
678ee21bbd Format ./web/backend/API/API.Core 2026-06-20 15:14:20 -04:00
Aaron Po
3d046e369c Format ./web/backend/Domain/Domain.Exceptions 2026-06-20 15:13:58 -04:00
Aaron Po
034436d018 Format ./web/backend/Domain/Domain.Entities 2026-06-20 15:13:57 -04:00
Aaron Po
58833f330c Format ./web/backend/Database/Database.Seed 2026-06-20 15:13:32 -04:00
Aaron Po
bfda60d831 Format ./web/backend/Database/Database.Migrations 2026-06-20 15:13:31 -04:00
Aaron Po
39b5d5bf1d Format ./web/backend/Infrastructure/Infrastructure.Sql 2026-06-20 15:13:02 -04:00
Aaron Po
e5d08c9ec8 Format ./web/backend/Infrastructure/Infrastructure.PasswordHashing 2026-06-20 15:13:02 -04:00
Aaron Po
1272a7ff40 Format ./web/backend/Infrastructure/Infrastructure.Jwt 2026-06-20 15:13:02 -04:00
Aaron Po
0933ded022 Format ./web/backend/Infrastructure/Infrastructure.Email.Templates 2026-06-20 15:13:01 -04:00
Aaron Po
a37f2971a7 Format ./web/backend/Infrastructure/Infrastructure.Email 2026-06-20 15:13:01 -04:00
Aaron Po
9ac039dfeb Format ./web/backend/Features/Features.UserManagement.Tests 2026-06-20 15:09:43 -04:00
Aaron Po
1875dcc8bf Format ./web/backend/Features/Features.UserManagement 2026-06-20 15:09:43 -04:00
Aaron Po
c19af68c7c Format ./web/backend/Features/Features.Emails.Tests 2026-06-20 15:09:42 -04:00
Aaron Po
433c3fbe3d Format ./web/backend/Features/Features.Emails 2026-06-20 15:09:42 -04:00
Aaron Po
aadeab3afa Format ./web/backend/Features/Features.Breweries.Tests 2026-06-20 15:09:42 -04:00
Aaron Po
0f77ae43b5 Format ./web/backend/Features/Features.Breweries 2026-06-20 15:09:41 -04:00
Aaron Po
4de455f34d Format ./web/backend/Features/Features.Auth.Tests 2026-06-20 15:09:41 -04:00
Aaron Po
2bdd7bb0c0 Format ./web/backend/Features/Features.Auth 2026-06-20 15:09:40 -04:00
Aaron Po
5b882ac51c code style cleanup 2026-06-20 13:55:17 -04:00
169 changed files with 4129 additions and 4081 deletions

View File

@@ -1,5 +1,5 @@
{ {
"printWidth": 80, "printWidth": 100,
"useTabs": false, "useTabs": false,
"indentSize": 4, "indentSize": 4,
"endOfLine": "lf", "endOfLine": "lf",

View File

@@ -8,15 +8,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
Include="Microsoft.AspNetCore.OpenApi"
Version="9.0.11"
/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
Include="FluentValidation.AspNetCore"
Version="11.3.0"
/>
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup> </ItemGroup>

View File

@@ -1,10 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json;
using Shared.Contracts;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication; namespace API.Core.Authentication;
@@ -13,8 +13,8 @@ namespace API.Core.Authentication;
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme. /// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
/// </summary> /// </summary>
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param> /// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param> /// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}"/>.</param> /// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param> /// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param> /// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
public class JwtAuthenticationHandler( public class JwtAuthenticationHandler(
@@ -36,63 +36,42 @@ public class JwtAuthenticationHandler(
/// token fails validation. /// token fails validation.
/// </remarks> /// </remarks>
/// <returns> /// <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. /// on success, or a failure result describing why authentication could not be completed.
/// </returns> /// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Use the same access-token secret source as TokenService to avoid mismatched validation. // Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET"); string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"]; secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured"); return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists // Check if Authorization header exists
if ( if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
)
)
{
return AuthenticateResult.Fail("Authorization header is missing"); return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString(); string authHeader = authHeaderValue.ToString();
if ( if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
!authHeader.StartsWith( return AuthenticateResult.Fail("Invalid authorization header format");
"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 try
{ {
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync( ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token, token,
secret secret
); );
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name); AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
catch (Exception ex) catch (Exception ex)
{ {
return AuthenticateResult.Fail( return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
$"Token validation failed: {ex.Message}"
);
} }
} }
@@ -106,13 +85,16 @@ public class JwtAuthenticationHandler(
Response.ContentType = "application/json"; Response.ContentType = "application/json";
Response.StatusCode = 401; 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); await Response.WriteAsJsonAsync(response);
} }
} }
/// <summary> /// <summary>
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler"/>. /// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary> /// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions"/>.</remarks> /// <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,19 +1,20 @@
using Microsoft.AspNetCore.Mvc; 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>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{ {
/// <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>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{
/// <summary> /// <summary>
/// Returns a generic 404 response for any request that did not match a defined route. /// Returns a generic 404 response for any request that did not match a defined route.
/// </summary> /// </summary>
@@ -23,5 +24,4 @@ namespace API.Core.Controllers
{ {
return NotFound(new { message = "Route not found." }); return NotFound(new { message = "Route not found." });
} }
}
} }

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Shared.Contracts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers; namespace API.Core.Controllers;
@@ -17,15 +17,15 @@ public class ProtectedController : ControllerBase
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims. /// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> whose payload contains the /// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier"/>) and <c>username</c> /// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
/// (from <see cref="ClaimTypes.Name"/>), either of which may be <c>null</c> if not present in the token. /// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns> /// </returns>
[HttpGet] [HttpGet]
public ActionResult<ResponseBody<object>> Get() public ActionResult<ResponseBody<object>> Get()
{ {
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value; string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok( return Ok(
new ResponseBody<object> new ResponseBody<object>

View File

@@ -1,11 +1,11 @@
// API.Core/Filters/GlobalExceptionFilter.cs // API.Core/Filters/GlobalExceptionFilter.cs
using Shared.Contracts;
using Domain.Exceptions; using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core; namespace API.Core;
@@ -14,40 +14,76 @@ namespace API.Core;
/// consistent JSON error responses with appropriate HTTP status codes. /// consistent JSON error responses with appropriate HTTP status codes.
/// </summary> /// </summary>
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param> /// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
: IExceptionFilter
{ {
/// <summary> /// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result"/> to an appropriate error response, /// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
/// marking the exception as handled. /// marking the exception as handled.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Maps exception types to responses as follows: /// Maps exception types to responses as follows:
/// <list type="bullet"> /// <list type="bullet">
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item> /// <item>
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item> /// <description>
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item> /// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item> /// validation error messages.
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item> /// </description>
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item> /// </item>
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item> /// <item>
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></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> /// </list>
/// </remarks> /// </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) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception) switch (context.Exception)
{ {
case FluentValidation.ValidationException fluentValidationException: case ValidationException fluentValidationException:
var errors = fluentValidationException Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName) .Errors.GroupBy(e => e.PropertyName)
.ToDictionary( .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult( context.Result = new BadRequestObjectResult(
new { message = "Validation failed", errors } new { message = "Validation failed", errors }
@@ -56,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case ConflictException ex: case ConflictException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 409, StatusCode = 409,
}; };
@@ -66,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case NotFoundException ex: case NotFoundException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 404, StatusCode = 404,
}; };
@@ -76,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case UnauthorizedException ex: case UnauthorizedException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 401, StatusCode = 401,
}; };
@@ -86,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case ForbiddenException ex: case ForbiddenException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 403, StatusCode = 403,
}; };
@@ -106,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case Domain.Exceptions.ValidationException ex: case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 400, StatusCode = 400,
}; };
@@ -117,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
default: default:
context.Result = new ObjectResult( context.Result = new ObjectResult(
new ResponseBody new ResponseBody { Message = "An unexpected error occurred" }
{
Message = "An unexpected error occurred",
}
) )
{ {
StatusCode = 500, StatusCode = 500,

View File

@@ -14,13 +14,14 @@ using Infrastructure.Jwt;
using Infrastructure.Sql; using Infrastructure.Sql;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
var builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter // Global Exception Filter
builder.Services.AddControllers(options => builder
{ .Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>(); options.Filters.Add<GlobalExceptionFilter>();
}) })
.AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly) .AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly); .AddApplicationPart(typeof(AuthController).Assembly);
@@ -55,16 +56,11 @@ builder.Services.AddHealthChecks();
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddConsole(); builder.Logging.AddConsole();
if (!builder.Environment.IsProduction()) if (!builder.Environment.IsProduction())
{
builder.Logging.AddDebug(); builder.Logging.AddDebug();
}
// Configure Dependency Injection ------------------------------------------------------------------------------------- // Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton< builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesUserManagement();
@@ -81,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication // Configure JWT Authentication
builder builder
.Services.AddAuthentication("JWT") .Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>( .AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
"JWT",
options => { }
);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
var app = builder.Build(); WebApplication app = builder.Build();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
@@ -106,7 +99,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound"); app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling // Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() => lifetime.ApplicationStopping.Register(() =>
{ {
app.Logger.LogInformation("Application is shutting down gracefully..."); app.Logger.LogInformation("Application is shutting down gracefully...");

View File

@@ -24,10 +24,7 @@
/> />
<!-- ASP.NET Core integration testing --> <!-- ASP.NET Core integration testing -->
<PackageReference <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,27 +1,27 @@
Feature: Protected Endpoint Access Token Validation Feature: Protected Endpoint Access Token Validation
As a backend developer As a backend developer
I want protected endpoints to validate access tokens I want protected endpoints to validate access tokens
So that unauthorized requests are rejected So that unauthorized requests are rejected
Scenario: Protected endpoint accepts valid access token Scenario: Protected endpoint accepts valid access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
When I submit a request to a protected endpoint with a valid access token When I submit a request to a protected endpoint with a valid access token
Then the response has HTTP status 200 Then the response has HTTP status 200
Scenario: Protected endpoint rejects missing access token Scenario: Protected endpoint rejects missing access token
Given the API is running Given the API is running
When I submit a request to a protected endpoint without an access token When I submit a request to a protected endpoint without an access token
Then the response has HTTP status 401 Then the response has HTTP status 401
Scenario: Protected endpoint rejects invalid access token Scenario: Protected endpoint rejects invalid access token
Given the API is running Given the API is running
When I submit a request to a protected endpoint with an invalid access token When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token Scenario: Protected endpoint rejects expired access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in with an immediately-expiring access token And I am logged in with an immediately-expiring access token
@@ -29,21 +29,21 @@ Feature: Protected Endpoint Access Token Validation
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running Given the API is running
And I have an access token signed with the wrong secret And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized" And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects refresh token as access token Scenario: Protected endpoint rejects refresh token as access token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
When I submit a request to a protected endpoint with my refresh token instead of access token When I submit a request to a protected endpoint with my refresh token instead of access token
Then the response has HTTP status 401 Then the response has HTTP status 401
Scenario: Protected endpoint rejects confirmation token as access token Scenario: Protected endpoint rejects confirmation token as access token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token And I have a valid confirmation token

View File

@@ -1,8 +1,9 @@
Feature: User Account Confirmation Feature: User Account Confirmation
As a newly registered user As a newly registered user
I want to confirm my email address via a validation token I want to confirm my email address via a validation token
So that my account is fully activated So that my account is fully activated
Scenario: Successful confirmation with valid token
Scenario: Successful confirmation with valid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
@@ -11,7 +12,7 @@ Feature: User Account Confirmation
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed" And the response JSON should have "message" containing "is confirmed"
Scenario: Re-confirming an already verified account remains successful Scenario: Re-confirming an already verified account remains successful
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
@@ -21,7 +22,7 @@ Feature: User Account Confirmation
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed" And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with invalid token Scenario: Confirmation fails with invalid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
@@ -29,7 +30,7 @@ Feature: User Account Confirmation
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with expired token Scenario: Confirmation fails with expired token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have an expired confirmation token for my account And I have an expired confirmation token for my account
@@ -38,7 +39,7 @@ Feature: User Account Confirmation
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with tampered token (wrong secret) Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a confirmation token signed with the wrong secret And I have a confirmation token signed with the wrong secret
@@ -47,20 +48,20 @@ Feature: User Account Confirmation
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing Scenario: Confirmation fails when token is missing
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
When I submit a confirmation request with a missing token When I submit a confirmation request with a missing token
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Confirmation endpoint only accepts POST requests Scenario: Confirmation endpoint only accepts POST requests
Given the API is running Given the API is running
And I have a valid confirmation token And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404 Then the response has HTTP status 404
Scenario: Confirmation fails with malformed token Scenario: Confirmation fails with malformed token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
@@ -68,7 +69,7 @@ Feature: User Account Confirmation
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token Scenario: Confirmation fails without an access token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account

View File

@@ -3,7 +3,7 @@ As a registered user
I want to log in to my account I want to log in to my account
So that I receive an authentication token to access authenticated routes So that I receive an authentication token to access authenticated routes
Scenario: Successful login with valid credentials Scenario: Successful login with valid credentials
Given the API is running Given the API is running
And I have an existing account And I have an existing account
When I submit a login request with a username and password When I submit a login request with a username and password
@@ -11,29 +11,29 @@ So that I receive an authentication token to access authenticated routes
And the response JSON should have "message" equal "Logged in successfully." And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token And the response JSON should have an access token
Scenario: Login fails with invalid credentials Scenario: Login fails with invalid credentials
Given the API is running Given the API is running
And I do not have an existing account And I do not have an existing account
When I submit a login request with a username and password When I submit a login request with a username and password
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password." And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails when required missing username Scenario: Login fails when required missing username
Given the API is running Given the API is running
When I submit a login request with a missing username When I submit a login request with a missing username
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login fails when required missing password Scenario: Login fails when required missing password
Given the API is running Given the API is running
When I submit a login request with a missing password When I submit a login request with a missing password
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing Scenario: Login fails when both username and password are missing
Given the API is running Given the API is running
When I submit a login request with both username and password missing When I submit a login request with both username and password missing
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Login endpoint only accepts POST requests Scenario: Login endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a login request using a GET request When I submit a login request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -3,7 +3,7 @@ As a client of the API
I want consistent 404 responses I want consistent 404 responses
So that consumers can gracefully handle missing routes So that consumers can gracefully handle missing routes
Scenario: GET request to an invalid route returns 404 Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I send an HTTP request "GET" to "/invalid-route" When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -1,9 +1,9 @@
Feature: User Registration Feature: User Registration
As a new user As a new user
I want to register an account I want to register an account
So that I can log in and access authenticated routes So that I can log in and access authenticated routes
Scenario: Successful registration with valid details Scenario: Successful registration with valid details
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
@@ -12,49 +12,49 @@ Feature: User Registration
And the response JSON should have "message" equal "User registered successfully." And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token And the response JSON should have an access token
Scenario: Registration fails with existing username Scenario: Registration fails with existing username
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! | | test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
Scenario: Registration fails with existing email Scenario: Registration fails with existing email
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! | | newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
Then the response has HTTP status 409 Then the response has HTTP status 409
Scenario: Registration fails with missing required fields Scenario: Registration fails with missing required fields
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! | | | New | User | | | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration fails with invalid email format Scenario: Registration fails with invalid email format
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! | | newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration fails with weak password Scenario: Registration fails with weak password
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running Given the API is running
When I submit a registration request with values: When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! | | younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Registration endpoint only accepts POST requests Scenario: Registration endpoint only accepts POST requests
Given the API is running Given the API is running
When I submit a registration request using a GET request When I submit a registration request using a GET request
Then the response has HTTP status 404 Then the response has HTTP status 404

View File

@@ -1,9 +1,9 @@
Feature: Resend Confirmation Email Feature: Resend Confirmation Email
As a user who did not receive the confirmation email As a user who did not receive the confirmation email
I want to request a resend of the confirmation email I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse So that I can obtain a working confirmation link while preventing abuse
Scenario: Legitimate resend for an unconfirmed user Scenario: Legitimate resend for an unconfirmed user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
@@ -11,7 +11,7 @@ Feature: Resend Confirmation Email
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user Scenario: Resend is a no-op for an already confirmed user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid confirmation token for my account And I have a valid confirmation token for my account
@@ -21,7 +21,7 @@ Feature: Resend Confirmation Email
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user Scenario: Resend is a no-op for a non-existent user
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
@@ -29,7 +29,7 @@ Feature: Resend Confirmation Email
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent" And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication Scenario: Resend requires authentication
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account
When I submit a resend confirmation request without an access token When I submit a resend confirmation request without an access token

View File

@@ -1,9 +1,9 @@
Feature: Token Refresh Feature: Token Refresh
As an authenticated user As an authenticated user
I want to refresh my access token using my refresh token I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again So that I can maintain my session without logging in again
Scenario: Successful token refresh with valid refresh token Scenario: Successful token refresh with valid refresh token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in And I am logged in
@@ -13,13 +13,13 @@ Feature: Token Refresh
And the response JSON should have a new access token And the response JSON should have a new access token
And the response JSON should have a new refresh token And the response JSON should have a new refresh token
Scenario: Token refresh fails with invalid refresh token Scenario: Token refresh fails with invalid refresh token
Given the API is running Given the API is running
When I submit a refresh token request with an invalid refresh token When I submit a refresh token request with an invalid refresh token
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid" And the response JSON should have "message" containing "Invalid"
Scenario: Token refresh fails with expired refresh token Scenario: Token refresh fails with expired refresh token
Given the API is running Given the API is running
And I have an existing account And I have an existing account
And I am logged in with an immediately-expiring refresh token And I am logged in with an immediately-expiring refresh token
@@ -27,12 +27,12 @@ Feature: Token Refresh
Then the response has HTTP status 401 Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token" And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails when refresh token is missing Scenario: Token refresh fails when refresh token is missing
Given the API is running Given the API is running
When I submit a refresh token request with a missing refresh token When I submit a refresh token request with a missing refresh token
Then the response has HTTP status 400 Then the response has HTTP status 400
Scenario: Token refresh endpoint only accepts POST requests Scenario: Token refresh endpoint only accepts POST requests
Given the API is running Given the API is running
And I have a valid refresh token And I have a valid refresh token
When I submit a refresh token request using a GET request When I submit a refresh token request using a GET request

View File

@@ -8,11 +8,7 @@ public class MockEmailDispatcher : IEmailDispatcher
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new(); public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync( public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
string firstName,
string email,
string confirmationToken
)
{ {
SentRegistrationEmails.Add( SentRegistrationEmails.Add(
new RegistrationEmail new RegistrationEmail

View File

@@ -10,12 +10,7 @@ public class MockEmailProvider : IEmailProvider
{ {
public List<SentEmail> SentEmails { get; } = new(); public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync( public Task SendAsync(string to, string subject, string body, bool isHtml = true)
string to,
string subject,
string body,
bool isHtml = true
)
{ {
SentEmails.Add( SentEmails.Add(
new SentEmail new SentEmail
@@ -31,12 +26,7 @@ public class MockEmailProvider : IEmailProvider
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SendAsync( public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
{ {
SentEmails.Add( SentEmails.Add(
new SentEmail new SentEmail

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using API.Specs;
using FluentAssertions; using FluentAssertions;
using Reqnroll; using Reqnroll;
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient() private HttpClient GetClient()
{ {
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client)) if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
{
return client; return client;
}
var factory = scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,
out var f out TestApiFactory? f
) )
? f ? f
: new TestApiFactory(); : new TestApiFactory();
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody 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( Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
jsonBody,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When("I send an HTTP request {string} to {string}")] [When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString( public async Task WhenISendAnHttpRequestStringToString(string method, string url)
string method,
string url
)
{ {
var client = GetClient(); HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage( HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
new HttpMethod(method), HttpResponseMessage response = await client.SendAsync(requestMessage);
url string responseBody = await response.Content.ReadAsStringAsync();
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected) public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
((int)response!.StatusCode).Should().Be(expected); ((int)response!.StatusCode).Should().Be(expected);
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode) public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue("No response was received from the API"); .BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode); ((int)response!.StatusCode).Should().Be(expectedCode);
} }
[Then("the response JSON should have {string} equal {string}")] [Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString( public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
string field,
string expected
)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; 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() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
value.GetString().Should().Be(expected); value.GetString().Should().Be(expected);
} }
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
) )
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; 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() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String, string? actualValue = value.GetString();
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
actualValue actualValue
.Should() .Should()
.Contain( .Contain(

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,37 +1,37 @@
<Solution> <Solution>
<Folder Name="/API/"> <Folder Name="/API/">
<Project Path="API/API.Core/API.Core.csproj" /> <Project Path="API/API.Core/API.Core.csproj"/>
<Project Path="API/API.Specs/API.Specs.csproj" /> <Project Path="API/API.Specs/API.Specs.csproj"/>
</Folder> </Folder>
<Folder Name="/Database/"> <Folder Name="/Database/">
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" /> <Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
<Project Path="Database/Database.Seed/Database.Seed.csproj" /> <Project Path="Database/Database.Seed/Database.Seed.csproj"/>
</Folder> </Folder>
<Folder Name="/Domain/"> <Folder Name="/Domain/">
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" /> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" /> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
</Folder> </Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" /> <Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
<Project <Project
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" /> Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
<Project <Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" /> Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" /> <Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
</Folder> </Folder>
<Folder Name="/Features/"> <Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" /> <Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" /> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" /> <Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" /> <Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
<Project Path="Features/Features.Auth/Features.Auth.csproj" /> <Project Path="Features/Features.Auth/Features.Auth.csproj"/>
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" /> <Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
<Project Path="Features/Features.Emails/Features.Emails.csproj" /> <Project Path="Features/Features.Emails/Features.Emails.csproj"/>
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
<Project Path="Shared/Shared.Application/Shared.Application.csproj" /> <Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -1,6 +1,7 @@
using System.Data; using System.Data;
using System.Reflection; using System.Reflection;
using DbUp; using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace Database.Migrations; namespace Database.Migrations;
@@ -12,6 +13,12 @@ namespace Database.Migrations;
/// </summary> /// </summary>
public static class Program 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> /// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>, /// 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> /// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
@@ -24,45 +31,43 @@ public static class Program
/// <returns>A fully built SQL Server connection string.</returns> /// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when /// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName"/> is <c>null</c>) <c>DB_NAME</c> is not set. /// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception> /// </exception>
private static string BuildConnectionString(string? databaseName = null) 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"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName string dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME") ?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? 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"); ?? 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"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; 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> /// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target /// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console. /// database using DbUp, logging progress to the console.
@@ -70,13 +75,13 @@ public static class Program
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns> /// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations() private static bool DeployMigrations()
{ {
var upgrader = DeployChanges UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString) .To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole() .LogToConsole()
.Build(); .Build();
var result = upgrader.PerformUpgrade(); DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful; return result.Successful;
} }
@@ -90,41 +95,42 @@ public static class Program
/// </returns> /// </returns>
private static bool ClearDatabase() private static bool ClearDatabase()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
try try
{ {
myConn.Open(); myConn.Open();
// First, set the database to single user mode to close all connections // 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') " + "IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", + "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn); myConn
);
try try
{ {
setModeCommand.ExecuteNonQuery(); setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode."); 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}"); Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
} }
// Then drop the database // Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn); SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try try
{ {
dropCommand.ExecuteNonQuery(); dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully."); Console.WriteLine("Database cleared successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error dropping database: {ex}"); Console.WriteLine($"Error dropping database: {ex}");
return false; return false;
} }
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error clearing database: {ex}"); Console.WriteLine($"Error clearing database: {ex}");
return false; return false;
@@ -132,10 +138,9 @@ public static class Program
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open)
{
myConn.Close(); myConn.Close();
} }
}
return true; return true;
} }
@@ -147,31 +152,30 @@ public static class Program
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns> /// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists() private static bool CreateDatabaseIfNotExists()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
const string str = """ const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten] CREATE DATABASE [Biergarten]
"""; """;
var myCommand = new SqlCommand(str, myConn); SqlCommand myCommand = new(str, myConn);
try try
{ {
myConn.Open(); myConn.Open();
myCommand.ExecuteNonQuery(); myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully."); Console.WriteLine("Database creation command executed successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error creating database: {ex}"); Console.WriteLine($"Error creating database: {ex}");
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open)
{
myConn.Close(); myConn.Close();
} }
}
return true; return true;
} }
@@ -188,7 +192,7 @@ public static class Program
try try
{ {
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE"); string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true") if (clearDatabase == "true")
{ {
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database..."); Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -196,19 +200,17 @@ public static class Program
} }
CreateDatabaseIfNotExists(); CreateDatabaseIfNotExists();
var success = DeployMigrations(); bool success = DeployMigrations();
if (success) if (success)
{ {
Console.WriteLine("Database migrations completed successfully."); Console.WriteLine("Database migrations completed successfully.");
return 0; return 0;
} }
else
{
Console.WriteLine("Database migrations failed."); Console.WriteLine("Database migrations failed.");
return 1; return 1;
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine("An error occurred during database migrations:"); Console.WriteLine("An error occurred during database migrations:");

View File

@@ -74,11 +74,12 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
CONSTRAINT FK_Photo_UploadedBy CONSTRAINT FK_Photo_UploadedBy
FOREIGN KEY (UploadedByID) FOREIGN KEY (UploadedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID); ON Photo(UploadedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -99,18 +100,19 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
CONSTRAINT FK_UserAvatar_UserAccount CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID), REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -133,14 +135,15 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
CONSTRAINT FK_UserVerification_UserAccount CONSTRAINT FK_UserVerification_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT AK_UserVerification_UserAccountID CONSTRAINT AK_UserVerification_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID); ON UserVerification(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -174,14 +177,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
CONSTRAINT FK_UserCredential_UserAccount CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry) ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash); INCLUDE (Hash);
@@ -207,22 +212,25 @@ CREATE TABLE UserFollow
CONSTRAINT FK_UserFollow_UserAccount CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID) FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount 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); ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
CONSTRAINT FK_StateProvince_Country CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID) FOREIGN KEY (CountryID)
REFERENCES Country(CountryID) REFERENCES Country (CountryID)
); );
CREATE NONCLUSTERED INDEX IX_StateProvince_Country CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID); ON StateProvince(CountryID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -296,10 +305,11 @@ CREATE TABLE City
CONSTRAINT FK_City_StateProvince CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID) FOREIGN KEY (StateProvinceID)
REFERENCES StateProvince(StateProvinceID) REFERENCES StateProvince (StateProvinceID)
); );
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -328,11 +338,12 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
CONSTRAINT FK_BreweryPost_UserAccount CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID); ON BreweryPost(PostedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID) FOREIGN KEY (CityID)
REFERENCES City(CityID) REFERENCES City (CityID)
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID); ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
-- To assess when the time comes: -- To assess when the time comes:
@@ -413,19 +426,21 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
CONSTRAINT FK_BreweryPostPhoto_BreweryPost CONSTRAINT FK_BreweryPostPhoto_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostPhoto_Photo CONSTRAINT FK_BreweryPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID); ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID); ON BreweryPostPhoto(BreweryPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -461,7 +476,7 @@ CREATE TABLE BeerPost
Description NVARCHAR(MAX) NOT NULL, Description NVARCHAR(MAX) NOT NULL,
ABV DECIMAL(4,2) NOT NULL, ABV DECIMAL(4, 2) NOT NULL,
-- Alcohol By Volume (typically 0-67%) -- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL, IBU INT NOT NULL,
@@ -485,16 +500,16 @@ CREATE TABLE BeerPost
CONSTRAINT FK_BeerPost_PostedBy CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID) FOREIGN KEY (BeerStyleID)
REFERENCES BeerStyle(BeerStyleID), REFERENCES BeerStyle (BeerStyleID),
CONSTRAINT FK_BeerPost_Brewery CONSTRAINT FK_BeerPost_Brewery
FOREIGN KEY (BrewedByID) FOREIGN KEY (BrewedByID)
REFERENCES BreweryPost(BreweryPostID), REFERENCES BreweryPost (BreweryPostID),
CONSTRAINT CHK_BeerPost_ABV CONSTRAINT CHK_BeerPost_ABV
CHECK (ABV >= 0 AND ABV <= 67), CHECK (ABV >= 0 AND ABV <= 67),
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120) CHECK (IBU >= 0 AND IBU <= 120)
); );
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID); ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID); ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID); ON BeerPost(BrewedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -534,19 +552,21 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
CONSTRAINT FK_BeerPostPhoto_BeerPost CONSTRAINT FK_BeerPostPhoto_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID) REFERENCES BeerPost (BeerPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BeerPostPhoto_Photo CONSTRAINT FK_BeerPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID); ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID); ON BeerPostPhoto(BeerPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -577,19 +597,21 @@ CREATE TABLE BeerPostComment
CONSTRAINT FK_BeerPostComment_BeerPost CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID), REFERENCES BeerPost (BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID) FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5) CHECK (Rating BETWEEN 1 AND 5)
); );
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID); ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID); ON BeerPostComment(CommentedByID);

View File

@@ -1,15 +1,18 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode CREATE
( OR
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
@CountryCode NVARCHAR(2) @CountryCode NVARCHAR(2)
) )
RETURNS UNIQUEIDENTIFIER RETURNS UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER; DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID SELECT @CountryId = CountryID
FROM dbo.Country FROM dbo.Country
WHERE ISO3166_1 = @CountryCode; WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId; RETURN @CountryId;
END; END;

View File

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

View File

@@ -1,36 +1,34 @@
CREATE
CREATE OR ALTER PROCEDURE usp_CreateUserAccount OR
( ALTER PROCEDURE usp_CreateUserAccount
(
@UserAccountId UNIQUEIDENTIFIER OUTPUT, @UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64), @Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128) @Email VARCHAR (128)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER); DECLARE
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount INSERT INTO UserAccount
( (Username,
Username,
FirstName, FirstName,
LastName, LastName,
DateOfBirth, DateOfBirth,
Email Email)
)
OUTPUT INSERTED.UserAccountID INTO @Inserted OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES VALUES
( (
@Username, @Username, @FirstName, @LastName, @DateOfBirth, @Email
@FirstName,
@LastName,
@DateOfBirth,
@Email
); );
SELECT @UserAccountId = UserAccountID FROM @Inserted; SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END; END;

View File

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

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts CREATE
AS OR
ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
@@ -12,5 +15,5 @@ BEGIN
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount; FROM dbo.UserAccount;
END; END;

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail( CREATE
@Email VARCHAR(128) OR
) ALTER PROCEDURE usp_GetUserAccountByEmail(
AS @Email VARCHAR (128)
)
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
@@ -14,6 +17,6 @@ BEGIN
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE Email = @Email; WHERE Email = @Email;
END; END;

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById( CREATE
OR
ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
@@ -14,6 +17,6 @@ BEGIN
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId; WHERE UserAccountID = @UserAccountId;
END END

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername( CREATE
@Username VARCHAR(64) OR
) ALTER PROCEDURE usp_GetUserAccountByUsername(
AS @Username VARCHAR (64)
)
AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT UserAccountID, SELECT UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
@@ -14,6 +17,6 @@ BEGIN
UpdatedAt, UpdatedAt,
DateOfBirth, DateOfBirth,
Timer Timer
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE Username = @Username; WHERE Username = @Username;
END; END;

View File

@@ -1,27 +1,30 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( CREATE
@Username VARCHAR(64), OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128), @Email VARCHAR (128),
@UserAccountId UNIQUEIDENTIFIER @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET SET
NOCOUNT ON; NOCOUNT ON;
UPDATE UserAccount UPDATE UserAccount
SET Username = @Username, SET Username = @Username,
FirstName = @FirstName, FirstName = @FirstName,
LastName = @LastName, LastName = @LastName,
DateOfBirth = @DateOfBirth, DateOfBirth = @DateOfBirth,
Email = @Email Email = @Email
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0 IF
BEGIN @@ROWCOUNT = 0
BEGIN
THROW THROW
50001, 'UserAccount with the specified ID does not exist.', 1; 50001, 'UserAccount with the specified ID does not exist.', 1;
END END
END; END;

View File

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

View File

@@ -1,24 +1,30 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER @UserAccountId_ UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1; THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked -- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential UPDATE dbo.UserCredential
SET IsRevoked = 1, SET IsRevoked = 1,
RevokedAt = GETDATE() RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_ WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1; AND IsRevoked != 1;
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;

View File

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

View File

@@ -1,28 +1,33 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER, @UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX) @Hash NVARCHAR(MAX)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
BEGIN TRANSACTION; SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0 IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1; THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked -- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential UPDATE dbo.UserCredential
SET IsRevoked = 1, SET IsRevoked = 1,
RevokedAt = GETDATE() RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_; WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential INSERT INTO dbo.UserCredential
(UserAccountId, Hash) (UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash); VALUES (@UserAccountId_, @Hash);
END; END;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256), @BreweryName NVARCHAR(256),
@Description NVARCHAR(512), @Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER, @PostedByID UNIQUEIDENTIFIER,
@@ -7,44 +9,53 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@AddressLine2 NVARCHAR(256) = NULL, @AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20), @PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL @Coordinates GEOGRAPHY = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1; THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1; THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID) WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1; THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityID = @CityID) WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1; THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID(); DECLARE
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID(); @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION; BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID) (BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID); VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates); VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION; COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID, SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID; @NewBrewerLocationID AS BreweryPostLocationID;
END END

View File

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

View File

@@ -1,12 +1,15 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries( CREATE
OR
ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL, @Limit INT = NULL,
@Offset INT = NULL @Offset INT = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT bp.BreweryPostID, SELECT bp.BreweryPostID,
bp.PostedByID, bp.PostedByID,
bp.BreweryName, bp.BreweryName,
bp.Description, bp.Description,
@@ -19,10 +22,9 @@ BEGIN
bpl.AddressLine2, bpl.AddressLine2,
bpl.PostalCode, bpl.PostalCode,
bpl.Coordinates bpl.Coordinates
FROM dbo.BreweryPost bp FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END END

View File

@@ -1,9 +1,11 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER CREATE
AS OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN BEGIN
SELECT * SELECT *
FROM BreweryPost bp FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID; WHERE bp.BreweryPostID = @BreweryPostID;
END END

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery( CREATE
OR
ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER, @BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256), @BreweryName NVARCHAR(256),
@Description NVARCHAR(512), @Description NVARCHAR(512),
@@ -8,61 +10,70 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@AddressLine2 NVARCHAR(256) = NULL, @AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL, @PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL @Coordinates GEOGRAPHY = NULL
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1; THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1; THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID) WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1; 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 FROM dbo.City
WHERE CityID = @CityID) WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1; THROW 50404, 'City not found.', 1;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName, SET BreweryName = @BreweryName,
Description = @Description, Description = @Description,
UpdatedAt = GETDATE() UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
IF @CityID IS NULL IF
BEGIN @CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery. -- No location supplied: clear any existing location for this brewery.
DELETE FROM dbo.BreweryPostLocation DELETE
WHERE BreweryPostID = @BreweryPostID; FROM dbo.BreweryPostLocation
END WHERE BreweryPostID = @BreweryPostID;
ELSE IF EXISTS (SELECT 1 END
ELSE IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID) WHERE BreweryPostID = @BreweryPostID)
BEGIN BEGIN
UPDATE dbo.BreweryPostLocation UPDATE dbo.BreweryPostLocation
SET CityID = @CityID, SET CityID = @CityID,
AddressLine1 = @AddressLine1, AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2, AddressLine2 = @AddressLine2,
PostalCode = @PostalCode, PostalCode = @PostalCode,
Coordinates = @Coordinates Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
END END
ELSE ELSE
BEGIN BEGIN
INSERT INTO dbo.BreweryPostLocation INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates) (BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2, VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates); @PostalCode, @Coordinates);
END END
COMMIT TRANSACTION; COMMIT TRANSACTION;
END END

View File

@@ -9,10 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" /> <PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="dbup" Version="5.0.41" /> <PackageReference Include="dbup" Version="5.0.41" />
</ItemGroup> </ItemGroup>

View File

@@ -15,10 +15,7 @@ namespace Database.Seed;
internal class LocationSeeder : ISeeder internal class LocationSeeder : ISeeder
{ {
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary> /// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
private static readonly IReadOnlyList<( private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
string CountryName,
string CountryCode
)> Countries =
[ [
("Canada", "CA"), ("Canada", "CA"),
("Mexico", "MX"), ("Mexico", "MX"),
@@ -29,10 +26,11 @@ internal class LocationSeeder : ISeeder
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code, /// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
/// and the ISO 3166-1 code of its parent country. /// and the ISO 3166-1 code of its parent country.
/// </summary> /// </summary>
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States private static IReadOnlyList<(
{ string StateProvinceName,
get; string StateProvinceCode,
} = string CountryCode
)> States { get; } =
[ [
("Alabama", "US-AL", "US"), ("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"), ("Alaska", "US-AK", "US"),
@@ -266,28 +264,20 @@ internal class LocationSeeder : ISeeder
/// <returns>A task that completes when all locations have been seeded.</returns> /// <returns>A task that completes when all locations have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection) public async Task SeedAsync(SqlConnection connection)
{ {
foreach (var (countryName, countryCode) in Countries) foreach ((string countryName, string countryCode) in Countries)
{
await CreateCountryAsync(connection, countryName, countryCode); await CreateCountryAsync(connection, countryName, countryCode);
}
foreach ( foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
var (stateProvinceName, stateProvinceCode, countryCode) in States
)
{
await CreateStateProvinceAsync( await CreateStateProvinceAsync(
connection, connection,
stateProvinceName, stateProvinceName,
stateProvinceCode, stateProvinceCode,
countryCode countryCode
); );
}
foreach (var (stateProvinceCode, cityName) in Cities) foreach ((string stateProvinceCode, string cityName) in Cities)
{
await CreateCityAsync(connection, cityName, stateProvinceCode); await CreateCityAsync(connection, cityName, stateProvinceCode);
} }
}
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary> /// <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> /// <param name="connection">An open connection to the target database.</param>
@@ -300,10 +290,7 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
"dbo.USP_CreateCountry",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName); command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3166_1", countryCode); command.Parameters.AddWithValue("@ISO3166_1", countryCode);
@@ -324,15 +311,9 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
"dbo.USP_CreateStateProvince",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode); command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode); command.Parameters.AddWithValue("@CountryCode", countryCode);
@@ -350,16 +331,10 @@ internal class LocationSeeder : ISeeder
string stateProvinceCode string stateProvinceCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateCity", connection);
"dbo.USP_CreateCity",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName); command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
"@StateProvinceCode",
stateProvinceCode
);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }

View File

@@ -1,7 +1,5 @@
using Microsoft.Data.SqlClient; using Database.Seed;
using DbUp; using Microsoft.Data.SqlClient;
using System.Reflection;
using Database.Seed;
// Entry point for the database seeding utility. Connects to the target database // Entry point for the database seeding utility. Connects to the target database
// (retrying on transient failures), then runs each registered ISeeder in order to // (retrying on transient failures), then runs each registered ISeeder in order to
@@ -19,38 +17,41 @@ using Database.Seed;
/// </exception> /// </exception>
string BuildConnectionString() string BuildConnectionString()
{ {
var server = Environment.GetEnvironmentVariable("DB_SERVER") string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); ?? 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"); ?? 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"); ?? 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"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;
} }
try try
{ {
var connectionString = BuildConnectionString(); string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database..."); Console.WriteLine("Attempting to connect to database...");
@@ -60,7 +61,6 @@ try
int retryDelayMs = 2000; int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++) for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try try
{ {
connection = new SqlConnection(connectionString); connection = new SqlConnection(connectionString);
@@ -76,24 +76,17 @@ try
connection?.Dispose(); connection?.Dispose();
connection = null; connection = null;
} }
}
if (connection == null) if (connection == null)
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts."); throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
Console.WriteLine("Starting seeding..."); Console.WriteLine("Starting seeding...");
using (connection) using (connection)
{ {
ISeeder[] seeders = ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
[
new LocationSeeder(),
new UserSeeder(),
];
foreach (var seeder in seeders) foreach (ISeeder seeder in seeders)
{ {
Console.WriteLine($"Seeding {seeder.GetType().Name}..."); Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection); await seeder.SeedAsync(connection);

View File

@@ -11,7 +11,7 @@ namespace Database.Seed;
/// Seeds user accounts, credentials, and verification records. Creates one fixed /// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>) /// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
/// for testing, followed by a randomly-generated account for each name in /// for testing, followed by a randomly-generated account for each name in
/// <see cref="SeedNames"/>, each with a random password and date of birth. Skips /// <see cref="SeedNames" />, each with a random password and date of birth. Skips
/// adding a verification record for a user that already has one, so this seeder is /// adding a verification record for a user that already has one, so this seeder is
/// safe to re-run, though re-running will still attempt to register (and may fail on) /// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist. /// users that already exist.
@@ -19,10 +19,7 @@ namespace Database.Seed;
internal class UserSeeder : ISeeder internal class UserSeeder : ISeeder
{ {
/// <summary>The first/last name pairs used to generate seed user accounts.</summary> /// <summary>The first/last name pairs used to generate seed user accounts.</summary>
private static readonly IReadOnlyList<( private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
string FirstName,
string LastName
)> SeedNames =
[ [
("Aarya", "Mathews"), ("Aarya", "Mathews"),
("Aiden", "Wells"), ("Aiden", "Wells"),
@@ -128,7 +125,7 @@ internal class UserSeeder : ISeeder
/// <summary> /// <summary>
/// Registers a fixed test user account followed by one randomly-generated account /// Registers a fixed test user account followed by one randomly-generated account
/// per entry in <see cref="SeedNames"/>, adding a user verification record for each /// per entry in <see cref="SeedNames" />, adding a user verification record for each
/// newly created account that does not already have one. Progress counts are /// newly created account that does not already have one. Progress counts are
/// written to the console on completion. /// written to the console on completion.
/// </summary> /// </summary>
@@ -136,8 +133,8 @@ internal class UserSeeder : ISeeder
/// <returns>A task that completes when all users have been seeded.</returns> /// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection) public async Task SeedAsync(SqlConnection connection)
{ {
var generator = new PasswordGenerator(); PasswordGenerator generator = new();
var rng = new Random(); Random rng = new();
int createdUsers = 0; int createdUsers = 0;
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 0; int createdVerifications = 0;
@@ -147,8 +144,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test"; const string firstName = "Test";
const string lastName = "User"; const string lastName = "User";
const string email = "test.user@thebiergarten.app"; const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01); DateTime dob = new(1985, 03, 01);
var hash = GeneratePasswordHash("password"); string hash = GeneratePasswordHash("password");
await RegisterUserAsync( await RegisterUserAsync(
connection, connection,
@@ -160,24 +157,19 @@ internal class UserSeeder : ISeeder
hash hash
); );
} }
foreach (var (firstName, lastName) in SeedNames) foreach ((string firstName, string lastName) in SeedNames)
{ {
// prepare user fields // prepare user fields
var username = $"{firstName[0]}.{lastName}"; string username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app"; string email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng); DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it // generate a password and hash it
string pwd = generator.Generate( string pwd = generator.Generate(64, 10, 10);
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
var id = await RegisterUserAsync( Guid id = await RegisterUserAsync(
connection, connection,
username, username,
firstName, firstName,
@@ -189,9 +181,9 @@ internal class UserSeeder : ISeeder
createdUsers++; createdUsers++;
createdCredentials++; createdCredentials++;
// add user verification // add user verification
if (await HasUserVerificationAsync(connection, id)) continue; if (await HasUserVerificationAsync(connection, id))
continue;
await AddUserVerificationAsync(connection, id); await AddUserVerificationAsync(connection, id);
createdVerifications++; createdVerifications++;
@@ -212,8 +204,8 @@ internal class UserSeeder : ISeeder
/// <param name="lastName">The user's last name.</param> /// <param name="lastName">The user's last name.</param>
/// <param name="dateOfBirth">The user's date of birth.</param> /// <param name="dateOfBirth">The user's date of birth.</param>
/// <param name="email">The user's email address.</param> /// <param name="email">The user's email address.</param>
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash"/>.</param> /// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
/// <returns>The newly created user account's <see cref="Guid"/> identifier.</returns> /// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
private static async Task<Guid> RegisterUserAsync( private static async Task<Guid> RegisterUserAsync(
SqlConnection connection, SqlConnection connection,
string username, string username,
@@ -224,10 +216,9 @@ internal class UserSeeder : ISeeder
string hash 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; command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username; command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName; command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName; command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
@@ -235,8 +226,7 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email; command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash; command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return (Guid)result!; return (Guid)result!;
} }
@@ -252,7 +242,7 @@ internal class UserSeeder : ISeeder
{ {
byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd)) Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
@@ -278,9 +268,9 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
"""; """;
await using var command = new SqlCommand(sql, connection); await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId); command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return result is not null; return result is not null;
} }
@@ -288,15 +278,9 @@ internal class UserSeeder : ISeeder
/// <param name="connection">An open connection to the target database.</param> /// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The identifier of the user account to verify.</param> /// <param name="userAccountId">The identifier of the user account to verify.</param>
/// <returns>A task that completes when the verification record has been created.</returns> /// <returns>A task that completes when the verification record has been created.</returns>
private static async Task AddUserVerificationAsync( private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
SqlConnection connection,
Guid userAccountId
)
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
"dbo.USP_CreateUserVerification",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId); command.Parameters.AddWithValue("@UserAccountID_", userAccountId);

View File

@@ -12,7 +12,7 @@ public class BreweryPost
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the <see cref="UserAccount"/> that authored this post. Maps to <c>PostedByID</c>. /// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
/// </summary> /// </summary>
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
@@ -37,12 +37,14 @@ public class BreweryPost
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
/// <summary> /// <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> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
/// <summary> /// <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> /// </summary>
public BreweryPostLocation? Location { get; set; } public BreweryPostLocation? Location { get; set; }
} }

View File

@@ -1,8 +1,9 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents the physical location of a <see cref="BreweryPost"/>. Maps to the <c>BreweryPostLocation</c> table. /// 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> /// </summary>
public class BreweryPostLocation public class BreweryPostLocation
{ {
@@ -12,7 +13,8 @@ public class BreweryPostLocation
public Guid BreweryPostLocationId { get; set; } public Guid BreweryPostLocationId { get; set; }
/// <summary> /// <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> /// </summary>
public Guid BreweryPostId { get; set; } public Guid BreweryPostId { get; set; }
@@ -37,12 +39,14 @@ public class BreweryPostLocation
public Guid CityId { get; set; } public Guid CityId { get; set; }
/// <summary> /// <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> /// </summary>
public byte[]? Coordinates { get; set; } public byte[]? Coordinates { get; set; }
/// <summary> /// <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> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -2,7 +2,8 @@ namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity /// 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> /// </summary>
public class UserAccount public class UserAccount
{ {
@@ -47,7 +48,8 @@ public class UserAccount
public DateTime DateOfBirth { get; set; } public DateTime DateOfBirth { get; set; }
/// <summary> /// <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> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,8 +1,9 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount"/>. /// 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> /// </summary>
public class UserCredential public class UserCredential
{ {
@@ -12,7 +13,7 @@ public class UserCredential
public Guid UserCredentialId { get; set; } public Guid UserCredentialId { get; set; }
/// <summary> /// <summary>
/// Foreign key referencing the owning <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>. /// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
/// </summary> /// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
@@ -32,7 +33,8 @@ public class UserCredential
public string Hash { get; set; } = string.Empty; public string Hash { get; set; } = string.Empty;
/// <summary> /// <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> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,7 +1,7 @@
namespace Domain.Entities; namespace Domain.Entities;
/// <summary> /// <summary>
/// Records that a <see cref="UserAccount"/> has completed verification (e.g., email confirmation). /// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record, /// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
/// and it is deleted automatically when the owning user account is deleted. /// and it is deleted automatically when the owning user account is deleted.
/// </summary> /// </summary>
@@ -13,7 +13,8 @@ public class UserVerification
public Guid UserVerificationId { get; set; } public Guid UserVerificationId { get; set; }
/// <summary> /// <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> /// </summary>
public Guid UserAccountId { get; set; } public Guid UserAccountId { get; set; }
@@ -23,7 +24,8 @@ public class UserVerification
public DateTime VerificationDateTime { get; set; } public DateTime VerificationDateTime { get; set; }
/// <summary> /// <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> /// </summary>
public byte[]? Timer { get; set; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

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

View File

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

View File

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

View File

@@ -11,20 +11,29 @@ namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests public class ResendConfirmationEmailHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepositoryMock = new(); private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<IMediator> _mediatorMock = new();
private readonly ResendConfirmationEmailHandler _handler; private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
public ResendConfirmationEmailHandlerTests() public ResendConfirmationEmailHandlerTests()
{ {
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object); _handler = new ResendConfirmationEmailHandler(
_authRepositoryMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
} }
[Fact] [Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified() public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" }; UserAccount user = new()
{
UserAccountId = userId,
FirstName = "Aaron",
Email = "aaron@example.com",
};
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false); _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
@@ -33,9 +42,15 @@ public class ResendConfirmationEmailHandlerTests
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.Is<SendResendConfirmationEmailCommand>(c => x =>
c.FirstName == "Aaron" && c.Email == "aaron@example.com" && c.ConfirmationToken == "fresh-token" x.Send(
), It.IsAny<CancellationToken>()), It.Is<SendResendConfirmationEmailCommand>(c =>
c.FirstName == "Aaron"
&& c.Email == "aaron@example.com"
&& c.ConfirmationToken == "fresh-token"
),
It.IsAny<CancellationToken>()
),
Times.Once Times.Once
); );
} }
@@ -43,13 +58,17 @@ public class ResendConfirmationEmailHandlerTests
[Fact] [Fact]
public async Task Handle_DoesNothing_WhenUserDoesNotExist() public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()), x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never Times.Never
); );
} }
@@ -57,8 +76,8 @@ public class ResendConfirmationEmailHandlerTests
[Fact] [Fact]
public async Task Handle_DoesNothing_WhenUserAlreadyVerified() public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var user = new UserAccount { UserAccountId = userId }; UserAccount user = new() { UserAccountId = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user); _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true); _authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
@@ -66,7 +85,11 @@ public class ResendConfirmationEmailHandlerTests
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None); await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify( _mediatorMock.Verify(
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()), x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never Times.Never
); );
} }

View File

@@ -1,9 +1,10 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions; using Features.Auth.Dtos;
using Features.Auth.Queries.Login; using Features.Auth.Queries.Login;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Moq; using Moq;
@@ -12,25 +13,29 @@ namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests public class LoginHandlerTests
{ {
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly LoginHandler _handler;
public LoginHandlerTests() public LoginHandlerTests()
{ {
_authRepoMock = new Mock<IAuthRepository>(); _authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>(); _passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler(_authRepoMock.Object, _passwordInfraMock.Object, _tokenServiceMock.Object); _handler = new LoginHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
} }
[Fact] [Fact]
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername() public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{ {
const string username = "CogitoErgoSum"; const string username = "CogitoErgoSum";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount UserAccount userAccount = new()
{ {
UserAccountId = userAccountId, UserAccountId = userAccountId,
Username = username, Username = username,
@@ -40,7 +45,7 @@ public class LoginHandlerTests
DateOfBirth = new DateTime(1596, 03, 31), DateOfBirth = new DateTime(1596, 03, 31),
}; };
var userCredential = new UserCredential UserCredential userCredential = new()
{ {
UserCredentialId = Guid.NewGuid(), UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId, UserAccountId = userAccountId,
@@ -49,12 +54,23 @@ public class LoginHandlerTests
}; };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); _authRepoMock
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(true); .Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token"); .ReturnsAsync(userCredential);
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token"); _passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_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.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId); result.UserAccountId.Should().Be(userAccountId);
@@ -67,44 +83,73 @@ public class LoginHandlerTests
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException() public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{ {
const string username = "de_beauvoir"; const string username = "de_beauvoir";
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync((UserAccount?)null); _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>(); await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never); _authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
Times.Never
);
} }
[Fact] [Fact]
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException() public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{ {
const string username = "BRussell"; const string username = "BRussell";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _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."); await act.Should()
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never); .ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
} }
[Fact] [Fact]
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException() public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{ {
const string username = "RCarnap"; const string username = "RCarnap";
var userAccountId = Guid.NewGuid(); Guid userAccountId = Guid.NewGuid();
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username }; UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" }; UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "hashed-password",
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount); _authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential); _authRepoMock
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false); .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."); await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
} }
} }

View File

@@ -1,22 +1,24 @@
using Apps72.Dev.Data.DbMocker; using Apps72.Dev.Data.DbMocker;
using FluentAssertions; using Domain.Entities;
using Features.Auth.Repository; using Features.Auth.Repository;
using FluentAssertions;
namespace Features.Auth.Tests.Repository; namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTests public class AuthRepositoryTests
{ {
private static AuthRepository CreateRepo(MockDbConnection conn) => private static AuthRepository CreateRepo(MockDbConnection conn)
new(new TestConnectionFactory(conn)); {
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact] [Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{ {
var expectedUserId = Guid.NewGuid(); Guid expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser") conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
.ReturnsScalar(expectedUserId);
// Mock the subsequent read for the newly created user by id // Mock the subsequent read for the newly created user by id
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
@@ -46,14 +48,14 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync( UserAccount result = await repo.RegisterUserAsync(
username: "testuser", "testuser",
firstName: "Test", "Test",
lastName: "User", "User",
email: "test@example.com", "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1), new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123" "hashedpassword123"
); );
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -68,8 +70,8 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists() public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable( .ReturnsTable(
@@ -98,8 +100,8 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com"); UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
@@ -112,13 +114,13 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists() public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com"); UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -126,12 +128,10 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists() public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable( .ReturnsTable(
MockTable MockTable
.WithColumns( .WithColumns(
@@ -158,8 +158,8 @@ public class AuthRepositoryTests
) )
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser"); UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId); result!.UserAccountId.Should().Be(userId);
@@ -170,15 +170,13 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists() public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{ {
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent"); UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -186,13 +184,11 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var credentialId = Guid.NewGuid(); Guid credentialId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable( .ReturnsTable(
MockTable MockTable
.WithColumns( .WithColumns(
@@ -202,17 +198,11 @@ public class AuthRepositoryTests
("CreatedAt", typeof(DateTime)), ("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[])) ("Timer", typeof(byte[]))
) )
.AddRow( .AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
)
); );
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId); result!.UserCredentialId.Should().Be(credentialId);
@@ -223,16 +213,14 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists() public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull(); result.Should().BeNull();
} }
@@ -240,18 +228,16 @@ public class AuthRepositoryTests
[Fact] [Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully() public async Task RotateCredentialAsync_ExecutesSuccessfully()
{ {
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password"; string newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection(); MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential") conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
.ReturnsScalar(1);
var repo = CreateRepo(conn); AuthRepository repo = CreateRepo(conn);
// Should not throw // Should not throw
var act = async () => Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }
} }

View File

@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{ {
private readonly DbConnection _conn = conn; 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 System.Security.Claims;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Moq; using Moq;
@@ -12,8 +12,8 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests public class TokenServiceRefreshTests
{ {
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService; private readonly TokenService _tokenService;
public TokenServiceRefreshTests() public TokenServiceRefreshTests()
@@ -22,35 +22,41 @@ public class TokenServiceRefreshTests
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens // Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable(
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); "ACCESS_TOKEN_SECRET",
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); "test-access-secret-that-is-very-long-1234567890"
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
); );
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"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
} }
[Fact] [Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens() public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string refreshToken = "valid-refresh-token"; const string refreshToken = "valid-refresh-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
var userAccount = new UserAccount UserAccount userAccount = new()
{ {
UserAccountId = userId, UserAccountId = userId,
Username = username, Username = username,
@@ -68,14 +74,14 @@ public class TokenServiceRefreshTests
// Mock the generation of new tokens // Mock the generation of new tokens
_tokenInfraMock _tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>())) .Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"); .Returns(
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
);
_authRepositoryMock _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync(userAccount);
// Act // Act
var result = await _tokenService.RefreshTokenAsync(refreshToken); RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -84,14 +90,17 @@ public class TokenServiceRefreshTests
result.AccessToken.Should().NotBeEmpty(); result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty(); result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify( _authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
x => x.GetUserByIdAsync(userId),
Times.Once
);
// Verify tokens were generated (called twice - once for access, once for refresh) // Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify( _tokenInfraMock.Verify(
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()), x =>
x.GenerateJwt(
It.IsAny<Guid>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Exactly(2) Times.Exactly(2)
); );
} }
@@ -107,9 +116,10 @@ public class TokenServiceRefreshTests
.ThrowsAsync(new UnauthorizedException("Invalid refresh token")); .ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(invalidToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -123,41 +133,41 @@ public class TokenServiceRefreshTests
.ThrowsAsync(new UnauthorizedException("Refresh token has expired")); .ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(expiredToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException() public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string refreshToken = "valid-refresh-token"; const string refreshToken = "valid-refresh-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
_authRepositoryMock _authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
.Setup(x => x.GetUserByIdAsync(userId))
.ReturnsAsync((UserAccount?)null);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.RefreshTokenAsync(refreshToken) .Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*"); .WithMessage("*User account not found*");
} }
} }

View File

@@ -1,10 +1,9 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using FluentAssertions;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Moq; using Moq;
@@ -12,8 +11,8 @@ namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests public class TokenServiceValidationTests
{ {
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly Mock<IAuthRepository> _authRepositoryMock; private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService; private readonly TokenService _tokenService;
public TokenServiceValidationTests() public TokenServiceValidationTests()
@@ -22,75 +21,82 @@ public class TokenServiceValidationTests
_authRepositoryMock = new Mock<IAuthRepository>(); _authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens // Set environment variables for tokens
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890"); Environment.SetEnvironmentVariable(
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890"); "ACCESS_TOKEN_SECRET",
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890"); "test-access-secret-that-is-very-long-1234567890"
_tokenService = new TokenService(
_tokenInfraMock.Object,
_authRepositoryMock.Object
); );
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"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
} }
[Fact] [Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-access-token"; const string token = "valid-access-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
await _tokenService.ValidateAccessTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.UserId.Should().Be(userId); result.UserId.Should().Be(userId);
result.Username.Should().Be(username); result.Username.Should().Be(username);
result.Principal.Should().NotBeNull(); result.Principal.Should().NotBeNull();
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString()); result
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
?.Value.Should()
.Be(userId.ToString());
} }
[Fact] [Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-refresh-token"; const string token = "valid-refresh-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
await _tokenService.ValidateRefreshTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -102,27 +108,26 @@ public class TokenServiceValidationTests
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken() public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string username = "testuser"; const string username = "testuser";
const string token = "valid-confirmation-token"; const string token = "valid-confirmation-token";
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act // Act
var result = ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -141,9 +146,10 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -154,14 +160,13 @@ public class TokenServiceValidationTests
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException( .ThrowsAsync(new UnauthorizedException("Token has expired"));
"Token has expired"
));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -172,23 +177,24 @@ public class TokenServiceValidationTests
const string token = "token-without-user-id"; const string token = "token-without-user-id";
// Claims without Sub (user ID) // Claims without Sub (user ID)
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
@@ -196,27 +202,28 @@ public class TokenServiceValidationTests
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException() public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{ {
// Arrange // Arrange
var userId = Guid.NewGuid(); Guid userId = Guid.NewGuid();
const string token = "token-without-username"; const string token = "token-without-username";
// Claims without UniqueName (username) // Claims without UniqueName (username)
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*"); .WithMessage("*missing required claims*");
} }
@@ -228,24 +235,25 @@ public class TokenServiceValidationTests
const string token = "token-with-malformed-user-id"; const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format // Claims with invalid GUID format
var claims = new List<Claim> List<Claim> claims = new()
{ {
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"), new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new(JwtRegisteredClaimNames.UniqueName, username), new Claim(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var claimsIdentity = new ClaimsIdentity(claims); ClaimsIdentity claimsIdentity = new(claims);
var principal = new ClaimsPrincipal(claimsIdentity); ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock _tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>())) .Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal); .ReturnsAsync(principal);
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateAccessTokenAsync(token) .Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>() .Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*"); .WithMessage("*malformed user ID*");
} }
@@ -260,9 +268,10 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateRefreshTokenAsync(token) .Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
[Fact] [Fact]
@@ -276,8 +285,9 @@ public class TokenServiceValidationTests
.ThrowsAsync(new UnauthorizedException("Invalid token")); .ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert // Act & Assert
await FluentActions.Invoking(async () => await FluentActions
await _tokenService.ValidateConfirmationTokenAsync(token) .Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
).Should().ThrowAsync<UnauthorizedException>(); .Should()
.ThrowAsync<UnauthorizedException>();
} }
} }

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -7,29 +8,30 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.ConfirmUser;
/// <summary> /// <summary>
/// Handles <see cref="ConfirmUserCommand"/> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed. /// corresponding user account as confirmed.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param> /// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param> /// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler( public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
IAuthRepository authRepository, : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
ITokenService tokenService
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{ {
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found. /// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
/// </exception> /// </exception>
public async Task<ConfirmationPayload> Handle(ConfirmUserCommand request, CancellationToken cancellationToken) 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) if (user == null)
{
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
}
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow); return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
} }

View File

@@ -5,16 +5,24 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Handles <see cref="RefreshTokenCommand"/> by validating the refresh token and issuing a new /// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair. /// access/refresh token pair.
/// </summary> /// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param> /// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService) public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload> : IRequestHandler<RefreshTokenCommand, LoginPayload>
{ {
public async Task<LoginPayload> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken
)
{ {
var result = await tokenService.RefreshTokenAsync(request.RefreshToken); RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken); return new LoginPayload(
result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.RefreshToken,
result.AccessToken
);
} }
} }

View File

@@ -3,17 +3,15 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.RefreshToken;
/// <summary> /// <summary>
/// Validates <see cref="RefreshTokenCommand"/> instances before they are processed. /// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand> public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{ {
/// <summary> /// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken"/> to be non-empty. /// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary> /// </summary>
public RefreshTokenValidator() public RefreshTokenValidator()
{ {
RuleFor(x => x.RefreshToken) RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
.NotEmpty()
.WithMessage("Refresh token is required");
} }
} }

View File

@@ -6,12 +6,18 @@ namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>. /// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
/// </summary> /// </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="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="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="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="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( public record RegisterUserCommand(
string Username, string Username,
string FirstName, string FirstName,

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -9,7 +10,7 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Handles <see cref="RegisterUserCommand"/>: validates uniqueness, hashes the password, persists the /// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration /// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails. /// confirmation email via Features.Emails.
/// </summary> /// </summary>
@@ -27,13 +28,16 @@ public class RegisterUserHandler(
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address. /// Thrown when an existing account already has the same username or email address.
/// </exception> /// </exception>
public async Task<RegistrationPayload> Handle(RegisterUserCommand request, CancellationToken cancellationToken) public async Task<RegistrationPayload> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken
)
{ {
await ValidateUserDoesNotExist(request.Username, request.Email); 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.Username,
request.FirstName, request.FirstName,
request.LastName, request.LastName,
@@ -42,20 +46,28 @@ public class RegisterUserHandler(
hashed hashed
); );
var accessToken = tokenService.GenerateAccessToken(createdUser); string accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser); string refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser); string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{ return new RegistrationPayload(
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false); createdUser.UserAccountId,
} createdUser.Username,
string.Empty,
string.Empty,
false
);
var emailSent = false; bool emailSent = false;
try try
{ {
await mediator.Send( await mediator.Send(
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken), new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken cancellationToken
); );
emailSent = true; emailSent = true;
@@ -67,7 +79,13 @@ public class RegisterUserHandler(
// ignored // ignored
} }
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent); return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
} }
/// <exception cref="ConflictException"> /// <exception cref="ConflictException">
@@ -75,12 +93,10 @@ public class RegisterUserHandler(
/// </exception> /// </exception>
private async Task ValidateUserDoesNotExist(string username, string email) private async Task ValidateUserDoesNotExist(string username, string email)
{ {
var existingUsername = await authRepo.GetUserByUsernameAsync(username); UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
var existingEmail = await authRepo.GetUserByEmailAsync(email); UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null) if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists"); throw new ConflictException("Username or email already exists");
} }
}
} }

View File

@@ -3,7 +3,7 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.RegisterUser;
/// <summary> /// <summary>
/// Validates <see cref="RegisterUserCommand"/> instances before they are processed. /// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand> public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{ {
@@ -61,8 +61,6 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
.Matches("[0-9]") .Matches("[0-9]")
.WithMessage("Password must contain at least one number") .WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]") .Matches("[^a-zA-Z0-9]")
.WithMessage( .WithMessage("Password must contain at least one special character");
"Password must contain at least one special character"
);
} }
} }

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Auth.Repository; using Features.Auth.Repository;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
@@ -6,7 +7,7 @@ using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary> /// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand"/> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails. /// sending it via Features.Emails.
/// </summary> /// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param> /// <param name="authRepository">Repository used to look up the user and check verification status.</param>
@@ -22,20 +23,19 @@ public class ResendConfirmationEmailHandler(
IMediator mediator IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand> ) : IRequestHandler<ResendConfirmationEmailCommand>
{ {
public async Task Handle(ResendConfirmationEmailCommand request, CancellationToken cancellationToken) public async Task Handle(
ResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{ {
var user = await authRepository.GetUserByIdAsync(request.UserId); UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null) if (user == null)
{
return; // Silent return to prevent user enumeration return; // Silent return to prevent user enumeration
}
if (await authRepository.IsUserVerifiedAsync(request.UserId)) if (await authRepository.IsUserVerifiedAsync(request.UserId))
{
return; // Already confirmed, no-op return; // Already confirmed, no-op
}
var confirmationToken = tokenService.GenerateConfirmationToken(user); string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send( await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken), new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken cancellationToken

View File

@@ -9,21 +9,21 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts; using Shared.Contracts;
namespace Features.Auth.Controllers namespace Features.Auth.Controllers;
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{ {
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary> /// <summary>
/// Registers a new user account. /// Registers a new user account.
/// </summary> /// </summary>
@@ -32,19 +32,22 @@ namespace Features.Auth.Controllers
/// username, issued refresh/access tokens, and whether a confirmation email was sent. /// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks> /// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param> /// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="RegistrationPayload"/>.</returns> /// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register( public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
[FromBody] RegisterUserCommand command [FromBody] RegisterUserCommand command
) )
{ {
var payload = await mediator.Send(command); RegistrationPayload payload = await mediator.Send(command);
return Created("/", new ResponseBody<RegistrationPayload> return Created(
"/",
new ResponseBody<RegistrationPayload>
{ {
Message = "User registered successfully.", Message = "User registered successfully.",
Payload = payload, Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -55,17 +58,19 @@ namespace Features.Auth.Controllers
/// username, and a newly issued refresh/access token pair. /// username, and a newly issued refresh/access token pair.
/// </remarks> /// </remarks>
/// <param name="query">The login credentials (username and password).</param> /// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns> /// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query) 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> return Ok(
new ResponseBody<LoginPayload>
{ {
Message = "Logged in successfully.", Message = "Logged in successfully.",
Payload = payload, Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -76,16 +81,20 @@ namespace Features.Auth.Controllers
/// user's ID and the confirmation timestamp. /// user's ID and the confirmation timestamp.
/// </remarks> /// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param> /// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="ConfirmationPayload"/>.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")] [HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm([FromQuery] string token) 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> return Ok(
new ResponseBody<ConfirmationPayload>
{ {
Message = "User with ID " + payload.UserAccountId + " is confirmed.", Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload, Payload = payload,
}); }
);
} }
/// <summary> /// <summary>
@@ -95,7 +104,7 @@ namespace Features.Auth.Controllers
/// Requires JWT authentication. On success, responds with <c>200 OK</c>. /// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks> /// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param> /// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the email was resent.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")] [HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId) public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{ {
@@ -111,19 +120,20 @@ namespace Features.Auth.Controllers
/// username, and the newly issued refresh/access token pair. /// username, and the newly issued refresh/access token pair.
/// </remarks> /// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param> /// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="LoginPayload"/>.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh( public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command [FromBody] RefreshTokenCommand command
) )
{ {
var payload = await mediator.Send(command); LoginPayload payload = await mediator.Send(command);
return Ok(new ResponseBody<LoginPayload> return Ok(
new ResponseBody<LoginPayload>
{ {
Message = "Token refreshed successfully.", Message = "Token refreshed successfully.",
Payload = payload, Payload = payload,
});
} }
);
} }
} }

View File

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

View File

@@ -3,13 +3,13 @@ using FluentValidation;
namespace Features.Auth.Queries.Login; namespace Features.Auth.Queries.Login;
/// <summary> /// <summary>
/// Validates <see cref="LoginQuery"/> instances before they are processed. /// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary> /// </summary>
public class LoginValidator : AbstractValidator<LoginQuery> public class LoginValidator : AbstractValidator<LoginQuery>
{ {
/// <summary> /// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username"/> and /// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password"/> to be non-empty. /// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary> /// </summary>
public LoginValidator() public LoginValidator()
{ {

View File

@@ -7,12 +7,12 @@ using Microsoft.Data.SqlClient;
namespace Features.Auth.Repository; namespace Features.Auth.Repository;
/// <summary> /// <summary>
/// ADO.NET-based implementation of <see cref="IAuthRepository"/> backed by SQL Server stored procedures, /// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification. /// handling user registration, credential lookup/rotation, and account verification.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param> /// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory) public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory), : Repository<UserAccount>(connectionFactory),
IAuthRepository IAuthRepository
{ {
/// <summary> /// <summary>
@@ -21,8 +21,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively: /// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
/// it may be returned as a <see cref="Guid"/>, a parseable <see cref="string"/>, or a 16-byte array. /// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
/// If the result cannot be interpreted, <see cref="Guid.Empty"/> is used, which will cause the /// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail. /// subsequent lookup to fail.
/// </remarks> /// </remarks>
/// <param name="username">Unique username for the user</param> /// <param name="username">Unique username for the user</param>
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <returns>The newly created UserAccount with generated ID</returns> /// <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="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> /// <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 username,
string firstName, string firstName,
string lastName, string lastName,
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
string passwordHash string passwordHash
) )
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser"; command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -56,30 +56,23 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@DateOfBirth", dateOfBirth); AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash); AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
Guid userAccountId = Guid.Empty; Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value) if (result != null && result != DBNull.Value)
{ {
if (result is Guid g) if (result is Guid g)
{
userAccountId = g; userAccountId = g;
} else if (result is string s && Guid.TryParse(s, out Guid parsed))
else if (result is string s && Guid.TryParse(s, out var parsed))
{
userAccountId = parsed; userAccountId = parsed;
}
else if (result is byte[] bytes && bytes.Length == 16) else if (result is byte[] bytes && bytes.Length == 16)
{
userAccountId = new Guid(bytes); userAccountId = new Guid(bytes);
}
else else
{
// Fallback: try to convert and parse string representation // Fallback: try to convert and parse string representation
try try
{ {
var str = result.ToString(); string? str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p)) if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
userAccountId = p; userAccountId = p;
} }
catch catch
@@ -87,9 +80,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
userAccountId = Guid.Empty; 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> /// <summary>
@@ -99,18 +92,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="email">Email address to search for</param> /// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <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)
string email
)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail"; command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email); 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; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
@@ -121,18 +112,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="username">Username to search for</param> /// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <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)
string username
)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername"; command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username); 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; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
@@ -143,18 +132,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync( public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
Guid userAccountId
)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId"; command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); 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; return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
} }
@@ -165,13 +152,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param> /// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task RotateCredentialAsync( public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
Guid userAccountId,
string newPasswordHash
)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential"; command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -187,18 +171,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <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)
Guid userAccountId
)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById"; command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId); 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; return await reader.ReadAsync() ? MapToEntity(reader) : null;
} }
@@ -209,26 +191,23 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed. /// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param> /// <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> /// <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> /// <exception cref="Microsoft.Data.SqlClient.SqlException">
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync( /// Thrown when the database command fails for a reason other than
Guid userAccountId /// a duplicate verification record.
) /// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
{ {
var user = await GetUserByIdAsync(userAccountId); UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null) if (user == null)
{
return null; return null;
}
// Idempotency: if already verified, treat as successful confirmation. // Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId)) if (await IsUserVerifiedAsync(userAccountId))
{
return user; return user;
}
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification"; command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
@@ -256,20 +235,20 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId) public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID"; "SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text; command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId); AddParameter(command, "@UserAccountID", userAccountId);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value; return result != null && result != DBNull.Value;
} }
/// <summary> /// <summary>
/// Determines whether a <see cref="SqlException"/> represents a duplicate key violation /// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert. /// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary> /// </summary>
/// <param name="ex">The SQL exception to inspect.</param> /// <param name="ex">The SQL exception to inspect.</param>
@@ -280,17 +259,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return ex.Number == 2601 || ex.Number == 2627; return ex.Number == 2601 || ex.Number == 2627;
} }
/// <summary> /// <summary>
/// Maps a data reader row to a UserAccount entity. /// Maps a data reader row to a UserAccount entity.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount"/> instance.</returns> /// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override Domain.Entities.UserAccount MapToEntity( protected override UserAccount MapToEntity(DbDataReader reader)
DbDataReader reader
)
{ {
return new Domain.Entities.UserAccount return new UserAccount
{ {
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")), Username = reader.GetString(reader.GetOrdinal("Username")),
@@ -302,9 +278,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
? null ? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")), : reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")), DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
? null
: (byte[])reader["Timer"],
}; };
} }
@@ -313,56 +287,49 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// present in the reader's schema, allowing this method to support result sets that omit it. /// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential"/> instance.</returns> /// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader) private static UserCredential MapToCredentialEntity(DbDataReader reader)
{ {
var entity = new UserCredential UserCredential entity = new()
{ {
UserCredentialId = reader.GetGuid( UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
reader.GetOrdinal("UserCredentialId")
),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")), UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")), Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")), CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
}; };
// Optional columns // Optional columns
var hasTimer = bool hasTimer =
reader reader
.GetSchemaTable() .GetSchemaTable()
?.Rows.Cast<System.Data.DataRow>() ?.Rows.Cast<DataRow>()
.Any(r => .Any(r =>
string.Equals( string.Equals(
r["ColumnName"]?.ToString(), r["ColumnName"]?.ToString(),
"Timer", "Timer",
StringComparison.OrdinalIgnoreCase StringComparison.OrdinalIgnoreCase
) )
) ?? false; )
?? false;
if (hasTimer) if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null ? null
: (byte[])reader["Timer"]; : (byte[])reader["Timer"];
}
return entity; return entity;
} }
/// <summary> /// <summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to /// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value"/>. /// <see cref="DBNull.Value" />.
/// </summary> /// </summary>
/// <param name="command">The command to add the parameter to.</param> /// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param> /// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param> /// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter( private static void AddParameter(DbCommand command, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
var p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;
p.Value = value ?? DBNull.Value; p.Value = value ?? DBNull.Value;
command.Parameters.Add(p); command.Parameters.Add(p);

View File

@@ -18,7 +18,7 @@ public interface IAuthRepository
/// <param name="dateOfBirth">User's date of birth</param> /// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param> /// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns> /// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Entities.UserAccount> RegisterUserAsync( Task<UserAccount> RegisterUserAsync(
string username, string username,
string firstName, string firstName,
string lastName, string lastName,
@@ -33,7 +33,7 @@ public interface IAuthRepository
/// </summary> /// </summary>
/// <param name="email">Email address to search for</param> /// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email); Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary> /// <summary>
/// Retrieves a user account by username (typically used for login). /// Retrieves a user account by username (typically used for login).
@@ -41,7 +41,7 @@ public interface IAuthRepository
/// </summary> /// </summary>
/// <param name="username">Username to search for</param> /// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username); Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary> /// <summary>
/// Retrieves the active (non-revoked) credential for a user account. /// Retrieves the active (non-revoked) credential for a user account.
@@ -49,9 +49,7 @@ public interface IAuthRepository
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns> /// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync( Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
Guid userAccountId
);
/// <summary> /// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one. /// Rotates a user's credential by invalidating all existing credentials and creating a new one.
@@ -66,14 +64,14 @@ public interface IAuthRepository
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param> /// <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> /// <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> /// <summary>
/// Retrieves a user account by ID. /// Retrieves a user account by ID.
/// </summary> /// </summary>
/// <param name="userAccountId">ID of the user account</param> /// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns> /// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId); Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary> /// <summary>
/// Checks whether a user account has been verified. /// Checks whether a user account has been verified.

View File

@@ -10,8 +10,10 @@ public enum TokenType
{ {
/// <summary>A short-lived token used to authorize API requests.</summary> /// <summary>A short-lived token used to authorize API requests.</summary>
AccessToken, AccessToken,
/// <summary>A long-lived token used to obtain new access tokens.</summary> /// <summary>A long-lived token used to obtain new access tokens.</summary>
RefreshToken, RefreshToken,
/// <summary>A short-lived token used to confirm a user's email/account.</summary> /// <summary>A short-lived token used to confirm a user's email/account.</summary>
ConfirmationToken, ConfirmationToken,
} }
@@ -21,7 +23,7 @@ public enum TokenType
/// </summary> /// </summary>
/// <param name="UserId">The unique identifier of the user the token belongs to.</param> /// <param name="UserId">The unique identifier of the user the token belongs to.</param>
/// <param name="Username">The username extracted from the token's claims.</param> /// <param name="Username">The username extracted from the token's claims.</param>
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param> /// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal); public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary> /// <summary>
@@ -30,21 +32,19 @@ public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Princ
/// <param name="UserAccount">The user account associated with the refreshed session.</param> /// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param> /// <param name="RefreshToken">The newly issued refresh token.</param>
/// <param name="AccessToken">The newly issued access token.</param> /// <param name="AccessToken">The newly issued access token.</param>
public record RefreshTokenResult( public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
/// <summary> /// <summary>
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>. /// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
/// </summary> /// </summary>
public static class TokenServiceExpirationHours public static class TokenServiceExpirationHours
{ {
/// <summary>The expiration window, in hours, for access tokens.</summary> /// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1; public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary> /// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
public const double RefreshTokenHours = 504; // 21 days public const double RefreshTokenHours = 504; // 21 days
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary> /// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes public const double ConfirmationTokenHours = 0.5; // 30 minutes
} }
@@ -76,38 +76,39 @@ public interface ITokenService
string GenerateConfirmationToken(UserAccount user); string GenerateConfirmationToken(UserAccount user);
/// <summary> /// <summary>
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>. /// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam> /// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns> /// <returns>The signed token string corresponding to the requested token type.</returns>
string GenerateToken<T>(UserAccount user) where T : struct, Enum; string GenerateToken<T>(UserAccount user)
where T : struct, Enum;
/// <summary> /// <summary>
/// Validates an access token. /// Validates an access token.
/// </summary> /// </summary>
/// <param name="token">The access token string to validate.</param> /// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateAccessTokenAsync(string token); Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a refresh token. /// Validates a refresh token.
/// </summary> /// </summary>
/// <param name="token">The refresh token string to validate.</param> /// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token); Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a confirmation token. /// Validates a confirmation token.
/// </summary> /// </summary>
/// <param name="token">The confirmation token string to validate.</param> /// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token); Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary> /// <summary>
/// Validates a refresh token and issues a new access/refresh token pair for the associated user. /// Validates a refresh token and issues a new access/refresh token pair for the associated user.
/// </summary> /// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param> /// <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> /// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString); Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
} }

View File

@@ -1,5 +1,5 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Repository; using Features.Auth.Repository;
@@ -8,20 +8,19 @@ using Infrastructure.Jwt;
namespace Features.Auth.Services; namespace Features.Auth.Services;
/// <summary> /// <summary>
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs /// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
/// for access, refresh, and confirmation flows using secrets read from environment variables. /// for access, refresh, and confirmation flows using secrets read from environment variables.
/// </summary> /// </summary>
public class TokenService : ITokenService public class TokenService : ITokenService
{ {
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly IAuthRepository _authRepository;
private readonly string _accessTokenSecret; private readonly string _accessTokenSecret;
private readonly string _refreshTokenSecret; private readonly IAuthRepository _authRepository;
private readonly string _confirmationTokenSecret; private readonly string _confirmationTokenSecret;
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary> /// <summary>
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and /// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables. /// confirmation token signing secrets from environment variables.
/// </summary> /// </summary>
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param> /// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
@@ -30,81 +29,107 @@ public class TokenService : ITokenService
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or /// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set. /// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
/// </exception> /// </exception>
public TokenService( public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
ITokenInfrastructure tokenInfrastructure,
IAuthRepository authRepository
)
{ {
_tokenInfrastructure = tokenInfrastructure; _tokenInfrastructure = tokenInfrastructure;
_authRepository = authRepository; _authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET") _accessTokenSecret =
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set"); Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set"
);
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET") _refreshTokenSecret =
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set"); Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set"
);
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET") _confirmationTokenSecret =
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set"); Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
} }
/// <summary> /// <summary>
/// Generates an access token for the given user, signed with the access token secret and /// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns> /// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user) 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); return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_accessTokenSecret
);
} }
/// <summary> /// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and /// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns> /// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user) public string GenerateRefreshToken(UserAccount user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret); TokenServiceExpirationHours.RefreshTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_refreshTokenSecret
);
} }
/// <summary> /// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and /// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours. /// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary> /// </summary>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns> /// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user) public string GenerateConfirmationToken(UserAccount user)
{ {
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours); DateTime expiresAt = DateTime.UtcNow.AddHours(
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret); TokenServiceExpirationHours.ConfirmationTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_confirmationTokenSecret
);
} }
/// <summary> /// <summary>
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to /// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or /// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value. /// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
/// </summary> /// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam> /// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param> /// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns> /// <returns>The signed token string corresponding to the requested token type.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type. /// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception> /// </exception>
public string GenerateToken<T>(UserAccount user) where T : struct, Enum public string GenerateToken<T>(UserAccount user)
where T : struct, Enum
{ {
if (typeof(T) != typeof(TokenType)) if (typeof(T) != typeof(TokenType))
throw new InvalidOperationException("Invalid token type"); throw new InvalidOperationException("Invalid token type");
var tokenTypeName = typeof(T).Name; string tokenTypeName = typeof(T).Name;
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed)) if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
throw new InvalidOperationException("Invalid token type"); throw new InvalidOperationException("Invalid token type");
var tokenType = (TokenType)parsed; TokenType tokenType = (TokenType)parsed;
return tokenType switch return tokenType switch
{ {
TokenType.AccessToken => GenerateAccessToken(user), TokenType.AccessToken => GenerateAccessToken(user),
@@ -118,34 +143,62 @@ public class TokenService : ITokenService
/// Validates an access token against the access token secret. /// Validates an access token against the access token secret.
/// </summary> /// </summary>
/// <param name="token">The access token string to validate.</param> /// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token) public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access"); {
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <summary> /// <summary>
/// Validates a refresh token against the refresh token secret. /// Validates a refresh token against the refresh token secret.
/// </summary> /// </summary>
/// <param name="token">The refresh token string to validate.</param> /// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token) public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh"); {
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <summary> /// <summary>
/// Validates a confirmation token against the confirmation token secret. /// Validates a confirmation token against the confirmation token secret.
/// </summary> /// </summary>
/// <param name="token">The confirmation token string to validate.</param> /// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation. /// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception> /// </exception>
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token) 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> /// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// Performs the shared validation logic for access, refresh, and confirmation tokens:
@@ -154,24 +207,30 @@ public class TokenService : ITokenService
/// <param name="token">The token string to validate.</param> /// <param name="token">The token string to validate.</param>
/// <param name="secret">The secret key to validate the token's signature against.</param> /// <param name="secret">The secret key to validate the token's signature against.</param>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns> /// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException"> /// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid"/>, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
/// </exception> /// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType) private async Task<ValidatedToken> ValidateTokenInternalAsync(
string token,
string secret,
string tokenType
)
{ {
try try
{ {
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret); ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value; string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim)) if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims"); 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"); throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
return new ValidatedToken(userId, usernameClaim, principal); return new ValidatedToken(userId, usernameClaim, principal);
@@ -185,26 +244,4 @@ public class TokenService : ITokenService
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}"); 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,39 +1,31 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions;
using Features.Breweries.Commands.CreateBrewery; using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.Dtos;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Commands; namespace Features.Breweries.Tests.Commands;
public class CreateBreweryHandlerTests public class CreateBreweryHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly CreateBreweryHandler _handler; private readonly CreateBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public CreateBreweryHandlerTests() public CreateBreweryHandlerTests()
{ {
_handler = new CreateBreweryHandler(_repoMock.Object); _handler = new CreateBreweryHandler(_repoMock.Object);
} }
private static CreateBreweryLocation ValidLocation() => private static CreateBreweryLocation ValidLocation()
new( {
CityId: Guid.NewGuid(), return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
AddressLine1: "123 Main St", }
AddressLine2: null,
PostalCode: "12345",
Coordinates: null
);
[Fact] [Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
{ {
var command = new CreateBreweryCommand( CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
PostedById: Guid.NewGuid(),
BreweryName: "MyBrew",
Description: "Desc",
Location: ValidLocation()
);
BreweryPost? persisted = null; BreweryPost? persisted = null;
_repoMock _repoMock
@@ -41,9 +33,9 @@ public class CreateBreweryHandlerTests
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var before = DateTime.UtcNow; DateTime before = DateTime.UtcNow;
var result = await _handler.Handle(command, CancellationToken.None); BreweryDto result = await _handler.Handle(command, CancellationToken.None);
var after = DateTime.UtcNow; DateTime after = DateTime.UtcNow;
persisted.Should().NotBeNull(); persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().NotBe(Guid.Empty); persisted!.BreweryPostId.Should().NotBe(Guid.Empty);

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
using Domain.Entities; using Domain.Entities;
using FluentAssertions; using Features.Breweries.Dtos;
using Features.Breweries.Queries.GetAllBreweries; using Features.Breweries.Queries.GetAllBreweries;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Queries; namespace Features.Breweries.Tests.Queries;
public class GetAllBreweriesHandlerTests public class GetAllBreweriesHandlerTests
{ {
private readonly Mock<IBreweryRepository> _repoMock = new();
private readonly GetAllBreweriesHandler _handler; private readonly GetAllBreweriesHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public GetAllBreweriesHandlerTests() public GetAllBreweriesHandlerTests()
{ {
@@ -19,10 +20,12 @@ public class GetAllBreweriesHandlerTests
[Fact] [Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository() public async Task Handle_PassesLimitAndOffset_ToRepository()
{ {
_repoMock.Setup(r => r.GetAllAsync(10, 5)) _repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<BreweryPost>());
.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(); result.Should().BeEmpty();
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); _repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
@@ -31,16 +34,21 @@ public class GetAllBreweriesHandlerTests
[Fact] [Fact]
public async Task Handle_ReturnsAllBreweries_FromRepository() 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 = "A" },
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
}; };
_repoMock.Setup(r => r.GetAllAsync(null, null)) _repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync(breweries);
.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)); result
.Select(b => b.BreweryPostId)
.Should()
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
} }
} }

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>. /// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
/// </summary> /// </summary>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,

View File

@@ -6,7 +6,7 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location. /// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
/// </summary> /// </summary>
/// <param name="repository">Repository used to persist the new brewery post.</param> /// <param name="repository">Repository used to persist the new brewery post.</param>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
@@ -18,9 +18,12 @@ public class CreateBreweryHandler(IBreweryRepository repository)
/// <param name="request">The details of the brewery post to create.</param> /// <param name="request">The details of the brewery post to create.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param> /// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The newly created brewery post.</returns> /// <returns>The newly created brewery post.</returns>
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) public async Task<BreweryDto> Handle(
CreateBreweryCommand request,
CancellationToken cancellationToken
)
{ {
var entity = new BreweryPost BreweryPost entity = new()
{ {
BreweryPostId = Guid.NewGuid(), BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById, PostedById = request.PostedById,

View File

@@ -3,21 +3,19 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary> /// <summary>
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed. /// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
/// </summary> /// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
/// <summary> /// <summary>
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>, /// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and /// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description, /// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
/// address line 1, and postal code fields. /// address line 1, and postal code fields.
/// </summary> /// </summary>
public CreateBreweryValidator() public CreateBreweryValidator()
{ {
RuleFor(x => x.PostedById) RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
.NotEmpty()
.WithMessage("PostedById is required.");
RuleFor(x => x.BreweryName) RuleFor(x => x.BreweryName)
.NotEmpty() .NotEmpty()
@@ -31,9 +29,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
.MaximumLength(512) .MaximumLength(512)
.WithMessage("Description cannot exceed 512 characters."); .WithMessage("Description cannot exceed 512 characters.");
RuleFor(x => x.Location) RuleFor(x => x.Location).NotNull().WithMessage("Location is required.");
.NotNull()
.WithMessage("Location is required.");
RuleFor(x => x.Location.CityId) RuleFor(x => x.Location.CityId)
.NotEmpty() .NotEmpty()

View File

@@ -4,12 +4,14 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary> /// <summary>
/// Handles <see cref="DeleteBreweryCommand"/> by deleting the matching brewery post. /// Handles <see cref="DeleteBreweryCommand" /> by deleting the matching brewery post.
/// </summary> /// </summary>
/// <param name="repository">Repository used to delete the brewery post.</param> /// <param name="repository">Repository used to delete the brewery post.</param>
public class DeleteBreweryHandler(IBreweryRepository repository) public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand> : IRequestHandler<DeleteBreweryCommand>
{ {
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) => public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
repository.DeleteAsync(request.BreweryPostId); {
return repository.DeleteAsync(request.BreweryPostId);
}
} }

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary> /// <summary>
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand"/>. /// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand" />.
/// </summary> /// </summary>
public record UpdateBreweryLocation( public record UpdateBreweryLocation(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
@@ -17,7 +17,7 @@ public record UpdateBreweryLocation(
/// <summary> /// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>. /// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
/// A <c>null</c> <see cref="Location"/> clears the brewery's location. /// A <c>null</c> <see cref="Location" /> clears the brewery's location.
/// </summary> /// </summary>
public record UpdateBreweryCommand( public record UpdateBreweryCommand(
Guid BreweryPostId, Guid BreweryPostId,

View File

@@ -6,29 +6,34 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary> /// <summary>
/// Handles <see cref="UpdateBreweryCommand"/> by persisting changes to an existing brewery post. /// Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.
/// </summary> /// </summary>
/// <param name="repository">Repository used to persist the updated brewery post.</param> /// <param name="repository">Repository used to persist the updated brewery post.</param>
public class UpdateBreweryHandler(IBreweryRepository repository) public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto> : IRequestHandler<UpdateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>, /// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
/// the brewery's location is cleared. /// the brewery's location is cleared.
/// </summary> /// </summary>
/// <param name="request">The updated details of the brewery post.</param> /// <param name="request">The updated details of the brewery post.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param> /// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The updated brewery post.</returns> /// <returns>The updated brewery post.</returns>
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken) public async Task<BreweryDto> Handle(
UpdateBreweryCommand request,
CancellationToken cancellationToken
)
{ {
var entity = new BreweryPost BreweryPost entity = new()
{ {
BreweryPostId = request.BreweryPostId, BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById, PostedById = request.PostedById,
BreweryName = request.BreweryName, BreweryName = request.BreweryName,
Description = request.Description, Description = request.Description,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
Location = request.Location is null ? null : new BreweryPostLocation Location = request.Location is null
? null
: new BreweryPostLocation
{ {
BreweryPostLocationId = request.Location.BreweryPostLocationId, BreweryPostLocationId = request.Location.BreweryPostLocationId,
BreweryPostId = request.BreweryPostId, BreweryPostId = request.BreweryPostId,

View File

@@ -16,7 +16,7 @@ namespace Features.Breweries.Controllers;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints /// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>. /// (<see cref="GetById" /> and <see cref="GetAll" />) opt out via <c>[AllowAnonymous]</c>.
/// </remarks> /// </remarks>
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param> /// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
[ApiController] [ApiController]
@@ -30,22 +30,24 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="id">The unique identifier of the brewery post to retrieve.</param> /// <param name="id">The unique identifier of the brewery post to retrieve.</param>
/// <returns> /// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found; /// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="BreweryDto" /> if found;
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message. /// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody" /> error message.
/// </returns> /// </returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id) 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) if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." }); return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto> return Ok(
new ResponseBody<BreweryDto>
{ {
Message = "Brewery retrieved successfully.", Message = "Brewery retrieved successfully.",
Payload = brewery, Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>
@@ -54,66 +56,90 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param> /// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param> /// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of a collection of <see cref="BreweryDto" />.</returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset) [FromQuery] int? offset
)
{ {
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset)); IEnumerable<BreweryDto> breweries = await mediator.Send(
return Ok(new ResponseBody<IEnumerable<BreweryDto>> new GetAllBreweriesQuery(limit, offset)
);
return Ok(
new ResponseBody<IEnumerable<BreweryDto>>
{ {
Message = "Breweries retrieved successfully.", Message = "Breweries retrieved successfully.",
Payload = breweries, Payload = breweries,
}); }
);
} }
/// <summary> /// <summary>
/// Creates a new brewery post. /// Creates a new brewery post.
/// </summary> /// </summary>
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param> /// <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] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command) 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> return Created(
$"/api/brewery/{brewery.BreweryPostId}",
new ResponseBody<BreweryDto>
{ {
Message = "Brewery created successfully.", Message = "Brewery created successfully.",
Payload = brewery, Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>
/// Updates an existing brewery post. /// Updates an existing brewery post.
/// </summary> /// </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="id">
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param> /// 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> /// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated <see cref="BreweryDto"/> if the /// 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 /// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
/// when the route ID does not match the payload ID. /// when the route ID does not match the payload ID.
/// </returns> /// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command) public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id,
[FromBody] UpdateBreweryCommand command
)
{ {
if (command.BreweryPostId != id) if (command.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload 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> return Ok(
new ResponseBody<BreweryDto>
{ {
Message = "Brewery updated successfully.", Message = "Brewery updated successfully.",
Payload = brewery, Payload = brewery,
}); }
);
} }
/// <summary> /// <summary>
/// Deletes a brewery post. /// Deletes a brewery post.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param> /// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns> /// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the deletion.</returns>
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id) public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{ {

View File

@@ -1,7 +1,7 @@
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary> /// <summary>
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>. /// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto" />.
/// </summary> /// </summary>
public class BreweryLocationDto public class BreweryLocationDto
{ {

View File

@@ -3,17 +3,19 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary> /// <summary>
/// Maps <see cref="BreweryPost"/> domain entities to their <see cref="BreweryDto"/> wire representation. /// Maps <see cref="BreweryPost" /> domain entities to their <see cref="BreweryDto" /> wire representation.
/// </summary> /// </summary>
public static class BreweryDtoMapper public static class BreweryDtoMapper
{ {
/// <summary> /// <summary>
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation, /// Maps a <see cref="BreweryPost" /> domain entity to its <see cref="BreweryDto" /> representation,
/// including its location, if present. /// including its location, if present.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post entity to map.</param> /// <param name="brewery">The brewery post entity to map.</param>
/// <returns>The mapped <see cref="BreweryDto"/>.</returns> /// <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, BreweryPostId = brewery.BreweryPostId,
PostedById = brewery.PostedById, PostedById = brewery.PostedById,
@@ -22,7 +24,9 @@ public static class BreweryDtoMapper
CreatedAt = brewery.CreatedAt, CreatedAt = brewery.CreatedAt,
UpdatedAt = brewery.UpdatedAt, UpdatedAt = brewery.UpdatedAt,
Timer = brewery.Timer, Timer = brewery.Timer,
Location = brewery.Location is null ? null : new BreweryLocationDto Location = brewery.Location is null
? null
: new BreweryLocationDto
{ {
BreweryPostLocationId = brewery.Location.BreweryPostLocationId, BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
BreweryPostId = brewery.Location.BreweryPostId, BreweryPostId = brewery.Location.BreweryPostId,
@@ -33,4 +37,5 @@ public static class BreweryDtoMapper
Coordinates = brewery.Location.Coordinates, Coordinates = brewery.Location.Coordinates,
}, },
}; };
}
} }

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Breweries.Dtos; using Features.Breweries.Dtos;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using MediatR; using MediatR;
@@ -5,15 +6,21 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary> /// <summary>
/// Handles <see cref="GetAllBreweriesQuery"/> by retrieving a paginated list of brewery posts. /// Handles <see cref="GetAllBreweriesQuery" /> by retrieving a paginated list of brewery posts.
/// </summary> /// </summary>
/// <param name="repository">Repository used to query brewery post data.</param> /// <param name="repository">Repository used to query brewery post data.</param>
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken) 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()); return breweries.Select(b => b.ToDto());
} }
} }

View File

@@ -1,3 +1,4 @@
using Domain.Entities;
using Features.Breweries.Dtos; using Features.Breweries.Dtos;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using MediatR; using MediatR;
@@ -5,15 +6,18 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary> /// <summary>
/// Handles <see cref="GetBreweryByIdQuery"/> by looking up the matching brewery post. /// Handles <see cref="GetBreweryByIdQuery" /> by looking up the matching brewery post.
/// </summary> /// </summary>
/// <param name="repository">Repository used to query brewery post data.</param> /// <param name="repository">Repository used to query brewery post data.</param>
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken) 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(); return brewery?.ToDto();
} }
} }

View File

@@ -1,3 +1,4 @@
using System.Data;
using System.Data.Common; using System.Data.Common;
using Domain.Entities; using Domain.Entities;
using Infrastructure.Sql; using Infrastructure.Sql;
@@ -5,33 +6,32 @@ using Infrastructure.Sql;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// ADO.NET-based implementation of <see cref="IBreweryRepository"/> backed by SQL Server stored /// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
/// procedures. /// procedures.
/// </summary> /// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param> /// <param name="connectionFactory">The factory used to create database connections.</param>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: Repository<BreweryPost>(connectionFactory), IBreweryRepository : Repository<BreweryPost>(connectionFactory),
IBreweryRepository
{ {
/// <summary> /// <summary>
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure. /// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the brewery post.</param> /// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns> /// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<BreweryPost?> GetByIdAsync(Guid id) public async Task<BreweryPost?> GetByIdAsync(Guid id)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandType = System.Data.CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.CommandText = "USP_GetBreweryById"; command.CommandText = "USP_GetBreweryById";
AddParameter(command, "@BreweryPostID", id); AddParameter(command, "@BreweryPostID", id);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync()) if (await reader.ReadAsync())
{
return MapToEntity(reader); return MapToEntity(reader);
}
return null; return null;
} }
@@ -42,14 +42,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param> /// <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> /// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns> /// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetAllBreweries"; command.CommandText = "USP_GetAllBreweries";
command.CommandType = System.Data.CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue) if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value); AddParameter(command, "@Limit", limit.Value);
@@ -57,30 +57,28 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
if (offset.HasValue) if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value); AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync(); await using DbDataReader reader = await command.ExecuteReaderAsync();
var breweries = new List<BreweryPost>(); List<BreweryPost> breweries = new();
while (await reader.ReadAsync()) while (await reader.ReadAsync())
{
breweries.Add(MapToEntity(reader)); breweries.Add(MapToEntity(reader));
}
return breweries; return breweries;
} }
/// <summary> /// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, using the /// Updates a brewery post's name and description, and upserts or clears its location, using the
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery"/>.<c>Location</c> is /// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed. /// <c>null</c>, any existing location for the brewery is removed.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post containing updated values.</param> /// <param name="brewery">The brewery post containing updated values.</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery) public async Task UpdateAsync(BreweryPost brewery)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_UpdateBrewery"; command.CommandText = "USP_UpdateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId); AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
AddParameter(command, "@BreweryName", brewery.BreweryName); AddParameter(command, "@BreweryName", brewery.BreweryName);
@@ -103,10 +101,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id) public async Task DeleteAsync(Guid id)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_DeleteBrewery"; command.CommandText = "USP_DeleteBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", id); AddParameter(command, "@BreweryPostID", id);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
@@ -116,20 +114,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure. /// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param> /// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/>.<c>Location</c> is <c>null</c>.</exception> /// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception> /// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task CreateAsync(BreweryPost brewery) public async Task CreateAsync(BreweryPost brewery)
{ {
await using var connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using var command = connection.CreateCommand(); await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateBrewery"; command.CommandText = "USP_CreateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
if (brewery.Location is null) if (brewery.Location is null)
{
throw new ArgumentException("Location must be provided when creating a brewery."); throw new ArgumentException("Location must be provided when creating a brewery.");
}
AddParameter(command, "@BreweryName", brewery.BreweryName); AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description); AddParameter(command, "@Description", brewery.Description);
@@ -140,27 +136,26 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode); AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates); AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
/// <summary> /// <summary>
/// Maps the current row of a data reader to a <see cref="BreweryPost"/> entity, including its /// Maps the current row of a data reader to a <see cref="BreweryPost" /> entity, including its
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its /// rowversion <c>Timer</c> field and, if location columns are present in the result set, its
/// associated <see cref="BreweryPostLocation"/>. /// associated <see cref="BreweryPostLocation" />.
/// </summary> /// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param> /// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="BreweryPost"/> instance.</returns> /// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
protected override BreweryPost MapToEntity(DbDataReader reader) protected override BreweryPost MapToEntity(DbDataReader reader)
{ {
var brewery = new BreweryPost(); BreweryPost brewery = new();
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId"); int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
var ordPostedById = reader.GetOrdinal("PostedById"); int ordPostedById = reader.GetOrdinal("PostedById");
var ordBreweryName = reader.GetOrdinal("BreweryName"); int ordBreweryName = reader.GetOrdinal("BreweryName");
var ordDescription = reader.GetOrdinal("Description"); int ordDescription = reader.GetOrdinal("Description");
var ordCreatedAt = reader.GetOrdinal("CreatedAt"); int ordCreatedAt = reader.GetOrdinal("CreatedAt");
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt"); int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
var ordTimer = reader.GetOrdinal("Timer"); int ordTimer = reader.GetOrdinal("Timer");
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId); brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
brewery.PostedById = reader.GetGuid(ordPostedById); brewery.PostedById = reader.GetGuid(ordPostedById);
@@ -172,39 +167,39 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
// Read timer (varbinary/rowversion) robustly // Read timer (varbinary/rowversion) robustly
if (reader.IsDBNull(ordTimer)) if (reader.IsDBNull(ordTimer))
{
brewery.Timer = null; brewery.Timer = null;
}
else else
{
try try
{ {
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer); brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
} }
catch catch
{ {
var length = reader.GetBytes(ordTimer, 0, null, 0, 0); long length = reader.GetBytes(ordTimer, 0, null, 0, 0);
var buffer = new byte[length]; byte[] buffer = new byte[length];
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length); reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
brewery.Timer = buffer; brewery.Timer = buffer;
} }
}
// Map BreweryPostLocation if columns are present // Map BreweryPostLocation if columns are present
try try
{ {
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId"); int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
if (!reader.IsDBNull(ordLocationId)) if (!reader.IsDBNull(ordLocationId))
{ {
var location = new BreweryPostLocation BreweryPostLocation location = new()
{ {
BreweryPostLocationId = reader.GetGuid(ordLocationId), BreweryPostLocationId = reader.GetGuid(ordLocationId),
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")), BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
CityId = reader.GetGuid(reader.GetOrdinal("CityId")), CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")), 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")), 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; brewery.Location = location;
} }
@@ -219,18 +214,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <summary> /// <summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to /// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value"/>. /// <see cref="DBNull.Value" />.
/// </summary> /// </summary>
/// <param name="command">The command to add the parameter to.</param> /// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param> /// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value"/>.</param> /// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter( private static void AddParameter(DbCommand command, string name, object? value)
DbCommand command,
string name,
object? value
)
{ {
var p = command.CreateParameter(); DbParameter p = command.CreateParameter();
p.ParameterName = name; p.ParameterName = name;
p.Value = value ?? DBNull.Value; p.Value = value ?? DBNull.Value;
command.Parameters.Add(p); command.Parameters.Add(p);

View File

@@ -11,7 +11,7 @@ public interface IBreweryRepository
/// Retrieves a brewery post by its unique identifier. /// Retrieves a brewery post by its unique identifier.
/// </summary> /// </summary>
/// <param name="id">The unique identifier of the brewery post.</param> /// <param name="id">The unique identifier of the brewery post.</param>
/// <returns>The matching <see cref="BreweryPost"/>, or <c>null</c> if not found.</returns> /// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
Task<BreweryPost?> GetByIdAsync(Guid id); Task<BreweryPost?> GetByIdAsync(Guid id);
/// <summary> /// <summary>
@@ -19,7 +19,7 @@ public interface IBreweryRepository
/// </summary> /// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param> /// <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> /// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns> /// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
/// <summary> /// <summary>
@@ -38,6 +38,6 @@ public interface IBreweryRepository
/// Creates a new brewery post, including its location details. /// Creates a new brewery post, including its location details.
/// </summary> /// </summary>
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param> /// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery"/> has no <c>Location</c>.</exception> /// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception>
Task CreateAsync(BreweryPost brewery); Task CreateAsync(BreweryPost brewery);
} }

View File

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

View File

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

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