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;
@@ -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,7 +85,10 @@ 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);
} }
} }

View File

@@ -1,13 +1,14 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers namespace API.Core.Controllers;
{
/// <summary> /// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller. /// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>. /// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>. /// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks> /// </remarks>
[ApiController] [ApiController]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
@@ -24,4 +25,3 @@ 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;
@@ -24,8 +24,8 @@ public class ProtectedController : ControllerBase
[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,8 +14,7 @@ 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,
@@ -24,30 +23,67 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
/// <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,10 +14,11 @@ 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>();
}) })
@@ -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

@@ -2,6 +2,7 @@ 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

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,14 +1,12 @@
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)
@@ -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,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>
@@ -28,41 +35,39 @@ public static class Program
/// </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

@@ -78,7 +78,8 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -110,7 +111,8 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -140,7 +142,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID); ON UserVerification(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -178,10 +181,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
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);
@@ -216,13 +221,16 @@ CREATE TABLE UserFollow
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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -274,7 +282,8 @@ CREATE TABLE StateProvince
REFERENCES Country (CountryID) REFERENCES Country (CountryID)
); );
CREATE NONCLUSTERED INDEX IX_StateProvince_Country CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID); ON StateProvince(CountryID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -299,7 +308,8 @@ CREATE TABLE City
REFERENCES StateProvince (StateProvinceID) REFERENCES StateProvince (StateProvinceID)
); );
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -332,7 +342,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -373,10 +384,12 @@ CREATE TABLE BreweryPostLocation
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:
@@ -422,10 +435,12 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -543,10 +561,12 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
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);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -588,8 +608,10 @@ CREATE TABLE BeerPostComment
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,11 +1,14 @@
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

View File

@@ -1,11 +1,14 @@
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
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode; WHERE ISO3166_2 = @StateProvinceCode;

View File

@@ -1,5 +1,6 @@
CREATE
CREATE OR ALTER PROCEDURE usp_CreateUserAccount OR
ALTER PROCEDURE usp_CreateUserAccount
( (
@UserAccountId UNIQUEIDENTIFIER OUTPUT, @UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR (64), @Username VARCHAR (64),
@@ -10,27 +11,24 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount
) )
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
FROM UserAccount
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
END; END;

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
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,

View File

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

View File

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

View File

@@ -1,17 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId( CREATE
OR
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER @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,15 +1,21 @@
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

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser( CREATE
OR
ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR (64), @Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@@ -8,12 +10,16 @@ CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
) )
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,
@@ -23,18 +29,22 @@ BEGIN
@DateOfBirth = @DateOfBirth, @DateOfBirth = @DateOfBirth,
@Email = @Email; @Email = @Email;
IF @UserAccountId_ IS NULL IF
@UserAccountId_ IS NULL
BEGIN BEGIN
THROW 50000, 'Failed to create user account.', 1; THROW
50000, 'Failed to create user account.', 1;
END END
INSERT INTO dbo.UserCredential INSERT INTO dbo.UserCredential
(UserAccountId, Hash) (UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash); VALUES (@UserAccountId_, @Hash);
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
BEGIN BEGIN
THROW 50002, 'Failed to create user credential.', 1; THROW
50002, 'Failed to create user credential.', 1;
END END
COMMIT TRANSACTION; COMMIT TRANSACTION;

View File

@@ -1,12 +1,17 @@
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_

View File

@@ -1,17 +1,24 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER, CREATE
OR
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL @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

View File

@@ -1,26 +1,36 @@
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
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF @StateProvinceId IS NULL
BEGIN BEGIN
THROW 50001, 'State/province does not exist', 1; TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END 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
50002, 'City already exists.', 1;
END END
INSERT INTO dbo.City INSERT INTO dbo.City

View File

@@ -1,21 +1,26 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100), @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);
IF
@CountryId IS NULL
BEGIN BEGIN
THROW 50001, 'Country does not exist', 1; 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,
@@ -10,29 +12,38 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
) )
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)

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
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
END END

View File

@@ -1,10 +1,13 @@
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,
@@ -23,6 +26,5 @@ BEGIN
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,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER CREATE
OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
SELECT * SELECT *

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),
@@ -11,26 +13,33 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
) )
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,
@@ -38,10 +47,12 @@ BEGIN
UpdatedAt = GETDATE() UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
IF @CityID IS NULL IF
@CityID IS NULL
BEGIN 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
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID; WHERE BreweryPostID = @BreweryPostID;
END END
ELSE IF EXISTS (SELECT 1 ELSE IF EXISTS (SELECT 1

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

@@ -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"),
@@ -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++;
@@ -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

@@ -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

@@ -2,7 +2,8 @@ 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

@@ -2,7 +2,8 @@ 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
{ {
@@ -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

@@ -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;
@@ -12,24 +13,25 @@ namespace Features.Auth.Commands.ConfirmUser;
/// </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

@@ -12,9 +12,17 @@ namespace Features.Auth.Commands.RefreshToken;
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

@@ -12,8 +12,6 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
/// </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;
@@ -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

@@ -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;
@@ -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,8 +9,8 @@ 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> /// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh. /// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary> /// </summary>
@@ -39,12 +39,15 @@ namespace Features.Auth.Controllers
[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>
@@ -60,12 +63,14 @@ namespace Features.Auth.Controllers
[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>
@@ -78,14 +83,18 @@ namespace Features.Auth.Controllers
/// <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>
@@ -118,12 +127,13 @@ namespace Features.Auth.Controllers
[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;
@@ -11,7 +12,10 @@ namespace Features.Auth.Queries.Login;
/// 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

@@ -12,7 +12,7 @@ namespace Features.Auth.Repository;
/// </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>
@@ -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;
} }
@@ -210,25 +192,22 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// </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,15 +235,15 @@ 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;
} }
@@ -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"],
}; };
} }
@@ -316,35 +290,32 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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;
} }
@@ -356,13 +327,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// <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,
} }
@@ -30,11 +32,7 @@ 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" />.
@@ -43,8 +41,10 @@ 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
} }
@@ -81,7 +81,8 @@ public interface ITokenService
/// <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.

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;
@@ -13,12 +13,11 @@ namespace Features.Auth.Services;
/// </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
@@ -30,22 +29,28 @@ 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>
@@ -56,8 +61,13 @@ public class TokenService : ITokenService
/// <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>
@@ -68,8 +78,15 @@ public class TokenService : ITokenService
/// <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>
@@ -80,8 +97,15 @@ public class TokenService : ITokenService
/// <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>
@@ -95,16 +119,17 @@ public class TokenService : ITokenService
/// <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),
@@ -123,7 +148,9 @@ public class TokenService : ITokenService
/// 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.
@@ -134,7 +161,9 @@ public class TokenService : ITokenService
/// 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.
@@ -145,45 +174,8 @@ public class TokenService : ITokenService
/// 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");
/// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary>
/// <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="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>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// 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).
/// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
{ {
try return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
{
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
if (!Guid.TryParse(userIdClaim, out var userId))
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
return new ValidatedToken(userId, usernameClaim, principal);
}
catch (UnauthorizedException)
{
throw;
}
catch (Exception e)
{
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
}
} }
/// <summary> /// <summary>
@@ -197,14 +189,59 @@ public class TokenService : ITokenService
/// </exception> /// </exception>
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString) public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{ {
var validated = await ValidateRefreshTokenAsync(refreshTokenString); ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
var user = await _authRepository.GetUserByIdAsync(validated.UserId); UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
if (user == null) if (user == null)
throw new UnauthorizedException("User account not found"); throw new UnauthorizedException("User account not found");
var newAccess = GenerateAccessToken(user); string newAccess = GenerateAccessToken(user);
var newRefresh = GenerateRefreshToken(user); string newRefresh = GenerateRefreshToken(user);
return new RefreshTokenResult(user, newRefresh, newAccess); return new RefreshTokenResult(user, newRefresh, newAccess);
} }
/// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary>
/// <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="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>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// 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).
/// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync(
string token,
string secret,
string tokenType
)
{
try
{
ClaimsPrincipal principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
string? userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
throw new UnauthorizedException(
$"Invalid {tokenType} token: missing required claims"
);
if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
return new ValidatedToken(userId, usernameClaim, principal);
}
catch (UnauthorizedException)
{
throw;
}
catch (Exception e)
{
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
}
}
} }

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

@@ -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

@@ -15,9 +15,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
/// </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

@@ -10,6 +10,8 @@ namespace Features.Breweries.Commands.DeleteBrewery;
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

@@ -19,16 +19,21 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
/// <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

@@ -37,15 +37,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
[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>
@@ -59,54 +61,78 @@ public class BreweryController(IMediator mediator) : ControllerBase
[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>

View File

@@ -13,7 +13,9 @@ public static class BreweryDtoMapper
/// </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,
@@ -34,3 +38,4 @@ public static class BreweryDtoMapper
}, },
}; };
} }
}

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;
@@ -11,9 +12,15 @@ namespace Features.Breweries.Queries.GetAllBreweries;
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;
@@ -11,9 +12,12 @@ namespace Features.Breweries.Queries.GetBreweryById;
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;
@@ -10,7 +11,8 @@ namespace Features.Breweries.Repository;
/// </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.
@@ -20,18 +22,16 @@ 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<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;
} }
@@ -46,10 +46,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<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,13 +57,11 @@ 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;
} }
@@ -77,10 +75,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 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();
@@ -120,16 +118,14 @@ 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 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,7 +136,6 @@ 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>
@@ -152,15 +147,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <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;
} }
@@ -224,13 +219,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// <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

@@ -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);

View File

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

View File

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

View File

@@ -29,36 +29,35 @@ public class EmailDispatcher(
/// Builds a confirmation link from the given token, renders the registration welcome email template, /// Builds a confirmation link from the given token, renders the registration welcome email template,
/// and sends it to the newly created user. /// and sends it to the newly created user.
/// </summary> /// </summary>
public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken) public async Task SendRegistrationEmailAsync(
string firstName,
string email,
string confirmationToken
)
{ {
var confirmationLink = string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml = string emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
firstName, firstName,
confirmationLink confirmationLink
); );
await emailProvider.SendAsync( await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
email,
"Welcome to The Biergarten App!",
emailHtml,
isHtml: true
);
} }
/// <summary> /// <summary>
/// Builds a confirmation link from the given token, renders the resend-confirmation email template, /// Builds a confirmation link from the given token, renders the resend-confirmation email template,
/// and sends it to the user. /// and sends it to the user.
/// </summary> /// </summary>
public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken) public async Task SendResendConfirmationEmailAsync(
string firstName,
string email,
string confirmationToken
)
{ {
var confirmationLink = string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml = string emailHtml = await emailTemplateProvider.RenderResendConfirmationEmailAsync(
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
firstName, firstName,
confirmationLink confirmationLink
); );
@@ -67,7 +66,7 @@ public class EmailDispatcher(
email, email,
"Confirm Your Email - The Biergarten App", "Confirm Your Email - The Biergarten App",
emailHtml, emailHtml,
isHtml: true true
); );
} }
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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