feat(wbs): AEG-VS-01-04 BE Vertical Slice - Endpoints & Handlers (Part 1)
- RegisterIdentity endpoint (POST /api/identities)
- RequestMfaSetup endpoint (PUT /api/identities/{id}/request-mfa)
- SQL repositories w/ optimistic concurrency (revision tracking)
- Application handlers (IEndpointHandler pattern)
- ValidationException + ProblemDetails error handling
- Unit tests: RegisterIdentityHandlerTests (4), RequestMfaSetupHandlerTests (4)
- Domain state machines integrated (IdentityState lifecycle)
- AGENTS.md v16.0: endpoint authority, idempotency, correlation ID ready
DI registration & integration tests deferred to next session.
17 new files, 500+ LOC, 8/8 unit tests ready to run
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
public sealed class RegisterIdentityEndpoint : Endpoint<RegisterIdentityRequest, RegisterIdentityResponse>
|
||||||
|
{
|
||||||
|
private readonly RegisterIdentityHandler _handler;
|
||||||
|
|
||||||
|
public RegisterIdentityEndpoint(RegisterIdentityHandler handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post("/api/identities");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _handler.HandleAsync(req, ct);
|
||||||
|
await SendAsync(response, 201, ct);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
await SendProblemDetailsAsync(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Validation Error",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
}, StatusCodes.Status400BadRequest, cancellation: ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
await SendProblemDetailsAsync(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Operation Error",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
}, StatusCodes.Status409Conflict, cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
public sealed class RegisterIdentityHandler : IEndpointHandler<RegisterIdentityRequest, RegisterIdentityResponse>
|
||||||
|
{
|
||||||
|
private readonly IRegisterIdentitySql _sql;
|
||||||
|
|
||||||
|
public RegisterIdentityHandler(IRegisterIdentitySql sql)
|
||||||
|
{
|
||||||
|
_sql = sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RegisterIdentityResponse> HandleAsync(RegisterIdentityRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var email = req.Email.Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
|
||||||
|
throw new ValidationException("Invalid email format");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(req.DisplayName) || req.DisplayName.Length > 255)
|
||||||
|
throw new ValidationException("Display name required, max 255 characters");
|
||||||
|
|
||||||
|
var emailExists = await _sql.EmailExistsAsync(email, ct);
|
||||||
|
if (emailExists)
|
||||||
|
throw new ValidationException("Email already registered");
|
||||||
|
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var state = IdentityState.CreateUndefined();
|
||||||
|
var registered = state.Register();
|
||||||
|
|
||||||
|
var createdId = await _sql.CreateIdentityAsync(identityId, email, req.DisplayName, correlationId, ct);
|
||||||
|
if (createdId == Guid.Empty)
|
||||||
|
throw new InvalidOperationException("Failed to create identity");
|
||||||
|
|
||||||
|
var (id, returnedEmail, displayName, currentState) = await _sql.GetIdentityAsync(createdId, ct);
|
||||||
|
|
||||||
|
return new RegisterIdentityResponse
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Email = returnedEmail,
|
||||||
|
State = currentState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
public sealed record RegisterIdentityRequest
|
||||||
|
{
|
||||||
|
public required string Email { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
public sealed record RegisterIdentityResponse
|
||||||
|
{
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public required string Email { get; init; }
|
||||||
|
public required string State { get; init; }
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
public interface IRegisterIdentitySql
|
||||||
|
{
|
||||||
|
Task<bool> EmailExistsAsync(string email, CancellationToken ct);
|
||||||
|
Task<Guid> CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct);
|
||||||
|
Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RegisterIdentitySql : IRegisterIdentitySql
|
||||||
|
{
|
||||||
|
private readonly Func<Task<NpgsqlConnection>> _connectionFactory;
|
||||||
|
|
||||||
|
public RegisterIdentitySql(Func<Task<NpgsqlConnection>> connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var conn = await _connectionFactory();
|
||||||
|
const string sql = """
|
||||||
|
SELECT EXISTS(SELECT 1 FROM identity.identity WHERE email = @email)
|
||||||
|
""";
|
||||||
|
return await conn.QuerySingleAsync<bool>(sql, new { email }, commandTimeout: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> CreateIdentityAsync(Guid id, string email, string displayName, string correlationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var conn = await _connectionFactory();
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO identity.identity (id, email, display_name, state, created_at, updated_at, published_at, revision_version, correlation_id)
|
||||||
|
VALUES (@id, @email, @displayName, @state, NOW(), NOW(), NOW(), 1, @correlationId)
|
||||||
|
ON CONFLICT (email) DO NOTHING
|
||||||
|
RETURNING id;
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = await conn.QuerySingleOrDefaultAsync<Guid?>(sql, new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
state = Domain.IdentityState.Active,
|
||||||
|
correlationId
|
||||||
|
}, commandTimeout: 5);
|
||||||
|
|
||||||
|
return result ?? Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Guid Id, string Email, string DisplayName, string State)> GetIdentityAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var conn = await _connectionFactory();
|
||||||
|
const string sql = """
|
||||||
|
SELECT id, email, display_name, state
|
||||||
|
FROM identity.identity
|
||||||
|
WHERE id = @id
|
||||||
|
""";
|
||||||
|
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { id }, commandTimeout: 5);
|
||||||
|
if (row is null)
|
||||||
|
throw new InvalidOperationException($"Identity {id} not found");
|
||||||
|
|
||||||
|
return ((Guid)row.id, (string)row.email, (string)row.display_name, (string)row.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
|
public sealed class RequestMfaSetupEndpoint : Endpoint<RequestMfaSetupRequest, RequestMfaSetupResponse>
|
||||||
|
{
|
||||||
|
private readonly RequestMfaSetupHandler _handler;
|
||||||
|
|
||||||
|
public RequestMfaSetupEndpoint(RequestMfaSetupHandler handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Put("/api/identities/{identityId:guid}/request-mfa");
|
||||||
|
AllowAnonymous();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task HandleAsync(RequestMfaSetupRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _handler.HandleAsync(req, ct);
|
||||||
|
await SendAsync(response, 200, ct);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
await SendProblemDetailsAsync(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Validation Error",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status400BadRequest
|
||||||
|
}, StatusCodes.Status400BadRequest, cancellation: ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
await SendProblemDetailsAsync(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "Operation Error",
|
||||||
|
Detail = ex.Message,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
}, StatusCodes.Status409Conflict, cancellation: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using FastEndpoints;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
|
public sealed class RequestMfaSetupHandler : IEndpointHandler<RequestMfaSetupRequest, RequestMfaSetupResponse>
|
||||||
|
{
|
||||||
|
private readonly IRequestMfaSetupSql _sql;
|
||||||
|
|
||||||
|
public RequestMfaSetupHandler(IRequestMfaSetupSql sql)
|
||||||
|
{
|
||||||
|
_sql = sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RequestMfaSetupResponse> HandleAsync(RequestMfaSetupRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (req.IdentityId == Guid.Empty)
|
||||||
|
throw new ValidationException("Identity ID required");
|
||||||
|
|
||||||
|
var (identityId, currentState, revision) = await _sql.GetIdentityAsync(req.IdentityId, ct);
|
||||||
|
|
||||||
|
var state = IdentityState.Parse(currentState);
|
||||||
|
var nextState = state.RequestMfaSetup();
|
||||||
|
|
||||||
|
await _sql.UpdateIdentityStateAsync(identityId, nextState.Value, revision, ct);
|
||||||
|
|
||||||
|
return new RequestMfaSetupResponse
|
||||||
|
{
|
||||||
|
IdentityId = identityId,
|
||||||
|
PreviousState = currentState,
|
||||||
|
NewState = nextState.Value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
|
public sealed record RequestMfaSetupRequest
|
||||||
|
{
|
||||||
|
public required Guid IdentityId { get; init; }
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
|
public sealed record RequestMfaSetupResponse
|
||||||
|
{
|
||||||
|
public required Guid IdentityId { get; init; }
|
||||||
|
public required string PreviousState { get; init; }
|
||||||
|
public required string NewState { get; init; }
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
|
||||||
|
public interface IRequestMfaSetupSql
|
||||||
|
{
|
||||||
|
Task<(Guid Id, string State, int Revision)> GetIdentityAsync(Guid identityId, CancellationToken ct);
|
||||||
|
Task UpdateIdentityStateAsync(Guid identityId, string newState, int expectedRevision, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RequestMfaSetupSql : IRequestMfaSetupSql
|
||||||
|
{
|
||||||
|
private readonly Func<Task<NpgsqlConnection>> _connectionFactory;
|
||||||
|
|
||||||
|
public RequestMfaSetupSql(Func<Task<NpgsqlConnection>> connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Guid Id, string State, int Revision)> GetIdentityAsync(Guid identityId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var conn = await _connectionFactory();
|
||||||
|
const string sql = """
|
||||||
|
SELECT id, state, revision_version
|
||||||
|
FROM identity.identity
|
||||||
|
WHERE id = @identityId
|
||||||
|
""";
|
||||||
|
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { identityId }, commandTimeout: 5);
|
||||||
|
if (row is null)
|
||||||
|
throw new InvalidOperationException($"Identity {identityId} not found");
|
||||||
|
|
||||||
|
return ((Guid)row.id, (string)row.state, (int)row.revision_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateIdentityStateAsync(Guid identityId, string newState, int expectedRevision, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var conn = await _connectionFactory();
|
||||||
|
const string sql = """
|
||||||
|
UPDATE identity.identity
|
||||||
|
SET state = @newState,
|
||||||
|
revision_version = revision_version + 1,
|
||||||
|
updated_at = NOW(),
|
||||||
|
published_at = NOW()
|
||||||
|
WHERE id = @identityId AND revision_version = @expectedRevision
|
||||||
|
""";
|
||||||
|
|
||||||
|
var rowsAffected = await conn.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
identityId,
|
||||||
|
newState,
|
||||||
|
expectedRevision
|
||||||
|
}, commandTimeout: 5);
|
||||||
|
|
||||||
|
if (rowsAffected == 0)
|
||||||
|
throw new InvalidOperationException("Optimistic concurrency violation: state changed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles;
|
||||||
|
|
||||||
|
public sealed class ValidationException : Exception
|
||||||
|
{
|
||||||
|
public ValidationException(string message) : base(message) { }
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RegisterIdentity;
|
||||||
|
|
||||||
|
namespace KArtSell.IdentityAccess.UnitTests.ManageIdentityAndRoles;
|
||||||
|
|
||||||
|
public class RegisterIdentityHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IRegisterIdentitySql> _sqlMock;
|
||||||
|
private readonly RegisterIdentityHandler _handler;
|
||||||
|
|
||||||
|
public RegisterIdentityHandlerTests()
|
||||||
|
{
|
||||||
|
_sqlMock = new Mock<IRegisterIdentitySql>();
|
||||||
|
_handler = new RegisterIdentityHandler(_sqlMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_ValidRequest_CreatesIdentity()
|
||||||
|
{
|
||||||
|
var request = new RegisterIdentityRequest
|
||||||
|
{
|
||||||
|
Email = "test@example.com",
|
||||||
|
DisplayName = "Test User"
|
||||||
|
};
|
||||||
|
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
_sqlMock.Setup(s => s.EmailExistsAsync("test@example.com", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
_sqlMock.Setup(s => s.CreateIdentityAsync(It.IsAny<Guid>(), "test@example.com", "Test User", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(identityId);
|
||||||
|
_sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((identityId, "test@example.com", "Test User", Domain.IdentityState.Active));
|
||||||
|
|
||||||
|
var response = await _handler.HandleAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(response);
|
||||||
|
Assert.Equal(identityId, response.Id);
|
||||||
|
Assert.Equal("test@example.com", response.Email);
|
||||||
|
Assert.Equal(Domain.IdentityState.Active, response.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_InvalidEmail_ThrowsValidationException()
|
||||||
|
{
|
||||||
|
var request = new RegisterIdentityRequest
|
||||||
|
{
|
||||||
|
Email = "invalid-email",
|
||||||
|
DisplayName = "Test User"
|
||||||
|
};
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_EmailExists_ThrowsValidationException()
|
||||||
|
{
|
||||||
|
var request = new RegisterIdentityRequest
|
||||||
|
{
|
||||||
|
Email = "existing@example.com",
|
||||||
|
DisplayName = "Test User"
|
||||||
|
};
|
||||||
|
|
||||||
|
_sqlMock.Setup(s => s.EmailExistsAsync("existing@example.com", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_EmptyDisplayName_ThrowsValidationException()
|
||||||
|
{
|
||||||
|
var request = new RegisterIdentityRequest
|
||||||
|
{
|
||||||
|
Email = "test@example.com",
|
||||||
|
DisplayName = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Features.RequestMfaSetup;
|
||||||
|
using KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
|
||||||
|
|
||||||
|
namespace KArtSell.IdentityAccess.UnitTests.ManageIdentityAndRoles;
|
||||||
|
|
||||||
|
public class RequestMfaSetupHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IRequestMfaSetupSql> _sqlMock;
|
||||||
|
private readonly RequestMfaSetupHandler _handler;
|
||||||
|
|
||||||
|
public RequestMfaSetupHandlerTests()
|
||||||
|
{
|
||||||
|
_sqlMock = new Mock<IRequestMfaSetupSql>();
|
||||||
|
_handler = new RequestMfaSetupHandler(_sqlMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_ActiveIdentity_TransitionsToRequiresMfaSetup()
|
||||||
|
{
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
var request = new RequestMfaSetupRequest { IdentityId = identityId };
|
||||||
|
|
||||||
|
_sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((identityId, IdentityState.Active, 1));
|
||||||
|
_sqlMock.Setup(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var response = await _handler.HandleAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(identityId, response.IdentityId);
|
||||||
|
Assert.Equal(IdentityState.Active, response.PreviousState);
|
||||||
|
Assert.Equal(IdentityState.RequiresMfaSetup, response.NewState);
|
||||||
|
|
||||||
|
_sqlMock.Verify(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_EmptyIdentityId_ThrowsValidationException()
|
||||||
|
{
|
||||||
|
var request = new RequestMfaSetupRequest { IdentityId = Guid.Empty };
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_IdentityNotFound_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
var request = new RequestMfaSetupRequest { IdentityId = identityId };
|
||||||
|
|
||||||
|
_sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Identity not found"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_OptimisticConcurrencyViolation_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
var identityId = Guid.NewGuid();
|
||||||
|
var request = new RequestMfaSetupRequest { IdentityId = identityId };
|
||||||
|
|
||||||
|
_sqlMock.Setup(s => s.GetIdentityAsync(identityId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((identityId, IdentityState.Active, 1));
|
||||||
|
_sqlMock.Setup(s => s.UpdateIdentityStateAsync(identityId, IdentityState.RequiresMfaSetup, 1, It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Optimistic concurrency violation: state changed"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _handler.HandleAsync(request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user