Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions tests/Orbit.Application.Tests/Behaviors/ValidationBehaviorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using MediatR;
using NSubstitute;
using Orbit.Application.Behaviors;

namespace Orbit.Application.Tests.Behaviors;

public record ValidationTestRequest(string Name) : IRequest<string>;

public class ValidationBehaviorTests
{
private readonly RequestHandlerDelegate<string> _next = Substitute.For<RequestHandlerDelegate<string>>();

public ValidationBehaviorTests()
{
_next.Invoke().Returns("success");
}

[Fact]
public async Task Handle_NoValidators_CallsNext()
{
// Arrange
var validators = Enumerable.Empty<IValidator<ValidationTestRequest>>();
var behavior = new ValidationBehavior<ValidationTestRequest, string>(validators);
var request = new ValidationTestRequest("test");

// Act
var result = await behavior.Handle(request, _next, CancellationToken.None);

// Assert
result.Should().Be("success");
await _next.Received(1).Invoke();
}

[Fact]
public async Task Handle_ValidInput_CallsNext()
{
// Arrange
var validator = Substitute.For<IValidator<ValidationTestRequest>>();
validator.ValidateAsync(Arg.Any<ValidationContext<ValidationTestRequest>>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult());

var behavior = new ValidationBehavior<ValidationTestRequest, string>(new[] { validator });
var request = new ValidationTestRequest("valid");

// Act
var result = await behavior.Handle(request, _next, CancellationToken.None);

// Assert
result.Should().Be("success");
await _next.Received(1).Invoke();
}

[Fact]
public async Task Handle_InvalidInput_ThrowsValidationException()
{
// Arrange
var validator = Substitute.For<IValidator<ValidationTestRequest>>();
var failures = new List<ValidationFailure>
{
new("Name", "Name is required")
};
validator.ValidateAsync(Arg.Any<ValidationContext<ValidationTestRequest>>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult(failures));

var behavior = new ValidationBehavior<ValidationTestRequest, string>(new[] { validator });
var request = new ValidationTestRequest("");

// Act
var act = () => behavior.Handle(request, _next, CancellationToken.None);

// Assert
var ex = await act.Should().ThrowAsync<ValidationException>();
ex.Which.Errors.Should().HaveCount(1);
await _next.DidNotReceive().Invoke();
}

[Fact]
public async Task Handle_MultipleFailures_ThrowsAllErrors()
{
// Arrange
var validator1 = Substitute.For<IValidator<ValidationTestRequest>>();
validator1.ValidateAsync(Arg.Any<ValidationContext<ValidationTestRequest>>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult(new[]
{
new ValidationFailure("Name", "Name is required")
}));

var validator2 = Substitute.For<IValidator<ValidationTestRequest>>();
validator2.ValidateAsync(Arg.Any<ValidationContext<ValidationTestRequest>>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult(new[]
{
new ValidationFailure("Name", "Name is too short")
}));

var behavior = new ValidationBehavior<ValidationTestRequest, string>(new[] { validator1, validator2 });
var request = new ValidationTestRequest("");

// Act
var act = () => behavior.Handle(request, _next, CancellationToken.None);

// Assert
var ex = await act.Should().ThrowAsync<ValidationException>();
ex.Which.Errors.Should().HaveCount(2);
await _next.DidNotReceive().Invoke();
}
}
173 changes: 173 additions & 0 deletions tests/Orbit.Application.Tests/Commands/Auth/AuthCommandHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using Orbit.Application.Auth.Commands;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Tests.Commands.Auth;

public class AuthCommandHandlerTests
{
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private readonly IEmailService _emailService = Substitute.For<IEmailService>();
private readonly IGenericRepository<User> _userRepo = Substitute.For<IGenericRepository<User>>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly ITokenService _tokenService = Substitute.For<ITokenService>();

private const string TestEmail = "test@example.com";

// ===== SendCode =====

[Fact]
public async Task SendCode_Valid_CachesCodeAndSendsEmail()
{
var handler = new SendCodeCommandHandler(_cache, _emailService);
var command = new SendCodeCommand(TestEmail);

var result = await handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
// Verify code was cached
_cache.TryGetValue($"verify:{TestEmail}", out VerificationEntry? entry).Should().BeTrue();
entry.Should().NotBeNull();
entry!.Code.Should().HaveLength(6);
entry.Attempts.Should().Be(0);
// Verify email was sent
await _emailService.Received(1).SendVerificationCodeAsync(
TestEmail, Arg.Any<string>(), "en", Arg.Any<CancellationToken>());
}

[Fact]
public async Task SendCode_RateLimit_ReturnsFailure()
{
// Pre-populate cache with a recent entry (less than 60s ago)
var existingEntry = new VerificationEntry("123456", 0, DateTime.UtcNow);
_cache.Set($"verify:{TestEmail}", existingEntry,
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });

var handler = new SendCodeCommandHandler(_cache, _emailService);
var command = new SendCodeCommand(TestEmail);

var result = await handler.Handle(command, CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("wait");
await _emailService.DidNotReceive().SendVerificationCodeAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}

// ===== VerifyCode =====

[Fact]
public async Task VerifyCode_Valid_ReturnsLoginResponse()
{
var user = User.Create("Test", TestEmail).Value;
SetupCacheWithCode("123456");
SetupExistingUser(user);
_tokenService.GenerateToken(Arg.Any<Guid>(), Arg.Any<string>())
.Returns("jwt-token");

var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _tokenService, _emailService);
var command = new VerifyCodeCommand(TestEmail, "123456");

var result = await handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Token.Should().Be("jwt-token");
result.Value.Email.Should().Be(TestEmail);
// Cache should be cleared after successful verification
_cache.TryGetValue($"verify:{TestEmail}", out _).Should().BeFalse();
}

[Fact]
public async Task VerifyCode_WrongCode_ReturnsFailure()
{
SetupCacheWithCode("123456");

var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _tokenService, _emailService);
var command = new VerifyCodeCommand(TestEmail, "999999");

var result = await handler.Handle(command, CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Invalid");
// Attempt count should have incremented
_cache.TryGetValue($"verify:{TestEmail}", out VerificationEntry? entry).Should().BeTrue();
entry!.Attempts.Should().Be(1);
}

[Fact]
public async Task VerifyCode_MaxAttempts_ReturnsFailure()
{
// Set up entry with 3 attempts already (max reached)
var entry = new VerificationEntry("123456", 3, DateTime.UtcNow);
_cache.Set($"verify:{TestEmail}", entry,
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });

var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _tokenService, _emailService);
var command = new VerifyCodeCommand(TestEmail, "123456");

var result = await handler.Handle(command, CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Too many attempts");
// Cache entry should be removed
_cache.TryGetValue($"verify:{TestEmail}", out _).Should().BeFalse();
}

[Fact]
public async Task VerifyCode_NewUser_CreatesAccount()
{
SetupCacheWithCode("123456");
// No existing user
_userRepo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new List<User>());
_tokenService.GenerateToken(Arg.Any<Guid>(), Arg.Any<string>())
.Returns("jwt-token");

var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _tokenService, _emailService);
var command = new VerifyCodeCommand(TestEmail, "123456");

var result = await handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
await _userRepo.Received(1).AddAsync(Arg.Any<User>(), Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task VerifyCode_ExistingUser_ReturnsToken()
{
var user = User.Create("Existing", TestEmail).Value;
SetupCacheWithCode("123456");
SetupExistingUser(user);
_tokenService.GenerateToken(Arg.Any<Guid>(), Arg.Any<string>())
.Returns("jwt-token");

var handler = new VerifyCodeCommandHandler(_cache, _userRepo, _unitOfWork, _tokenService, _emailService);
var command = new VerifyCodeCommand(TestEmail, "123456");

var result = await handler.Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Token.Should().Be("jwt-token");
// Should NOT create a new user
await _userRepo.DidNotReceive().AddAsync(Arg.Any<User>(), Arg.Any<CancellationToken>());
}

// ----- Helpers -----

private void SetupCacheWithCode(string code)
{
var entry = new VerificationEntry(code, 0, DateTime.UtcNow);
_cache.Set($"verify:{TestEmail}", entry,
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
}

private void SetupExistingUser(User user)
{
_userRepo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new List<User> { user });
}
}
Loading