mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 09:37:23 +00:00
Compare commits
24 Commits
0c3b0e99e8
...
ac97c03612
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac97c03612 | ||
|
|
460b0dca9a | ||
|
|
f7e3ace9c1 | ||
|
|
95f4fb8b91 | ||
|
|
1ebe17bf42 | ||
|
|
678ee21bbd | ||
|
|
3d046e369c | ||
|
|
034436d018 | ||
|
|
58833f330c | ||
|
|
bfda60d831 | ||
|
|
39b5d5bf1d | ||
|
|
e5d08c9ec8 | ||
|
|
1272a7ff40 | ||
|
|
0933ded022 | ||
|
|
a37f2971a7 | ||
|
|
9ac039dfeb | ||
|
|
1875dcc8bf | ||
|
|
c19af68c7c | ||
|
|
433c3fbe3d | ||
|
|
aadeab3afa | ||
|
|
0f77ae43b5 | ||
|
|
4de455f34d | ||
|
|
2bdd7bb0c0 | ||
|
|
5b882ac51c |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"indentSize": 4,
|
||||
"endOfLine": "lf",
|
||||
|
||||
@@ -8,15 +8,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.OpenApi"
|
||||
Version="9.0.11"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference
|
||||
Include="FluentValidation.AspNetCore"
|
||||
Version="11.3.0"
|
||||
/>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Shared.Contracts;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace API.Core.Authentication;
|
||||
|
||||
@@ -36,63 +36,42 @@ public class JwtAuthenticationHandler(
|
||||
/// token fails validation.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// A successful <see cref="AuthenticateResult"/> containing the validated <see cref="System.Security.Claims.ClaimsPrincipal"/>
|
||||
/// A successful <see cref="AuthenticateResult" /> containing the validated
|
||||
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
|
||||
/// on success, or a failure result describing why authentication could not be completed.
|
||||
/// </returns>
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
secret = configuration["Jwt:SecretKey"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||
}
|
||||
|
||||
// Check if Authorization header exists
|
||||
if (
|
||||
!Request.Headers.TryGetValue(
|
||||
"Authorization",
|
||||
out var authHeaderValue
|
||||
)
|
||||
)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
|
||||
return AuthenticateResult.Fail("Authorization header is missing");
|
||||
}
|
||||
|
||||
var authHeader = authHeaderValue.ToString();
|
||||
if (
|
||||
!authHeader.StartsWith(
|
||||
"Bearer ",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
"Invalid authorization header format"
|
||||
);
|
||||
}
|
||||
string authHeader = authHeaderValue.ToString();
|
||||
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateResult.Fail("Invalid authorization header format");
|
||||
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
string token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||
token,
|
||||
secret
|
||||
);
|
||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
$"Token validation failed: {ex.Message}"
|
||||
);
|
||||
return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +85,10 @@ public class JwtAuthenticationHandler(
|
||||
Response.ContentType = "application/json";
|
||||
Response.StatusCode = 401;
|
||||
|
||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||
ResponseBody response = new()
|
||||
{
|
||||
Message = "Unauthorized: Invalid or missing authentication token",
|
||||
};
|
||||
await Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in <c>Program.cs</c>.
|
||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
||||
/// <c>Program.cs</c>.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
@@ -24,4 +25,3 @@ namespace API.Core.Controllers
|
||||
return NotFound(new { message = "Route not found." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Shared.Contracts;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
@@ -24,8 +24,8 @@ public class ProtectedController : ControllerBase
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||
|
||||
using Shared.Contracts;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shared.Contracts;
|
||||
using ValidationException = FluentValidation.ValidationException;
|
||||
|
||||
namespace API.Core;
|
||||
|
||||
@@ -14,8 +14,7 @@ namespace API.Core;
|
||||
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
: IExceptionFilter
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Maps exception types to responses as follows:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="FluentValidation.ValidationException"/> → <c>400 Bad Request</c> with per-property validation error messages.</description></item>
|
||||
/// <item><description><see cref="ConflictException"/> → <c>409 Conflict</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||
/// <item><description><see cref="NotFoundException"/> → <c>404 Not Found</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||
/// <item><description><see cref="UnauthorizedException"/> → <c>401 Unauthorized</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||
/// <item><description><see cref="ForbiddenException"/> → <c>403 Forbidden</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||
/// <item><description><see cref="SqlException"/> → <c>503 Service Unavailable</c> with a generic database error message.</description></item>
|
||||
/// <item><description><see cref="Domain.Exceptions.ValidationException"/> → <c>400 Bad Request</c> with a <see cref="ResponseBody"/> message.</description></item>
|
||||
/// <item><description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description></item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
|
||||
/// validation error messages.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
|
||||
/// message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
|
||||
/// message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
|
||||
/// <see cref="ResponseBody" /> message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
|
||||
/// message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
|
||||
/// message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
|
||||
/// <see cref="ResponseBody" /> message.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="context">The context for the unhandled exception, including the exception itself and the result to populate.</param>
|
||||
/// <param name="context">
|
||||
/// The context for the unhandled exception, including the exception itself and the result to
|
||||
/// populate.
|
||||
/// </param>
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
switch (context.Exception)
|
||||
{
|
||||
case FluentValidation.ValidationException fluentValidationException:
|
||||
var errors = fluentValidationException
|
||||
case ValidationException fluentValidationException:
|
||||
Dictionary<string, string[]> errors = fluentValidationException
|
||||
.Errors.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
);
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
|
||||
context.Result = new BadRequestObjectResult(
|
||||
new { message = "Validation failed", errors }
|
||||
@@ -56,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
break;
|
||||
|
||||
case ConflictException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 409,
|
||||
};
|
||||
@@ -66,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
break;
|
||||
|
||||
case NotFoundException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 404,
|
||||
};
|
||||
@@ -76,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
break;
|
||||
|
||||
case UnauthorizedException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 401,
|
||||
};
|
||||
@@ -86,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
break;
|
||||
|
||||
case ForbiddenException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 403,
|
||||
};
|
||||
@@ -106,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
break;
|
||||
|
||||
case Domain.Exceptions.ValidationException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||
{
|
||||
StatusCode = 400,
|
||||
};
|
||||
@@ -117,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
|
||||
default:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody
|
||||
{
|
||||
Message = "An unexpected error occurred",
|
||||
}
|
||||
new ResponseBody { Message = "An unexpected error occurred" }
|
||||
)
|
||||
{
|
||||
StatusCode = 500,
|
||||
|
||||
@@ -14,10 +14,11 @@ using Infrastructure.Jwt;
|
||||
using Infrastructure.Sql;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Global Exception Filter
|
||||
builder.Services.AddControllers(options =>
|
||||
builder
|
||||
.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
})
|
||||
@@ -55,16 +56,11 @@ builder.Services.AddHealthChecks();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
if (!builder.Environment.IsProduction())
|
||||
{
|
||||
builder.Logging.AddDebug();
|
||||
}
|
||||
|
||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||
|
||||
builder.Services.AddSingleton<
|
||||
ISqlConnectionFactory,
|
||||
DefaultSqlConnectionFactory
|
||||
>();
|
||||
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
||||
|
||||
builder.Services.AddFeaturesBreweries();
|
||||
builder.Services.AddFeaturesUserManagement();
|
||||
@@ -81,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
// Configure JWT Authentication
|
||||
builder
|
||||
.Services.AddAuthentication("JWT")
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
"JWT",
|
||||
options => { }
|
||||
);
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
@@ -106,7 +99,7 @@ app.MapControllers();
|
||||
app.MapFallbackToController("Handle404", "NotFound");
|
||||
|
||||
// Graceful shutdown handling
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||
|
||||
@@ -24,10 +24,7 @@
|
||||
/>
|
||||
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@ Feature: User Account Confirmation
|
||||
As a newly registered user
|
||||
I want to confirm my email address via a validation token
|
||||
So that my account is fully activated
|
||||
|
||||
Scenario: Successful confirmation with valid token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
|
||||
@@ -8,11 +8,7 @@ public class MockEmailDispatcher : IEmailDispatcher
|
||||
|
||||
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||
|
||||
public Task SendRegistrationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
string confirmationToken
|
||||
)
|
||||
public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
|
||||
{
|
||||
SentRegistrationEmails.Add(
|
||||
new RegistrationEmail
|
||||
|
||||
@@ -10,12 +10,7 @@ public class MockEmailProvider : IEmailProvider
|
||||
{
|
||||
public List<SentEmail> SentEmails { get; } = new();
|
||||
|
||||
public Task SendAsync(
|
||||
string to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
@@ -31,12 +26,7 @@ public class MockEmailProvider : IEmailProvider
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendAsync(
|
||||
IEnumerable<string> to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using API.Specs;
|
||||
using FluentAssertions;
|
||||
using Reqnroll;
|
||||
|
||||
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
|
||||
private HttpClient GetClient()
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||
return client;
|
||||
}
|
||||
|
||||
var factory = scenario.TryGetValue<TestApiFactory>(
|
||||
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||
FactoryKey,
|
||||
out var f
|
||||
out TestApiFactory? f
|
||||
)
|
||||
? f
|
||||
: new TestApiFactory();
|
||||
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
string jsonBody
|
||||
)
|
||||
{
|
||||
var client = GetClient();
|
||||
HttpClient client = GetClient();
|
||||
|
||||
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
|
||||
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
|
||||
{
|
||||
Content = new StringContent(
|
||||
jsonBody,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I send an HTTP request {string} to {string}")]
|
||||
public async Task WhenISendAnHttpRequestStringToString(
|
||||
string method,
|
||||
string url
|
||||
)
|
||||
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
new HttpMethod(method),
|
||||
url
|
||||
);
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
HttpClient client = GetClient();
|
||||
HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
((int)response!.StatusCode).Should().Be(expected);
|
||||
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.Should()
|
||||
.BeTrue("No response was received from the API");
|
||||
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||
}
|
||||
|
||||
[Then("the response JSON should have {string} equal {string}")]
|
||||
public void ThenTheResponseJsonShouldHaveStringEqualString(
|
||||
string field,
|
||||
string expected
|
||||
)
|
||||
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
if (!root.TryGetProperty(field, out JsonElement value))
|
||||
{
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||
value.GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
if (!root.TryGetProperty(field, out JsonElement value))
|
||||
{
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
var actualValue = value.GetString();
|
||||
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||
string? actualValue = value.GetString();
|
||||
actualValue
|
||||
.Should()
|
||||
.Contain(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Specs.Mocks;
|
||||
using Features.Emails.Services;
|
||||
using Infrastructure.Email;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Specs
|
||||
{
|
||||
namespace API.Specs;
|
||||
|
||||
public class TestApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -18,29 +16,24 @@ namespace API.Specs
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace the real email provider with mock for testing
|
||||
var emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailProvider)
|
||||
);
|
||||
|
||||
if (emailProviderDescriptor != null)
|
||||
{
|
||||
services.Remove(emailProviderDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||
|
||||
// Replace the real email dispatcher with mock for testing
|
||||
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailDispatcher)
|
||||
);
|
||||
|
||||
if (emailDispatcherDescriptor != null)
|
||||
{
|
||||
services.Remove(emailDispatcherDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using DbUp;
|
||||
using DbUp.Engine;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Migrations;
|
||||
@@ -12,6 +13,12 @@ namespace Database.Migrations;
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||
private static readonly string connectionString = BuildConnectionString();
|
||||
|
||||
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||
|
||||
/// <summary>
|
||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||
@@ -28,41 +35,39 @@ public static class Program
|
||||
/// </exception>
|
||||
private static string BuildConnectionString(string? databaseName = null)
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
string server =
|
||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
|
||||
var dbName = databaseName
|
||||
string dbName =
|
||||
databaseName
|
||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
|
||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
||||
string user =
|
||||
Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
|
||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
string password =
|
||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
|
||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||
?? "True";
|
||||
string trustServerCertificate =
|
||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||
|
||||
var builder = new SqlConnectionStringBuilder
|
||||
SqlConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true
|
||||
Encrypt = true,
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||
private static readonly string connectionString = BuildConnectionString();
|
||||
|
||||
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||
|
||||
/// <summary>
|
||||
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||
/// database using DbUp, logging progress to the console.
|
||||
@@ -70,13 +75,13 @@ public static class Program
|
||||
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||
private static bool DeployMigrations()
|
||||
{
|
||||
var upgrader = DeployChanges
|
||||
UpgradeEngine? upgrader = DeployChanges
|
||||
.To.SqlDatabase(connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
var result = upgrader.PerformUpgrade();
|
||||
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
|
||||
return result.Successful;
|
||||
}
|
||||
|
||||
@@ -90,41 +95,42 @@ public static class Program
|
||||
/// </returns>
|
||||
private static bool ClearDatabase()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
SqlConnection myConn = new(masterConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
|
||||
// First, set the database to single user mode to close all connections
|
||||
var setModeCommand = new SqlCommand(
|
||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
||||
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||
myConn);
|
||||
SqlCommand setModeCommand = new(
|
||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
|
||||
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||
myConn
|
||||
);
|
||||
try
|
||||
{
|
||||
setModeCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database set to single user mode.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||
}
|
||||
|
||||
// Then drop the database
|
||||
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||
try
|
||||
{
|
||||
dropCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database cleared successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error dropping database: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error clearing database: {ex}");
|
||||
return false;
|
||||
@@ -132,10 +138,9 @@ public static class Program
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
private static bool CreateDatabaseIfNotExists()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
SqlConnection myConn = new(masterConnectionString);
|
||||
|
||||
const string str = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
||||
CREATE DATABASE [Biergarten]
|
||||
""";
|
||||
|
||||
var myCommand = new SqlCommand(str, myConn);
|
||||
SqlCommand myCommand = new(str, myConn);
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
myCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database creation command executed successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error creating database: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ public static class Program
|
||||
|
||||
try
|
||||
{
|
||||
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||
if (clearDatabase == "true")
|
||||
{
|
||||
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
||||
@@ -196,19 +200,17 @@ public static class Program
|
||||
}
|
||||
|
||||
CreateDatabaseIfNotExists();
|
||||
var success = DeployMigrations();
|
||||
bool success = DeployMigrations();
|
||||
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine("Database migrations completed successfully.");
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
Console.WriteLine("Database migrations failed.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("An error occurred during database migrations:");
|
||||
|
||||
@@ -78,7 +78,8 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||
ON Photo(UploadedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -110,7 +111,8 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
ON UserAvatar(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -140,7 +142,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||
ON UserVerification(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -178,10 +181,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
ON UserCredential(UserAccountID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||
INCLUDE (Hash);
|
||||
|
||||
@@ -216,13 +221,16 @@ CREATE TABLE UserFollow
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CONSTRAINT CK_CannotFollowOwnAccount
|
||||
CHECK (UserAccountID != FollowingID)
|
||||
CHECK (UserAccountID != FollowingID
|
||||
)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
ON UserFollow(UserAccountID, FollowingID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
ON UserFollow(FollowingID, UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -274,7 +282,8 @@ CREATE TABLE StateProvince
|
||||
REFERENCES Country (CountryID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_StateProvince_Country
|
||||
ON StateProvince(CountryID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -299,7 +308,8 @@ CREATE TABLE City
|
||||
REFERENCES StateProvince (StateProvinceID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_City_StateProvince
|
||||
ON City(StateProvinceID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -332,7 +342,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
ON BreweryPost(PostedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -373,10 +384,12 @@ CREATE TABLE BreweryPostLocation
|
||||
REFERENCES City (CityID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
ON BreweryPostLocation(BreweryPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
ON BreweryPostLocation(CityID);
|
||||
|
||||
-- To assess when the time comes:
|
||||
@@ -422,10 +435,12 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
|
||||
CHECK (IBU >= 0 AND IBU <= 120)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||
ON BeerPost(PostedByID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||
ON BeerPost(BeerStyleID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||
ON BeerPost(BrewedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -543,10 +561,12 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@@ -588,8 +608,10 @@ CREATE TABLE BeerPostComment
|
||||
CHECK (Rating BETWEEN 1 AND 5)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
ON BeerPostComment(BeerPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||
ON BeerPostComment(CommentedByID);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||
CREATE
|
||||
OR
|
||||
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||
(
|
||||
@CountryCode NVARCHAR(2)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
DECLARE @CountryId UNIQUEIDENTIFIER;
|
||||
DECLARE
|
||||
@CountryId UNIQUEIDENTIFIER;
|
||||
|
||||
SELECT @CountryId = CountryID
|
||||
FROM dbo.Country
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||
CREATE
|
||||
OR
|
||||
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||
(
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
|
||||
DECLARE
|
||||
@StateProvinceId UNIQUEIDENTIFIER;
|
||||
SELECT @StateProvinceId = StateProvinceID
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @StateProvinceCode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_CreateUserAccount
|
||||
(
|
||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||
@Username VARCHAR (64),
|
||||
@@ -10,27 +11,24 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||
DECLARE
|
||||
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||
|
||||
INSERT INTO UserAccount
|
||||
(
|
||||
Username,
|
||||
(Username,
|
||||
FirstName,
|
||||
LastName,
|
||||
DateOfBirth,
|
||||
Email
|
||||
)
|
||||
Email)
|
||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||
VALUES
|
||||
(
|
||||
@Username,
|
||||
@FirstName,
|
||||
@LastName,
|
||||
@DateOfBirth,
|
||||
@Email
|
||||
@Username, @FirstName, @LastName, @DateOfBirth, @Email
|
||||
);
|
||||
|
||||
SELECT @UserAccountId = UserAccountID FROM @Inserted;
|
||||
SELECT @UserAccountId = UserAccountID
|
||||
FROM @Inserted;
|
||||
END;
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_DeleteUserAccount
|
||||
(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
SET
|
||||
NOCOUNT ON
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||
BEGIN
|
||||
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
||||
RAISERROR
|
||||
('UserAccount with the specified ID does not exist.', 16,
|
||||
1);
|
||||
ROLLBACK TRANSACTION
|
||||
RETURN
|
||||
END
|
||||
|
||||
DELETE FROM UserAccount
|
||||
DELETE
|
||||
FROM UserAccount
|
||||
WHERE UserAccountId = @UserAccountId;
|
||||
END;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetAllUserAccounts
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT UserAccountID,
|
||||
Username,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||
@Email VARCHAR (128)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT UserAccountID,
|
||||
Username,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE USP_GetUserAccountById(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT UserAccountID,
|
||||
Username,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||
@Username VARCHAR (64)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT UserAccountID,
|
||||
Username,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE usp_UpdateUserAccount(
|
||||
@Username VARCHAR (64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
@@ -19,7 +21,8 @@ BEGIN
|
||||
Email = @Email
|
||||
WHERE UserAccountId = @UserAccountId;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
IF
|
||||
@@ROWCOUNT = 0
|
||||
BEGIN
|
||||
THROW
|
||||
50001, 'UserAccount with the specified ID does not exist.', 1;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
UserCredentialId,
|
||||
SELECT UserCredentialId,
|
||||
UserAccountId,
|
||||
Hash,
|
||||
IsRevoked,
|
||||
CreatedAt,
|
||||
RevokedAt
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
AND IsRevoked = 0;
|
||||
END;
|
||||
@@ -1,15 +1,21 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
||||
IF @@ROWCOUNT = 0
|
||||
IF
|
||||
@@ROWCOUNT = 0
|
||||
THROW 50001, 'User account not found', 1;
|
||||
|
||||
-- invalidate all other credentials by setting them to revoked
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
@Username VARCHAR (64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
@@ -8,12 +10,16 @@ CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
|
||||
DECLARE
|
||||
@UserAccountId_ UNIQUEIDENTIFIER;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
EXEC usp_CreateUserAccount
|
||||
@UserAccountId = @UserAccountId_ OUTPUT,
|
||||
@@ -23,18 +29,22 @@ BEGIN
|
||||
@DateOfBirth = @DateOfBirth,
|
||||
@Email = @Email;
|
||||
|
||||
IF @UserAccountId_ IS NULL
|
||||
IF
|
||||
@UserAccountId_ IS NULL
|
||||
BEGIN
|
||||
THROW 50000, 'Failed to create user account.', 1;
|
||||
THROW
|
||||
50000, 'Failed to create user account.', 1;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.UserCredential
|
||||
(UserAccountId, Hash)
|
||||
VALUES (@UserAccountId_, @Hash);
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
IF
|
||||
@@ROWCOUNT = 0
|
||||
BEGIN
|
||||
THROW 50002, 'Failed to create user credential.', 1;
|
||||
THROW
|
||||
50002, 'Failed to create user credential.', 1;
|
||||
END
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||
@Hash NVARCHAR(MAX)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||
@VerificationDateTime DATETIME = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
IF @VerificationDateTime IS NULL
|
||||
IF
|
||||
@VerificationDateTime IS NULL
|
||||
SET @VerificationDateTime = GETDATE();
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
||||
IF @@ROWCOUNT = 0
|
||||
IF
|
||||
@@ROWCOUNT = 0
|
||||
THROW 50001, 'Could not find a user with that id', 1;
|
||||
|
||||
INSERT INTO dbo.UserVerification
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateCity(
|
||||
@CityName NVARCHAR(100),
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION
|
||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||
IF @StateProvinceId IS NULL
|
||||
BEGIN
|
||||
THROW 50001, 'State/province does not exist', 1;
|
||||
TRANSACTION
|
||||
DECLARE
|
||||
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||
IF
|
||||
@StateProvinceId IS NULL
|
||||
BEGIN
|
||||
THROW
|
||||
50001, 'State/province does not exist', 1;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
IF
|
||||
EXISTS (SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityName = @CityName
|
||||
AND StateProvinceID = @StateProvinceId)
|
||||
BEGIN
|
||||
|
||||
THROW 50002, 'City already exists.', 1;
|
||||
THROW
|
||||
50002, 'City already exists.', 1;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.City
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||
@CountryName NVARCHAR(100),
|
||||
@ISO3166_1 NVARCHAR(2)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
IF
|
||||
EXISTS (SELECT 1
|
||||
FROM dbo.Country
|
||||
WHERE ISO3166_1 = @ISO3166_1)
|
||||
THROW 50001, 'Country already exists', 1;
|
||||
|
||||
INSERT INTO dbo.Country
|
||||
(CountryName, ISO3166_1)
|
||||
VALUES
|
||||
(@CountryName, @ISO3166_1);
|
||||
VALUES (@CountryName, @ISO3166_1);
|
||||
COMMIT TRANSACTION;
|
||||
END;
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||
@StateProvinceName NVARCHAR(100),
|
||||
@ISO3166_2 NVARCHAR(6),
|
||||
@CountryCode NVARCHAR(2)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
IF
|
||||
EXISTS (SELECT 1
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @ISO3166_2)
|
||||
RETURN;
|
||||
|
||||
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||
IF @CountryId IS NULL
|
||||
DECLARE
|
||||
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||
IF
|
||||
@CountryId IS NULL
|
||||
BEGIN
|
||||
THROW 50001, 'Country does not exist', 1;
|
||||
THROW
|
||||
50001, 'Country does not exist', 1;
|
||||
|
||||
END
|
||||
|
||||
INSERT INTO dbo.StateProvince
|
||||
(StateProvinceName, ISO3166_2, CountryID)
|
||||
VALUES
|
||||
(@StateProvinceName, @ISO3166_2, @CountryId);
|
||||
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
|
||||
END;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
@BreweryName NVARCHAR(256),
|
||||
@Description NVARCHAR(512),
|
||||
@PostedByID UNIQUEIDENTIFIER,
|
||||
@@ -10,29 +12,38 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
IF @BreweryName IS NULL
|
||||
IF
|
||||
@BreweryName IS NULL
|
||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||
|
||||
IF @Description IS NULL
|
||||
IF
|
||||
@Description IS NULL
|
||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
IF
|
||||
NOT EXISTS (SELECT 1
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @PostedByID)
|
||||
THROW 50404, 'User not found.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
IF
|
||||
NOT EXISTS (SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityID = @CityID)
|
||||
THROW 50404, 'City not found.', 1;
|
||||
|
||||
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE
|
||||
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE
|
||||
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
INSERT INTO dbo.BreweryPost
|
||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_DeleteBrewery
|
||||
@BreweryPostID UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
IF
|
||||
NOT EXISTS (SELECT 1
|
||||
FROM dbo.BreweryPost
|
||||
WHERE BreweryPostID = @BreweryPostID)
|
||||
THROW 50404, 'Brewery not found.', 1;
|
||||
|
||||
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
||||
DELETE FROM dbo.BreweryPost
|
||||
DELETE
|
||||
FROM dbo.BreweryPost
|
||||
WHERE BreweryPostID = @BreweryPostID;
|
||||
END
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||
@Limit INT = NULL,
|
||||
@Offset INT = NULL
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
|
||||
SELECT bp.BreweryPostID,
|
||||
bp.PostedByID,
|
||||
@@ -23,6 +26,5 @@ BEGIN
|
||||
LEFT JOIN dbo.BreweryPostLocation bpl
|
||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||
ORDER BY bp.CreatedAt DESC
|
||||
OFFSET ISNULL(@Offset, 0) ROWS
|
||||
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
||||
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
||||
END
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SELECT *
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||
CREATE
|
||||
OR
|
||||
ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||
@BreweryPostID UNIQUEIDENTIFIER,
|
||||
@BreweryName NVARCHAR(256),
|
||||
@Description NVARCHAR(512),
|
||||
@@ -11,26 +13,33 @@ CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET
|
||||
NOCOUNT ON;
|
||||
SET
|
||||
XACT_ABORT ON;
|
||||
|
||||
IF @BreweryName IS NULL
|
||||
IF
|
||||
@BreweryName IS NULL
|
||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||
|
||||
IF @Description IS NULL
|
||||
IF
|
||||
@Description IS NULL
|
||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
IF
|
||||
NOT EXISTS (SELECT 1
|
||||
FROM dbo.BreweryPost
|
||||
WHERE BreweryPostID = @BreweryPostID)
|
||||
THROW 50404, 'Brewery not found.', 1;
|
||||
|
||||
IF @CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
||||
IF
|
||||
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityID = @CityID)
|
||||
THROW 50404, 'City not found.', 1;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
BEGIN
|
||||
TRANSACTION;
|
||||
|
||||
UPDATE dbo.BreweryPost
|
||||
SET BreweryName = @BreweryName,
|
||||
@@ -38,10 +47,12 @@ BEGIN
|
||||
UpdatedAt = GETDATE()
|
||||
WHERE BreweryPostID = @BreweryPostID;
|
||||
|
||||
IF @CityID IS NULL
|
||||
IF
|
||||
@CityID IS NULL
|
||||
BEGIN
|
||||
-- No location supplied: clear any existing location for this brewery.
|
||||
DELETE FROM dbo.BreweryPostLocation
|
||||
DELETE
|
||||
FROM dbo.BreweryPostLocation
|
||||
WHERE BreweryPostID = @BreweryPostID;
|
||||
END
|
||||
ELSE IF EXISTS (SELECT 1
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
||||
<PackageReference
|
||||
Include="Konscious.Security.Cryptography.Argon2"
|
||||
Version="1.3.1"
|
||||
/>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -15,10 +15,7 @@ namespace Database.Seed;
|
||||
internal class LocationSeeder : ISeeder
|
||||
{
|
||||
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
|
||||
private static readonly IReadOnlyList<(
|
||||
string CountryName,
|
||||
string CountryCode
|
||||
)> Countries =
|
||||
private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
|
||||
[
|
||||
("Canada", "CA"),
|
||||
("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,
|
||||
/// and the ISO 3166-1 code of its parent country.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
||||
{
|
||||
get;
|
||||
} =
|
||||
private static IReadOnlyList<(
|
||||
string StateProvinceName,
|
||||
string StateProvinceCode,
|
||||
string CountryCode
|
||||
)> States { get; } =
|
||||
[
|
||||
("Alabama", "US-AL", "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>
|
||||
public async Task SeedAsync(SqlConnection connection)
|
||||
{
|
||||
foreach (var (countryName, countryCode) in Countries)
|
||||
{
|
||||
foreach ((string countryName, string countryCode) in Countries)
|
||||
await CreateCountryAsync(connection, countryName, countryCode);
|
||||
}
|
||||
|
||||
foreach (
|
||||
var (stateProvinceName, stateProvinceCode, countryCode) in States
|
||||
)
|
||||
{
|
||||
foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
|
||||
await CreateStateProvinceAsync(
|
||||
connection,
|
||||
stateProvinceName,
|
||||
stateProvinceCode,
|
||||
countryCode
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (stateProvinceCode, cityName) in Cities)
|
||||
{
|
||||
foreach ((string stateProvinceCode, string cityName) in Cities)
|
||||
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
||||
/// <param name="connection">An open connection to the target database.</param>
|
||||
@@ -300,10 +290,7 @@ internal class LocationSeeder : ISeeder
|
||||
string countryCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateCountry",
|
||||
connection
|
||||
);
|
||||
await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@CountryName", countryName);
|
||||
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
|
||||
@@ -324,15 +311,9 @@ internal class LocationSeeder : ISeeder
|
||||
string countryCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateStateProvince",
|
||||
connection
|
||||
);
|
||||
await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue(
|
||||
"@StateProvinceName",
|
||||
stateProvinceName
|
||||
);
|
||||
command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
|
||||
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
|
||||
command.Parameters.AddWithValue("@CountryCode", countryCode);
|
||||
|
||||
@@ -350,16 +331,10 @@ internal class LocationSeeder : ISeeder
|
||||
string stateProvinceCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateCity",
|
||||
connection
|
||||
);
|
||||
await using SqlCommand command = new("dbo.USP_CreateCity", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@CityName", cityName);
|
||||
command.Parameters.AddWithValue(
|
||||
"@StateProvinceCode",
|
||||
stateProvinceCode
|
||||
);
|
||||
command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using DbUp;
|
||||
using System.Reflection;
|
||||
using Database.Seed;
|
||||
using Database.Seed;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
// Entry point for the database seeding utility. Connects to the target database
|
||||
// (retrying on transient failures), then runs each registered ISeeder in order to
|
||||
@@ -19,38 +17,41 @@ using Database.Seed;
|
||||
/// </exception>
|
||||
string BuildConnectionString()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
string server =
|
||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
|
||||
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
|
||||
string dbName =
|
||||
Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
|
||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
||||
string user =
|
||||
Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
|
||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
string password =
|
||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
|
||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||
?? "True";
|
||||
string trustServerCertificate =
|
||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||
|
||||
var builder = new SqlConnectionStringBuilder
|
||||
SqlConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true
|
||||
Encrypt = true,
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var connectionString = BuildConnectionString();
|
||||
string connectionString = BuildConnectionString();
|
||||
|
||||
Console.WriteLine("Attempting to connect to database...");
|
||||
|
||||
@@ -60,7 +61,6 @@ try
|
||||
int retryDelayMs = 2000;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection = new SqlConnection(connectionString);
|
||||
@@ -76,24 +76,17 @@ try
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Starting seeding...");
|
||||
|
||||
using (connection)
|
||||
{
|
||||
ISeeder[] seeders =
|
||||
[
|
||||
new LocationSeeder(),
|
||||
new UserSeeder(),
|
||||
];
|
||||
ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
|
||||
|
||||
foreach (var seeder in seeders)
|
||||
foreach (ISeeder seeder in seeders)
|
||||
{
|
||||
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
||||
await seeder.SeedAsync(connection);
|
||||
|
||||
@@ -19,10 +19,7 @@ namespace Database.Seed;
|
||||
internal class UserSeeder : ISeeder
|
||||
{
|
||||
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
|
||||
private static readonly IReadOnlyList<(
|
||||
string FirstName,
|
||||
string LastName
|
||||
)> SeedNames =
|
||||
private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
|
||||
[
|
||||
("Aarya", "Mathews"),
|
||||
("Aiden", "Wells"),
|
||||
@@ -136,8 +133,8 @@ internal class UserSeeder : ISeeder
|
||||
/// <returns>A task that completes when all users have been seeded.</returns>
|
||||
public async Task SeedAsync(SqlConnection connection)
|
||||
{
|
||||
var generator = new PasswordGenerator();
|
||||
var rng = new Random();
|
||||
PasswordGenerator generator = new();
|
||||
Random rng = new();
|
||||
int createdUsers = 0;
|
||||
int createdCredentials = 0;
|
||||
int createdVerifications = 0;
|
||||
@@ -147,8 +144,8 @@ internal class UserSeeder : ISeeder
|
||||
const string firstName = "Test";
|
||||
const string lastName = "User";
|
||||
const string email = "test.user@thebiergarten.app";
|
||||
var dob = new DateTime(1985, 03, 01);
|
||||
var hash = GeneratePasswordHash("password");
|
||||
DateTime dob = new(1985, 03, 01);
|
||||
string hash = GeneratePasswordHash("password");
|
||||
|
||||
await RegisterUserAsync(
|
||||
connection,
|
||||
@@ -160,24 +157,19 @@ internal class UserSeeder : ISeeder
|
||||
hash
|
||||
);
|
||||
}
|
||||
foreach (var (firstName, lastName) in SeedNames)
|
||||
foreach ((string firstName, string lastName) in SeedNames)
|
||||
{
|
||||
// prepare user fields
|
||||
var username = $"{firstName[0]}.{lastName}";
|
||||
var email = $"{firstName}.{lastName}@thebiergarten.app";
|
||||
var dob = GenerateDateOfBirth(rng);
|
||||
string username = $"{firstName[0]}.{lastName}";
|
||||
string email = $"{firstName}.{lastName}@thebiergarten.app";
|
||||
DateTime dob = GenerateDateOfBirth(rng);
|
||||
|
||||
// generate a password and hash it
|
||||
string pwd = generator.Generate(
|
||||
length: 64,
|
||||
numberOfDigits: 10,
|
||||
numberOfSymbols: 10
|
||||
);
|
||||
string pwd = generator.Generate(64, 10, 10);
|
||||
string hash = GeneratePasswordHash(pwd);
|
||||
|
||||
|
||||
// register the user (creates account + credential)
|
||||
var id = await RegisterUserAsync(
|
||||
Guid id = await RegisterUserAsync(
|
||||
connection,
|
||||
username,
|
||||
firstName,
|
||||
@@ -189,9 +181,9 @@ internal class UserSeeder : ISeeder
|
||||
createdUsers++;
|
||||
createdCredentials++;
|
||||
|
||||
|
||||
// add user verification
|
||||
if (await HasUserVerificationAsync(connection, id)) continue;
|
||||
if (await HasUserVerificationAsync(connection, id))
|
||||
continue;
|
||||
|
||||
await AddUserVerificationAsync(connection, id);
|
||||
createdVerifications++;
|
||||
@@ -224,10 +216,9 @@ internal class UserSeeder : ISeeder
|
||||
string hash
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
|
||||
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
|
||||
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
|
||||
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
|
||||
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("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
|
||||
return (Guid)result!;
|
||||
}
|
||||
@@ -252,7 +242,7 @@ internal class UserSeeder : ISeeder
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
|
||||
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
|
||||
{
|
||||
Salt = salt,
|
||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||
@@ -278,9 +268,9 @@ internal class UserSeeder : ISeeder
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountId = @UserAccountId;
|
||||
""";
|
||||
await using var command = new SqlCommand(sql, connection);
|
||||
await using SqlCommand command = new(sql, connection);
|
||||
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
@@ -288,15 +278,9 @@ internal class UserSeeder : ISeeder
|
||||
/// <param name="connection">An open connection to the target database.</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>
|
||||
private static async Task AddUserVerificationAsync(
|
||||
SqlConnection connection,
|
||||
Guid userAccountId
|
||||
)
|
||||
private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateUserVerification",
|
||||
connection
|
||||
);
|
||||
await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
|
||||
|
||||
|
||||
@@ -37,12 +37,14 @@ public class BreweryPost
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||
/// been read from the database.
|
||||
/// </summary>
|
||||
public byte[]? Timer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The associated <see cref="BreweryPostLocation"/> for this post, if one has been set. This is a one-to-one navigation property.
|
||||
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
|
||||
/// navigation property.
|
||||
/// </summary>
|
||||
public BreweryPostLocation? Location { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
|
||||
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is deleted.
|
||||
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
|
||||
/// deleted.
|
||||
/// </summary>
|
||||
public class BreweryPostLocation
|
||||
{
|
||||
@@ -12,7 +13,8 @@ public class BreweryPostLocation
|
||||
public Guid BreweryPostLocationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key referencing the owning <see cref="BreweryPost"/>. Maps to <c>BreweryPostID</c>; unique, enforcing a one-to-one relationship.
|
||||
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
|
||||
/// one-to-one relationship.
|
||||
/// </summary>
|
||||
public Guid BreweryPostId { get; set; }
|
||||
|
||||
@@ -37,12 +39,14 @@ public class BreweryPostLocation
|
||||
public Guid CityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or <c>null</c> if not set.
|
||||
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
|
||||
/// <c>null</c> if not set.
|
||||
/// </summary>
|
||||
public byte[]? Coordinates { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||
/// been read from the database.
|
||||
/// </summary>
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
||||
/// referenced by <see cref="UserCredential"/>, <see cref="UserVerification"/>, brewery posts, and other user-owned data.
|
||||
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
|
||||
/// data.
|
||||
/// </summary>
|
||||
public class UserAccount
|
||||
{
|
||||
@@ -47,7 +48,8 @@ public class UserAccount
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||
/// been read from the database.
|
||||
/// </summary>
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
|
||||
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is deleted.
|
||||
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
|
||||
/// deleted.
|
||||
/// </summary>
|
||||
public class UserCredential
|
||||
{
|
||||
@@ -32,7 +33,8 @@ public class UserCredential
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||
/// been read from the database.
|
||||
/// </summary>
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ public class UserVerification
|
||||
public Guid UserVerificationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key referencing the verified <see cref="UserAccount"/>. Maps to <c>UserAccountID</c>; unique, enforcing a one-to-one relationship.
|
||||
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
|
||||
/// one-to-one relationship.
|
||||
/// </summary>
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
@@ -23,7 +24,8 @@ public class UserVerification
|
||||
public DateTime VerificationDateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has been read from the database.
|
||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||
/// been read from the database.
|
||||
/// </summary>
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,10 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
@@ -13,8 +14,8 @@ namespace Features.Auth.Tests.Commands;
|
||||
public class ConfirmUserHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly ConfirmUserHandler _handler;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
|
||||
public ConfirmUserHandlerTests()
|
||||
{
|
||||
@@ -25,30 +26,35 @@ public class ConfirmUserHandlerTests
|
||||
|
||||
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
|
||||
return new ValidatedToken(userId, username, principal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string confirmationToken = "valid-confirmation-token";
|
||||
|
||||
var validatedToken = MakeValidatedToken(userId, username);
|
||||
var userAccount = new UserAccount { UserAccountId = userId, Username = username };
|
||||
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
||||
|
||||
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||
|
||||
var result = await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||
ConfirmationPayload result = await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
@@ -63,7 +69,8 @@ public class ConfirmUserHandlerTests
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
||||
|
||||
var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
@@ -71,17 +78,25 @@ public class ConfirmUserHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "nonexistent";
|
||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.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*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
@@ -11,16 +12,19 @@ public class RefreshTokenHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
||||
{
|
||||
var tokenServiceMock = new Mock<ITokenService>();
|
||||
var handler = new RefreshTokenHandler(tokenServiceMock.Object);
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new UserAccount { UserAccountId = userId, Username = "testuser" };
|
||||
Mock<ITokenService> tokenServiceMock = new();
|
||||
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
|
||||
|
||||
tokenServiceMock
|
||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||
|
||||
var result = await handler.Handle(new RefreshTokenCommand("old-refresh-token"), CancellationToken.None);
|
||||
LoginPayload result = await handler.Handle(
|
||||
new RefreshTokenCommand("old-refresh-token"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("testuser");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
using Moq;
|
||||
@@ -14,10 +15,10 @@ namespace Features.Auth.Tests.Commands;
|
||||
public class RegisterUserHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly RegisterUserHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<IMediator> _mediatorMock;
|
||||
private readonly RegisterUserHandler _handler;
|
||||
|
||||
public RegisterUserHandlerTests()
|
||||
{
|
||||
@@ -34,23 +35,49 @@ public class RegisterUserHandlerTests
|
||||
);
|
||||
}
|
||||
|
||||
private static RegisterUserCommand ValidCommand(string username = "newuser", string email = "john.doe@example.com") =>
|
||||
new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
|
||||
private static RegisterUserCommand ValidCommand(
|
||||
string username = "newuser",
|
||||
string email = "john.doe@example.com"
|
||||
)
|
||||
{
|
||||
return new RegisterUserCommand(
|
||||
username,
|
||||
"John",
|
||||
"Doe",
|
||||
email,
|
||||
new DateTime(1990, 1, 1),
|
||||
"SecurePassword123!"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||
{
|
||||
var command = ValidCommand();
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
const string hashedPassword = "hashed_password_value";
|
||||
var expectedUserId = Guid.NewGuid();
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword))
|
||||
.ReturnsAsync(new UserAccount
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = expectedUserId,
|
||||
Username = command.Username,
|
||||
@@ -59,13 +86,20 @@ public class RegisterUserHandlerTests
|
||||
Email = command.Email,
|
||||
DateOfBirth = command.DateOfBirth,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>())).Returns("confirmation-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||
.Returns("confirmation-token");
|
||||
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(expectedUserId);
|
||||
@@ -83,18 +117,37 @@ public class RegisterUserHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||
{
|
||||
var command = ValidCommand(username: "existinguser");
|
||||
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
|
||||
RegisterUserCommand command = ValidCommand("existinguser");
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "existinguser",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync(existingUser);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -102,39 +155,73 @@ public class RegisterUserHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||
{
|
||||
var command = ValidCommand(email: "existing@example.com");
|
||||
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Email = "existing@example.com" };
|
||||
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Email = "existing@example.com",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
||||
|
||||
var act = async () => await _handler.Handle(command, CancellationToken.None);
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("Username or email already exists");
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||
{
|
||||
var command = ValidCommand();
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
const string hashedPassword = "hashed_secure_password";
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), hashedPassword))
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.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);
|
||||
|
||||
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(command.Username, command.FirstName, command.LastName, command.Email, command.DateOfBirth, hashedPassword),
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
command.Username,
|
||||
command.FirstName,
|
||||
command.LastName,
|
||||
command.Email,
|
||||
command.DateOfBirth,
|
||||
hashedPassword
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
@@ -142,22 +229,48 @@ public class RegisterUserHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
|
||||
{
|
||||
var command = ValidCommand();
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>())).ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid(), Username = command.Username, Email = command.Email });
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = command.Username,
|
||||
Email = command.Email,
|
||||
}
|
||||
);
|
||||
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
_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"));
|
||||
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
result.ConfirmationEmailSent.Should().BeFalse();
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
|
||||
@@ -11,20 +11,29 @@ namespace Features.Auth.Tests.Commands;
|
||||
public class ResendConfirmationEmailHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||
private readonly Mock<IMediator> _mediatorMock = new();
|
||||
private readonly ResendConfirmationEmailHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock = new();
|
||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||
|
||||
public ResendConfirmationEmailHandlerTests()
|
||||
{
|
||||
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object);
|
||||
_handler = new ResendConfirmationEmailHandler(
|
||||
_authRepositoryMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new UserAccount { UserAccountId = userId, FirstName = "Aaron", Email = "aaron@example.com" };
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = userId,
|
||||
FirstName = "Aaron",
|
||||
Email = "aaron@example.com",
|
||||
};
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
||||
@@ -33,9 +42,15 @@ public class ResendConfirmationEmailHandlerTests
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||
c.FirstName == "Aaron" && c.Email == "aaron@example.com" && c.ConfirmationToken == "fresh-token"
|
||||
), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||
c.FirstName == "Aaron"
|
||||
&& c.Email == "aaron@example.com"
|
||||
&& c.ConfirmationToken == "fresh-token"
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
@@ -43,13 +58,17 @@ public class ResendConfirmationEmailHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -57,8 +76,8 @@ public class ResendConfirmationEmailHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new UserAccount { UserAccountId = userId };
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new() { UserAccountId = userId };
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
||||
@@ -66,7 +85,11 @@ public class ResendConfirmationEmailHandlerTests
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
_mediatorMock.Verify(
|
||||
x => x.Send(It.IsAny<SendResendConfirmationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||
x =>
|
||||
x.Send(
|
||||
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Queries.Login;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Moq;
|
||||
|
||||
@@ -12,25 +13,29 @@ namespace Features.Auth.Tests.Queries;
|
||||
public class LoginHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly LoginHandler _handler;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly LoginHandler _handler;
|
||||
|
||||
public LoginHandlerTests()
|
||||
{
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new LoginHandler(_authRepoMock.Object, _passwordInfraMock.Object, _tokenServiceMock.Object);
|
||||
_handler = new LoginHandler(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||
{
|
||||
const string username = "CogitoErgoSum";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
|
||||
var userAccount = new UserAccount
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
Username = username,
|
||||
@@ -40,7 +45,7 @@ public class LoginHandlerTests
|
||||
DateOfBirth = new DateTime(1596, 03, 31),
|
||||
};
|
||||
|
||||
var userCredential = new UserCredential
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
@@ -49,12 +54,23 @@ public class LoginHandlerTests
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
||||
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(true);
|
||||
_tokenServiceMock.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>())).Returns("access-token");
|
||||
_tokenServiceMock.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())).Returns("refresh-token");
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_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.UserAccountId.Should().Be(userAccountId);
|
||||
@@ -67,44 +83,73 @@ public class LoginHandlerTests
|
||||
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||
{
|
||||
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>();
|
||||
_authRepoMock.Verify(x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()), Times.Never);
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "BRussell";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync((UserCredential?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||
_passwordInfraMock.Verify(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "RCarnap";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
||||
var userCredential = new UserCredential { UserCredentialId = Guid.NewGuid(), UserAccountId = userAccountId, Hash = "hashed-password" };
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "hashed-password",
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync(userCredential);
|
||||
_passwordInfraMock.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>())).Returns(false);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new LoginQuery(username, "wrong-password"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Features.Auth.Tests.Repository;
|
||||
|
||||
public class AuthRepositoryTests
|
||||
{
|
||||
private static AuthRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
private static AuthRepository CreateRepo(MockDbConnection conn)
|
||||
{
|
||||
return new AuthRepository(new TestConnectionFactory(conn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
||||
{
|
||||
var expectedUserId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
||||
.ReturnsScalar(expectedUserId);
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
|
||||
|
||||
// Mock the subsequent read for the newly created user by id
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||
@@ -46,14 +48,14 @@ public class AuthRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.RegisterUserAsync(
|
||||
username: "testuser",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test@example.com",
|
||||
dateOfBirth: new DateTime(1990, 1, 1),
|
||||
passwordHash: "hashedpassword123"
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount result = await repo.RegisterUserAsync(
|
||||
"testuser",
|
||||
"Test",
|
||||
"User",
|
||||
"test@example.com",
|
||||
new DateTime(1990, 1, 1),
|
||||
"hashedpassword123"
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
@@ -68,8 +70,8 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid userId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(
|
||||
@@ -98,8 +100,8 @@ public class AuthRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserAccountId.Should().Be(userId);
|
||||
@@ -112,13 +114,13 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
@@ -126,12 +128,10 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid userId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -158,8 +158,8 @@ public class AuthRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByUsernameAsync("usernameuser");
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserAccountId.Should().Be(userId);
|
||||
@@ -170,15 +170,13 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByUsernameAsync("nonexistent");
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
@@ -186,13 +184,11 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var credentialId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid userId = Guid.NewGuid();
|
||||
Guid credentialId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -202,17 +198,11 @@ public class AuthRepositoryTests
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
credentialId,
|
||||
userId,
|
||||
"hashed_password_value",
|
||||
DateTime.UtcNow,
|
||||
null
|
||||
)
|
||||
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserCredentialId.Should().Be(credentialId);
|
||||
@@ -223,16 +213,14 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid userId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
@@ -240,18 +228,16 @@ public class AuthRepositoryTests
|
||||
[Fact]
|
||||
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var newPasswordHash = "new_hashed_password";
|
||||
var conn = new MockDbConnection();
|
||||
Guid userId = Guid.NewGuid();
|
||||
string newPasswordHash = "new_hashed_password";
|
||||
MockDbConnection conn = new();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
||||
.ReturnsScalar(1);
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
AuthRepository repo = CreateRepo(conn);
|
||||
|
||||
// Should not throw
|
||||
var act = async () =>
|
||||
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
private readonly DbConnection _conn = conn;
|
||||
|
||||
public DbConnection CreateConnection() => _conn;
|
||||
public DbConnection CreateConnection()
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Moq;
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Features.Auth.Tests.Services;
|
||||
|
||||
public class TokenServiceRefreshTests
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceRefreshTests()
|
||||
@@ -22,35 +22,41 @@ public class TokenServiceRefreshTests
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
var userAccount = new UserAccount
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
@@ -68,14 +74,14 @@ public class TokenServiceRefreshTests
|
||||
// Mock the generation of new tokens
|
||||
_tokenInfraMock
|
||||
.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
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -84,14 +90,17 @@ public class TokenServiceRefreshTests
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.GetUserByIdAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_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)
|
||||
);
|
||||
}
|
||||
@@ -107,9 +116,10 @@ public class TokenServiceRefreshTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,41 +133,41 @@ public class TokenServiceRefreshTests
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "valid-refresh-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Moq;
|
||||
|
||||
@@ -12,8 +11,8 @@ namespace Features.Auth.Tests.Services;
|
||||
|
||||
public class TokenServiceValidationTests
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceValidationTests()
|
||||
@@ -22,75 +21,82 @@ public class TokenServiceValidationTests
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_authRepositoryMock.Object
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-access-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateAccessTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
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]
|
||||
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-refresh-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -102,27 +108,26 @@ public class TokenServiceValidationTests
|
||||
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string token = "valid-confirmation-token";
|
||||
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
@@ -141,9 +146,10 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,14 +160,13 @@ public class TokenServiceValidationTests
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Token has expired"
|
||||
));
|
||||
.ThrowsAsync(new UnauthorizedException("Token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -172,23 +177,24 @@ public class TokenServiceValidationTests
|
||||
const string token = "token-without-user-id";
|
||||
|
||||
// Claims without Sub (user ID)
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
@@ -196,27 +202,28 @@ public class TokenServiceValidationTests
|
||||
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid userId = Guid.NewGuid();
|
||||
const string token = "token-without-username";
|
||||
|
||||
// Claims without UniqueName (username)
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*missing required claims*");
|
||||
}
|
||||
|
||||
@@ -228,24 +235,25 @@ public class TokenServiceValidationTests
|
||||
const string token = "token-with-malformed-user-id";
|
||||
|
||||
// Claims with invalid GUID format
|
||||
var claims = new List<Claim>
|
||||
List<Claim> claims = new()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateAccessTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*malformed user ID*");
|
||||
}
|
||||
|
||||
@@ -260,9 +268,10 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -276,8 +285,9 @@ public class TokenServiceValidationTests
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
await FluentActions
|
||||
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
|
||||
.Should()
|
||||
.ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -12,24 +13,25 @@ namespace Features.Auth.Commands.ConfirmUser;
|
||||
/// </summary>
|
||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||
public class ConfirmUserHandler(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService
|
||||
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||
/// </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)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
|
||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,17 @@ namespace Features.Auth.Commands.RefreshToken;
|
||||
public class RefreshTokenHandler(ITokenService tokenService)
|
||||
: 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);
|
||||
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
|
||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(
|
||||
result.UserAccount.UserAccountId,
|
||||
result.UserAccount.Username,
|
||||
result.RefreshToken,
|
||||
result.AccessToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
/// </summary>
|
||||
public RefreshTokenValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,18 @@ namespace Features.Auth.Commands.RegisterUser;
|
||||
/// <summary>
|
||||
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
||||
/// </summary>
|
||||
/// <param name="Username">The desired username; must be 3-64 characters and contain only letters, numbers, dots, underscores, and hyphens.</param>
|
||||
/// <param name="Username">
|
||||
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
|
||||
/// underscores, and hyphens.
|
||||
/// </param>
|
||||
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
||||
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||
/// <param name="Password">The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a lowercase letter, a number, and a special character.</param>
|
||||
/// <param name="Password">
|
||||
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
|
||||
/// lowercase letter, a number, and a special character.
|
||||
/// </param>
|
||||
public record RegisterUserCommand(
|
||||
string Username,
|
||||
string FirstName,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -27,13 +28,16 @@ public class RegisterUserHandler(
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </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);
|
||||
|
||||
var hashed = passwordInfrastructure.Hash(request.Password);
|
||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
var createdUser = await authRepo.RegisterUserAsync(
|
||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||
request.Username,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
@@ -42,20 +46,28 @@ public class RegisterUserHandler(
|
||||
hashed
|
||||
);
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
|
||||
}
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false
|
||||
);
|
||||
|
||||
var emailSent = false;
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SendRegistrationEmailCommand(createdUser.FirstName, createdUser.Email, confirmationToken),
|
||||
new SendRegistrationEmailCommand(
|
||||
createdUser.FirstName,
|
||||
createdUser.Email,
|
||||
confirmationToken
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
emailSent = true;
|
||||
@@ -67,7 +79,13 @@ public class RegisterUserHandler(
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, refreshToken, accessToken, emailSent);
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
|
||||
/// <exception cref="ConflictException">
|
||||
@@ -75,12 +93,10 @@ public class RegisterUserHandler(
|
||||
/// </exception>
|
||||
private async Task ValidateUserDoesNotExist(string username, string email)
|
||||
{
|
||||
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
var existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
{
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,6 @@ public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
.WithMessage("Password must contain at least one special character");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
@@ -22,20 +23,19 @@ public class ResendConfirmationEmailHandler(
|
||||
IMediator mediator
|
||||
) : 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)
|
||||
{
|
||||
return; // Silent return to prevent user enumeration
|
||||
}
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
||||
{
|
||||
return; // Already confirmed, no-op
|
||||
}
|
||||
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
await mediator.Send(
|
||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
|
||||
@@ -9,8 +9,8 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Auth.Controllers
|
||||
{
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||
/// </summary>
|
||||
@@ -39,12 +39,15 @@ namespace Features.Auth.Controllers
|
||||
[FromBody] RegisterUserCommand command
|
||||
)
|
||||
{
|
||||
var payload = await mediator.Send(command);
|
||||
return Created("/", new ResponseBody<RegistrationPayload>
|
||||
RegistrationPayload payload = await mediator.Send(command);
|
||||
return Created(
|
||||
"/",
|
||||
new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,12 +63,14 @@ namespace Features.Auth.Controllers
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||
{
|
||||
var payload = await mediator.Send(query);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
LoginPayload payload = await mediator.Send(query);
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -78,14 +83,18 @@ namespace Features.Auth.Controllers
|
||||
/// <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>
|
||||
[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));
|
||||
return Ok(new ResponseBody<ConfirmationPayload>
|
||||
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,12 +127,13 @@ namespace Features.Auth.Controllers
|
||||
[FromBody] RefreshTokenCommand command
|
||||
)
|
||||
{
|
||||
var payload = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<LoginPayload>
|
||||
LoginPayload payload = await mediator.Send(command);
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
@@ -11,7 +12,10 @@ namespace Features.Auth.Queries.Login;
|
||||
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
||||
/// </summary>
|
||||
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
|
||||
/// <param name="passwordInfrastructure">Infrastructure component used to verify a plain-text password against a stored hash.</param>
|
||||
/// <param name="passwordInfrastructure">
|
||||
/// Infrastructure component used to verify a plain-text password against a stored
|
||||
/// hash.
|
||||
/// </param>
|
||||
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
|
||||
public class LoginHandler(
|
||||
IAuthRepository authRepo,
|
||||
@@ -25,20 +29,20 @@ public class LoginHandler(
|
||||
/// </exception>
|
||||
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user =
|
||||
UserAccount user =
|
||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
// @todo handle expired passwords
|
||||
var activeCred =
|
||||
UserCredential activeCred =
|
||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(user);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
string accessToken = tokenService.GenerateAccessToken(user);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
|
||||
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Features.Auth.Repository;
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
@@ -34,7 +34,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
public async Task<UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
@@ -43,8 +43,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
string passwordHash
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
|
||||
command.CommandText = "USP_RegisterUser";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
@@ -56,30 +56,23 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
||||
AddParameter(command, "@Hash", passwordHash);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
|
||||
Guid userAccountId = Guid.Empty;
|
||||
if (result != null && result != DBNull.Value)
|
||||
{
|
||||
if (result is Guid g)
|
||||
{
|
||||
userAccountId = g;
|
||||
}
|
||||
else if (result is string s && Guid.TryParse(s, out var parsed))
|
||||
{
|
||||
else if (result is string s && Guid.TryParse(s, out Guid parsed))
|
||||
userAccountId = parsed;
|
||||
}
|
||||
else if (result is byte[] bytes && bytes.Length == 16)
|
||||
{
|
||||
userAccountId = new Guid(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: try to convert and parse string representation
|
||||
try
|
||||
{
|
||||
var str = result.ToString();
|
||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
||||
string? str = result.ToString();
|
||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
|
||||
userAccountId = p;
|
||||
}
|
||||
catch
|
||||
@@ -87,9 +80,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
userAccountId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
return await GetUserByIdAsync(userAccountId)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,18 +92,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||
string email
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByEmail";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Email", email);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
@@ -121,18 +112,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
||||
string username
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByUsername";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Username", username);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
@@ -143,18 +132,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
||||
}
|
||||
|
||||
@@ -165,13 +152,10 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task RotateCredentialAsync(
|
||||
Guid userAccountId,
|
||||
string newPasswordHash
|
||||
)
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_RotateUserCredential";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
@@ -187,18 +171,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountById";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
@@ -210,25 +192,22 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails for a reason other than a duplicate verification record.</exception>
|
||||
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than
|
||||
/// a duplicate verification record.
|
||||
/// </exception>
|
||||
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
|
||||
{
|
||||
var user = await GetUserByIdAsync(userAccountId);
|
||||
UserAccount? user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_CreateUserVerification";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
@@ -256,15 +235,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||
command.CommandType = CommandType.Text;
|
||||
|
||||
AddParameter(command, "@UserAccountID", userAccountId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
object? result = await command.ExecuteScalarAsync();
|
||||
return result != null && result != DBNull.Value;
|
||||
}
|
||||
|
||||
@@ -280,17 +259,14 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
||||
protected override Domain.Entities.UserAccount MapToEntity(
|
||||
DbDataReader reader
|
||||
)
|
||||
protected override UserAccount MapToEntity(DbDataReader reader)
|
||||
{
|
||||
return new Domain.Entities.UserAccount
|
||||
return new UserAccount
|
||||
{
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Username = reader.GetString(reader.GetOrdinal("Username")),
|
||||
@@ -302,9 +278,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
? null
|
||||
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"],
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -316,35 +290,32 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
|
||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||
{
|
||||
var entity = new UserCredential
|
||||
UserCredential entity = new()
|
||||
{
|
||||
UserCredentialId = reader.GetGuid(
|
||||
reader.GetOrdinal("UserCredentialId")
|
||||
),
|
||||
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
||||
};
|
||||
|
||||
// Optional columns
|
||||
var hasTimer =
|
||||
bool hasTimer =
|
||||
reader
|
||||
.GetSchemaTable()
|
||||
?.Rows.Cast<System.Data.DataRow>()
|
||||
?.Rows.Cast<DataRow>()
|
||||
.Any(r =>
|
||||
string.Equals(
|
||||
r["ColumnName"]?.ToString(),
|
||||
"Timer",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
) ?? false;
|
||||
)
|
||||
?? false;
|
||||
|
||||
if (hasTimer)
|
||||
{
|
||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"];
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
@@ -356,13 +327,9 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <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="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
|
||||
@@ -18,7 +18,7 @@ public interface IAuthRepository
|
||||
/// <param name="dateOfBirth">User's date of birth</param>
|
||||
/// <param name="passwordHash">Hashed password</param>
|
||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||
Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
Task<UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
@@ -33,7 +33,7 @@ public interface IAuthRepository
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email);
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by username (typically used for login).
|
||||
@@ -41,7 +41,7 @@ public interface IAuthRepository
|
||||
/// </summary>
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username);
|
||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the active (non-revoked) credential for a user account.
|
||||
@@ -49,9 +49,7 @@ public interface IAuthRepository
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
);
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||
@@ -66,14 +64,14 @@ public interface IAuthRepository
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by ID.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user account has been verified.
|
||||
|
||||
@@ -10,8 +10,10 @@ public enum TokenType
|
||||
{
|
||||
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||
AccessToken,
|
||||
|
||||
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||
RefreshToken,
|
||||
|
||||
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||
ConfirmationToken,
|
||||
}
|
||||
@@ -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="RefreshToken">The newly issued refresh token.</param>
|
||||
/// <param name="AccessToken">The newly issued access token.</param>
|
||||
public record RefreshTokenResult(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public const double AccessTokenHours = 1;
|
||||
|
||||
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||
public const double RefreshTokenHours = 504; // 21 days
|
||||
|
||||
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||
}
|
||||
@@ -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>
|
||||
/// <param name="user">The user account to generate the token for.</param>
|
||||
/// <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>
|
||||
/// Validates an access token.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
@@ -13,12 +13,11 @@ namespace Features.Auth.Services;
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
|
||||
@@ -30,22 +29,28 @@ public class TokenService : ITokenService
|
||||
/// 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.
|
||||
/// </exception>
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IAuthRepository authRepository
|
||||
)
|
||||
public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_authRepository = authRepository;
|
||||
|
||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
_accessTokenSecret =
|
||||
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"ACCESS_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
_refreshTokenSecret =
|
||||
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"REFRESH_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
_confirmationTokenSecret =
|
||||
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,8 +61,13 @@ public class TokenService : ITokenService
|
||||
/// <returns>The signed access token string.</returns>
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_accessTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,8 +78,15 @@ public class TokenService : ITokenService
|
||||
/// <returns>The signed refresh token string.</returns>
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.RefreshTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_refreshTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,8 +97,15 @@ public class TokenService : ITokenService
|
||||
/// <returns>The signed confirmation token string.</returns>
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_confirmationTokenSecret
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,16 +119,17 @@ public class TokenService : ITokenService
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||
/// </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))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||
string tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out object? parsed))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenType = (TokenType)parsed;
|
||||
TokenType tokenType = (TokenType)parsed;
|
||||
return tokenType switch
|
||||
{
|
||||
TokenType.AccessToken => GenerateAccessToken(user),
|
||||
@@ -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.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a refresh token against the refresh token secret.
|
||||
@@ -134,7 +161,9 @@ public class TokenService : ITokenService
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
{
|
||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a confirmation token against the confirmation token secret.
|
||||
@@ -145,45 +174,8 @@ public class TokenService : ITokenService
|
||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||
/// </exception>
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
|
||||
/// <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
|
||||
{
|
||||
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}");
|
||||
}
|
||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -197,14 +189,59 @@ public class TokenService : ITokenService
|
||||
/// </exception>
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
var newAccess = GenerateAccessToken(user);
|
||||
var newRefresh = GenerateRefreshToken(user);
|
||||
string newAccess = GenerateAccessToken(user);
|
||||
string newRefresh = GenerateRefreshToken(user);
|
||||
|
||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Commands.CreateBrewery;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Commands;
|
||||
|
||||
public class CreateBreweryHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly CreateBreweryHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public CreateBreweryHandlerTests()
|
||||
{
|
||||
_handler = new CreateBreweryHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
private static CreateBreweryLocation ValidLocation() =>
|
||||
new(
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "123 Main St",
|
||||
AddressLine2: null,
|
||||
PostalCode: "12345",
|
||||
Coordinates: null
|
||||
);
|
||||
private static CreateBreweryLocation ValidLocation()
|
||||
{
|
||||
return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||
{
|
||||
var command = new CreateBreweryCommand(
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "MyBrew",
|
||||
Description: "Desc",
|
||||
Location: ValidLocation()
|
||||
);
|
||||
CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
_repoMock
|
||||
@@ -41,9 +33,9 @@ public class CreateBreweryHandlerTests
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
var after = DateTime.UtcNow;
|
||||
DateTime before = DateTime.UtcNow;
|
||||
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
|
||||
DateTime after = DateTime.UtcNow;
|
||||
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
||||
|
||||
@@ -9,9 +9,9 @@ public class DeleteBreweryHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToRepository()
|
||||
{
|
||||
var repoMock = new Mock<IBreweryRepository>();
|
||||
var handler = new DeleteBreweryHandler(repoMock.Object);
|
||||
var id = Guid.NewGuid();
|
||||
Mock<IBreweryRepository> repoMock = new();
|
||||
DeleteBreweryHandler handler = new(repoMock.Object);
|
||||
Guid id = Guid.NewGuid();
|
||||
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
||||
|
||||
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Commands.UpdateBrewery;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Commands;
|
||||
|
||||
public class UpdateBreweryHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly UpdateBreweryHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public UpdateBreweryHandlerTests()
|
||||
{
|
||||
@@ -19,12 +19,12 @@ public class UpdateBreweryHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||
{
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Renamed",
|
||||
Description: "New description",
|
||||
Location: null
|
||||
UpdateBreweryCommand command = new(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Renamed",
|
||||
"New description",
|
||||
null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
@@ -33,9 +33,9 @@ public class UpdateBreweryHandlerTests
|
||||
.Callback<BreweryPost>(b => persisted = b)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
DateTime before = DateTime.UtcNow;
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
var after = DateTime.UtcNow;
|
||||
DateTime after = DateTime.UtcNow;
|
||||
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||
@@ -49,12 +49,12 @@ public class UpdateBreweryHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
||||
{
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: null
|
||||
UpdateBreweryCommand command = new(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
"Description",
|
||||
null
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
@@ -71,20 +71,20 @@ public class UpdateBreweryHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
||||
{
|
||||
var locationCommand = new UpdateBreweryLocation(
|
||||
BreweryPostLocationId: Guid.NewGuid(),
|
||||
CityId: Guid.NewGuid(),
|
||||
AddressLine1: "456 Oak Ave",
|
||||
AddressLine2: "Suite 2",
|
||||
PostalCode: "54321",
|
||||
Coordinates: null
|
||||
UpdateBreweryLocation locationCommand = new(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"456 Oak Ave",
|
||||
"Suite 2",
|
||||
"54321",
|
||||
null
|
||||
);
|
||||
var command = new UpdateBreweryCommand(
|
||||
BreweryPostId: Guid.NewGuid(),
|
||||
PostedById: Guid.NewGuid(),
|
||||
BreweryName: "Name",
|
||||
Description: "Description",
|
||||
Location: locationCommand
|
||||
UpdateBreweryCommand command = new(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"Name",
|
||||
"Description",
|
||||
locationCommand
|
||||
);
|
||||
|
||||
BreweryPost? persisted = null;
|
||||
@@ -96,7 +96,9 @@ public class UpdateBreweryHandlerTests
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
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.CityId.Should().Be(locationCommand.CityId);
|
||||
persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetAllBreweries;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetAllBreweriesHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly GetAllBreweriesHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public GetAllBreweriesHandlerTests()
|
||||
{
|
||||
@@ -19,10 +20,12 @@ public class GetAllBreweriesHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||
|
||||
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweriesQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
@@ -31,16 +34,21 @@ public class GetAllBreweriesHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsAllBreweries_FromRepository()
|
||||
{
|
||||
var breweries = new[]
|
||||
BreweryPost[] breweries = new[]
|
||||
{
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||
.ReturnsAsync(breweries);
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync(breweries);
|
||||
|
||||
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
||||
IEnumerable<BreweryDto> result = await _handler.Handle(
|
||||
new GetAllBreweriesQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
result
|
||||
.Select(b => b.BreweryPostId)
|
||||
.Should()
|
||||
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Queries.GetBreweryById;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Breweries.Tests.Queries;
|
||||
|
||||
public class GetBreweryByIdHandlerTests
|
||||
{
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
private readonly GetBreweryByIdHandler _handler;
|
||||
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||
|
||||
public GetBreweryByIdHandlerTests()
|
||||
{
|
||||
@@ -19,11 +20,13 @@ public class GetBreweryByIdHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsBrewery_WhenFound()
|
||||
{
|
||||
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||
.ReturnsAsync(brewery);
|
||||
BreweryPost brewery = new() { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId)).ReturnsAsync(brewery);
|
||||
|
||||
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
||||
BreweryDto? result = await _handler.Handle(
|
||||
new GetBreweryByIdQuery(brewery.BreweryPostId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
||||
@@ -32,11 +35,13 @@ public class GetBreweryByIdHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||
.ReturnsAsync((BreweryPost?)null);
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((BreweryPost?)null);
|
||||
|
||||
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
||||
BreweryDto? result = await _handler.Handle(
|
||||
new GetBreweryByIdQuery(id),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Features.Breweries.Repository;
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Repository;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Features.Breweries.Tests.Repository;
|
||||
|
||||
public class BreweryRepositoryTests
|
||||
{
|
||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
private static BreweryRepository CreateRepo(MockDbConnection conn)
|
||||
{
|
||||
return new BreweryRepository(new TestConnectionFactory(conn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
||||
{
|
||||
var breweryId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
Guid breweryId = Guid.NewGuid();
|
||||
MockDbConnection conn = new();
|
||||
|
||||
// Repository calls the stored procedure
|
||||
const string getByIdSql = "USP_GetBreweryById";
|
||||
|
||||
var locationId = Guid.NewGuid();
|
||||
Guid locationId = Guid.NewGuid();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
||||
.ReturnsTable(
|
||||
@@ -56,8 +58,8 @@ public class BreweryRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(breweryId);
|
||||
BreweryRepository repo = CreateRepo(conn);
|
||||
BreweryPost? result = await repo.GetByIdAsync(breweryId);
|
||||
result.Should().NotBeNull();
|
||||
result!.BreweryPostId.Should().Be(breweryId);
|
||||
result.Location.Should().NotBeNull();
|
||||
@@ -67,23 +69,21 @@ public class BreweryRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(Guid.NewGuid());
|
||||
BreweryRepository repo = CreateRepo(conn);
|
||||
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
|
||||
result.Should().BeNull();
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ExecutesSuccessfully()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
||||
.ReturnsScalar(1);
|
||||
var repo = CreateRepo(conn);
|
||||
var brewery = new BreweryPost
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
|
||||
BreweryRepository repo = CreateRepo(conn);
|
||||
BreweryPost brewery = new()
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
PostedById = Guid.NewGuid(),
|
||||
@@ -96,12 +96,12 @@ public class BreweryRepositoryTests
|
||||
CityId = Guid.NewGuid(),
|
||||
AddressLine1 = "123 Main St",
|
||||
PostalCode = "12345",
|
||||
Coordinates = [0x00, 0x01]
|
||||
}
|
||||
Coordinates = [0x00, 0x01],
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
var act = async () => await repo.CreateAsync(brewery);
|
||||
Func<Task> act = async () => await repo.CreateAsync(brewery);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
private readonly DbConnection _conn = conn;
|
||||
|
||||
public DbConnection CreateConnection() => _conn;
|
||||
public DbConnection CreateConnection()
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ public class CreateBreweryHandler(IBreweryRepository repository)
|
||||
/// <param name="request">The details of the brewery post to create.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <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(),
|
||||
PostedById = request.PostedById,
|
||||
|
||||
@@ -15,9 +15,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
/// </summary>
|
||||
public CreateBreweryValidator()
|
||||
{
|
||||
RuleFor(x => x.PostedById)
|
||||
.NotEmpty()
|
||||
.WithMessage("PostedById is required.");
|
||||
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
|
||||
|
||||
RuleFor(x => x.BreweryName)
|
||||
.NotEmpty()
|
||||
@@ -31,9 +29,7 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||
.MaximumLength(512)
|
||||
.WithMessage("Description cannot exceed 512 characters.");
|
||||
|
||||
RuleFor(x => x.Location)
|
||||
.NotNull()
|
||||
.WithMessage("Location is required.");
|
||||
RuleFor(x => x.Location).NotNull().WithMessage("Location is required.");
|
||||
|
||||
RuleFor(x => x.Location.CityId)
|
||||
.NotEmpty()
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace Features.Breweries.Commands.DeleteBrewery;
|
||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||
: IRequestHandler<DeleteBreweryCommand>
|
||||
{
|
||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) =>
|
||||
repository.DeleteAsync(request.BreweryPostId);
|
||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return repository.DeleteAsync(request.BreweryPostId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,21 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||
/// <param name="request">The updated details of the brewery post.</param>
|
||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||
/// <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,
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Location = request.Location is null ? null : new BreweryPostLocation
|
||||
Location = request.Location is null
|
||||
? null
|
||||
: new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
|
||||
@@ -37,15 +37,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||
{
|
||||
var brewery = await mediator.Send(new GetBreweryByIdQuery(id));
|
||||
BreweryDto? brewery = await mediator.Send(new GetBreweryByIdQuery(id));
|
||||
if (brewery is null)
|
||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
return Ok(
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,54 +61,78 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset)
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
|
||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||
IEnumerable<BreweryDto> breweries = await mediator.Send(
|
||||
new GetAllBreweriesQuery(limit, offset)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new brewery post.
|
||||
/// </summary>
|
||||
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
|
||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>.</returns>
|
||||
/// <returns>
|
||||
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of the newly created
|
||||
/// <see cref="BreweryDto" />.
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||
[FromBody] CreateBreweryCommand command
|
||||
)
|
||||
{
|
||||
var brewery = await mediator.Send(command);
|
||||
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||
BreweryDto brewery = await mediator.Send(command);
|
||||
return Created(
|
||||
$"/api/brewery/{brewery.BreweryPostId}",
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing brewery post.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="command"/>'s ID.</param>
|
||||
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param>
|
||||
/// <param name="id">
|
||||
/// The unique identifier of the brewery post from the route, which must match <paramref name="command" />
|
||||
/// 's ID.
|
||||
/// </param>
|
||||
/// <param name="command">
|
||||
/// The updated brewery details, including the posting user, name, description, and optional
|
||||
/// location.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of the updated <see cref="BreweryDto" /> if the
|
||||
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
|
||||
/// when the route ID does not match the payload ID.
|
||||
/// </returns>
|
||||
[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)
|
||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||
|
||||
var brewery = await mediator.Send(command);
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
BreweryDto brewery = await mediator.Send(command);
|
||||
return Ok(
|
||||
new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = brewery,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,7 +13,9 @@ public static class BreweryDtoMapper
|
||||
/// </summary>
|
||||
/// <param name="brewery">The brewery post entity to map.</param>
|
||||
/// <returns>The mapped <see cref="BreweryDto" />.</returns>
|
||||
public static BreweryDto ToDto(this BreweryPost brewery) => new()
|
||||
public static BreweryDto ToDto(this BreweryPost brewery)
|
||||
{
|
||||
return new BreweryDto
|
||||
{
|
||||
BreweryPostId = brewery.BreweryPostId,
|
||||
PostedById = brewery.PostedById,
|
||||
@@ -22,7 +24,9 @@ public static class BreweryDtoMapper
|
||||
CreatedAt = brewery.CreatedAt,
|
||||
UpdatedAt = brewery.UpdatedAt,
|
||||
Timer = brewery.Timer,
|
||||
Location = brewery.Location is null ? null : new BreweryLocationDto
|
||||
Location = brewery.Location is null
|
||||
? null
|
||||
: new BreweryLocationDto
|
||||
{
|
||||
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
|
||||
BreweryPostId = brewery.Location.BreweryPostId,
|
||||
@@ -34,3 +38,4 @@ public static class BreweryDtoMapper
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
@@ -11,9 +12,15 @@ namespace Features.Breweries.Queries.GetAllBreweries;
|
||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||
: 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Features.Breweries.Dtos;
|
||||
using Features.Breweries.Repository;
|
||||
using MediatR;
|
||||
@@ -11,9 +12,12 @@ namespace Features.Breweries.Queries.GetBreweryById;
|
||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||
: 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Sql;
|
||||
@@ -10,7 +11,8 @@ namespace Features.Breweries.Repository;
|
||||
/// </summary>
|
||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
||||
: Repository<BreweryPost>(connectionFactory),
|
||||
IBreweryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
command.CommandText = "USP_GetBreweryById";
|
||||
AddParameter(command, "@BreweryPostID", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
if (await reader.ReadAsync())
|
||||
{
|
||||
return MapToEntity(reader);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_GetAllBreweries";
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
if (limit.HasValue)
|
||||
AddParameter(command, "@Limit", limit.Value);
|
||||
@@ -57,13 +57,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
if (offset.HasValue)
|
||||
AddParameter(command, "@Offset", offset.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
var breweries = new List<BreweryPost>();
|
||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
||||
List<BreweryPost> breweries = new();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
breweries.Add(MapToEntity(reader));
|
||||
}
|
||||
|
||||
return breweries;
|
||||
}
|
||||
@@ -77,10 +75,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task UpdateAsync(BreweryPost brewery)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_UpdateBrewery";
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
|
||||
AddParameter(command, "@BreweryName", brewery.BreweryName);
|
||||
@@ -103,10 +101,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = "USP_DeleteBrewery";
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@BreweryPostID", id);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
@@ -120,16 +118,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||
public async Task CreateAsync(BreweryPost brewery)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
|
||||
command.CommandText = "USP_CreateBrewery";
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
if (brewery.Location is null)
|
||||
{
|
||||
throw new ArgumentException("Location must be provided when creating a brewery.");
|
||||
}
|
||||
|
||||
AddParameter(command, "@BreweryName", brewery.BreweryName);
|
||||
AddParameter(command, "@Description", brewery.Description);
|
||||
@@ -140,7 +136,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
|
||||
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -152,15 +147,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
|
||||
protected override BreweryPost MapToEntity(DbDataReader reader)
|
||||
{
|
||||
var brewery = new BreweryPost();
|
||||
BreweryPost brewery = new();
|
||||
|
||||
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
||||
var ordPostedById = reader.GetOrdinal("PostedById");
|
||||
var ordBreweryName = reader.GetOrdinal("BreweryName");
|
||||
var ordDescription = reader.GetOrdinal("Description");
|
||||
var ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
||||
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
||||
var ordTimer = reader.GetOrdinal("Timer");
|
||||
int ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
||||
int ordPostedById = reader.GetOrdinal("PostedById");
|
||||
int ordBreweryName = reader.GetOrdinal("BreweryName");
|
||||
int ordDescription = reader.GetOrdinal("Description");
|
||||
int ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
||||
int ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
||||
int ordTimer = reader.GetOrdinal("Timer");
|
||||
|
||||
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
|
||||
brewery.PostedById = reader.GetGuid(ordPostedById);
|
||||
@@ -172,39 +167,39 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
|
||||
// Read timer (varbinary/rowversion) robustly
|
||||
if (reader.IsDBNull(ordTimer))
|
||||
{
|
||||
brewery.Timer = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var length = reader.GetBytes(ordTimer, 0, null, 0, 0);
|
||||
var buffer = new byte[length];
|
||||
long length = reader.GetBytes(ordTimer, 0, null, 0, 0);
|
||||
byte[] buffer = new byte[length];
|
||||
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
|
||||
brewery.Timer = buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Map BreweryPostLocation if columns are present
|
||||
try
|
||||
{
|
||||
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
||||
int ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
||||
if (!reader.IsDBNull(ordLocationId))
|
||||
{
|
||||
var location = new BreweryPostLocation
|
||||
BreweryPostLocation location = new()
|
||||
{
|
||||
BreweryPostLocationId = reader.GetGuid(ordLocationId),
|
||||
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
|
||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
||||
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
|
||||
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")),
|
||||
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("AddressLine2")),
|
||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
||||
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
|
||||
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates"))
|
||||
? null
|
||||
: reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates")),
|
||||
};
|
||||
brewery.Location = location;
|
||||
}
|
||||
@@ -224,13 +219,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <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="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
private static void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
DbParameter p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
|
||||
@@ -10,9 +10,9 @@ public class SendRegistrationEmailHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToEmailDispatcher()
|
||||
{
|
||||
var dispatcherMock = new Mock<IEmailDispatcher>();
|
||||
var handler = new SendRegistrationEmailHandler(dispatcherMock.Object);
|
||||
var command = new SendRegistrationEmailCommand("Aaron", "aaron@example.com", "token-123");
|
||||
Mock<IEmailDispatcher> dispatcherMock = new();
|
||||
SendRegistrationEmailHandler handler = new(dispatcherMock.Object);
|
||||
SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123");
|
||||
|
||||
await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ public class SendResendConfirmationEmailHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToEmailDispatcher()
|
||||
{
|
||||
var dispatcherMock = new Mock<IEmailDispatcher>();
|
||||
var handler = new SendResendConfirmationEmailHandler(dispatcherMock.Object);
|
||||
var command = new SendResendConfirmationEmailCommand("Aaron", "aaron@example.com", "token-456");
|
||||
Mock<IEmailDispatcher> dispatcherMock = new();
|
||||
SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object);
|
||||
SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456");
|
||||
|
||||
await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ namespace Features.Emails.Commands.SendRegistrationEmail;
|
||||
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
: IRequestHandler<SendRegistrationEmailCommand>
|
||||
{
|
||||
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken) =>
|
||||
emailDispatcher.SendRegistrationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
|
||||
public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return emailDispatcher.SendRegistrationEmailAsync(
|
||||
request.FirstName,
|
||||
request.Email,
|
||||
request.ConfirmationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ namespace Features.Emails.Commands.SendResendConfirmationEmail;
|
||||
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
: IRequestHandler<SendResendConfirmationEmailCommand>
|
||||
{
|
||||
public Task Handle(SendResendConfirmationEmailCommand request, CancellationToken cancellationToken) =>
|
||||
emailDispatcher.SendResendConfirmationEmailAsync(request.FirstName, request.Email, request.ConfirmationToken);
|
||||
public Task Handle(
|
||||
SendResendConfirmationEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return emailDispatcher.SendResendConfirmationEmailAsync(
|
||||
request.FirstName,
|
||||
request.Email,
|
||||
request.ConfirmationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,36 +29,35 @@ public class EmailDispatcher(
|
||||
/// Builds a confirmation link from the given token, renders the registration welcome email template,
|
||||
/// and sends it to the newly created user.
|
||||
/// </summary>
|
||||
public async Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
|
||||
public async Task SendRegistrationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
var emailHtml =
|
||||
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
string emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(
|
||||
email,
|
||||
"Welcome to The Biergarten App!",
|
||||
emailHtml,
|
||||
isHtml: true
|
||||
);
|
||||
await emailProvider.SendAsync(email, "Welcome to The Biergarten App!", emailHtml, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a confirmation link from the given token, renders the resend-confirmation email template,
|
||||
/// and sends it to the user.
|
||||
/// </summary>
|
||||
public async Task SendResendConfirmationEmailAsync(string firstName, string email, string confirmationToken)
|
||||
public async Task SendResendConfirmationEmailAsync(
|
||||
string firstName,
|
||||
string email,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
string confirmationLink = $"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
var emailHtml =
|
||||
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||
string emailHtml = await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||
firstName,
|
||||
confirmationLink
|
||||
);
|
||||
@@ -67,7 +66,7 @@ public class EmailDispatcher(
|
||||
email,
|
||||
"Confirm Your Email - The Biergarten App",
|
||||
emailHtml,
|
||||
isHtml: true
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ public class UpdateUserHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToRepository()
|
||||
{
|
||||
var repoMock = new Mock<IUserAccountRepository>();
|
||||
var handler = new UpdateUserHandler(repoMock.Object);
|
||||
var user = new UserAccount { UserAccountId = Guid.NewGuid() };
|
||||
Mock<IUserAccountRepository> repoMock = new();
|
||||
UpdateUserHandler handler = new(repoMock.Object);
|
||||
UserAccount user = new() { UserAccountId = Guid.NewGuid() };
|
||||
repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask);
|
||||
|
||||
await handler.Handle(new UpdateUserCommand(user), CancellationToken.None);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Domain.Entities;
|
||||
using FluentAssertions;
|
||||
using Features.UserManagement.Queries.GetAllUsers;
|
||||
using Features.UserManagement.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.UserManagement.Tests.Queries;
|
||||
@@ -11,11 +11,14 @@ public class GetAllUsersHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
var repoMock = new Mock<IUserAccountRepository>();
|
||||
var handler = new GetAllUsersHandler(repoMock.Object);
|
||||
Mock<IUserAccountRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
var result = await handler.Handle(new GetAllUsersQuery(10, 5), CancellationToken.None);
|
||||
IEnumerable<UserAccount> result = await handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Features.UserManagement.Queries.GetUserById;
|
||||
using Features.UserManagement.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.UserManagement.Tests.Queries;
|
||||
|
||||
public class GetUserByIdHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserAccountRepository> _repoMock = new();
|
||||
private readonly GetUserByIdHandler _handler;
|
||||
private readonly Mock<IUserAccountRepository> _repoMock = new();
|
||||
|
||||
public GetUserByIdHandlerTests()
|
||||
{
|
||||
@@ -20,10 +20,13 @@ public class GetUserByIdHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsUser_WhenFound()
|
||||
{
|
||||
var user = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "test" };
|
||||
UserAccount user = new() { UserAccountId = Guid.NewGuid(), Username = "test" };
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
var result = await _handler.Handle(new GetUserByIdQuery(user.UserAccountId), CancellationToken.None);
|
||||
UserAccount result = await _handler.Handle(
|
||||
new GetUserByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().Be(user);
|
||||
}
|
||||
@@ -31,10 +34,11 @@ public class GetUserByIdHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_Throws_WhenNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
Func<Task<UserAccount>> act = async () =>
|
||||
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@ internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
private readonly DbConnection _conn = conn;
|
||||
|
||||
public DbConnection CreateConnection() => _conn;
|
||||
public DbConnection CreateConnection()
|
||||
{
|
||||
return _conn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Domain.Entities;
|
||||
using Features.UserManagement.Repository;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Features.UserManagement.Tests.Repository;
|
||||
|
||||
public class UserAccountRepositoryTests
|
||||
{
|
||||
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
private static UserAccountRepository CreateRepo(MockDbConnection conn)
|
||||
{
|
||||
return new UserAccountRepository(new TestConnectionFactory(conn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsRow_Mapped()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
@@ -40,8 +43,8 @@ public class UserAccountRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(
|
||||
UserAccountRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetByIdAsync(
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
);
|
||||
|
||||
@@ -53,7 +56,7 @@ public class UserAccountRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsMultipleRows()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
@@ -92,22 +95,17 @@ public class UserAccountRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var results = (await repo.GetAllAsync(null, null)).ToList();
|
||||
UserAccountRepository repo = CreateRepo(conn);
|
||||
List<UserAccount> results = (await repo.GetAllAsync(null, null)).ToList();
|
||||
results.Should().HaveCount(2);
|
||||
results
|
||||
.Select(r => r.Username)
|
||||
.Should()
|
||||
.BeEquivalentTo(new[] { "a", "b" });
|
||||
results.Select(r => r.Username).Should().BeEquivalentTo("a", "b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByUsername_ReturnsRow()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
@@ -134,8 +132,8 @@ public class UserAccountRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByUsernameAsync("lookupuser");
|
||||
UserAccountRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetByUsernameAsync("lookupuser");
|
||||
result.Should().NotBeNull();
|
||||
result!.Email.Should().Be("lookup@example.com");
|
||||
}
|
||||
@@ -143,7 +141,7 @@ public class UserAccountRepositoryTests
|
||||
[Fact]
|
||||
public async Task GetByEmail_ReturnsRow()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
MockDbConnection conn = new();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
@@ -171,8 +169,8 @@ public class UserAccountRepositoryTests
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByEmailAsync("byemail@example.com");
|
||||
UserAccountRepository repo = CreateRepo(conn);
|
||||
UserAccount? result = await repo.GetByEmailAsync("byemail@example.com");
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("byemail");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace Features.UserManagement.Commands.UpdateUser;
|
||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<UpdateUserCommand>
|
||||
{
|
||||
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken) =>
|
||||
repository.UpdateAsync(request.UserAccount);
|
||||
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return repository.UpdateAsync(request.UserAccount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ using Features.UserManagement.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Features.UserManagement.Controllers
|
||||
{
|
||||
namespace Features.UserManagement.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read-only endpoints for retrieving user accounts.
|
||||
/// </summary>
|
||||
@@ -26,7 +26,7 @@ namespace Features.UserManagement.Controllers
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
var users = await mediator.Send(new GetAllUsersQuery(limit, offset));
|
||||
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@ namespace Features.UserManagement.Controllers
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
var user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(GetAllUsersQuery request, CancellationToken cancellationToken) =>
|
||||
repository.GetAllAsync(request.Limit, request.Offset);
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return repository.GetAllAsync(request.Limit, request.Offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||
{
|
||||
/// <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)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user