From a3ec7d63f258f6c95b7afbca85b094495bf57ca4 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 6 Sep 2026 17:48:03 -0400 Subject: [PATCH] Add validator tests for brewery and user commands (#282) --- .../Commands/CreateBreweryValidatorTests.cs | 171 +++++++++++++ .../Commands/UpdateBreweryValidatorTests.cs | 32 +++ .../Features.Breweries.Tests.csproj | 1 + ...eweryLocationsWithinRangeValidatorTests.cs | 109 ++++++++ .../CreateBrewery/CreateBreweryValidator.cs | 11 +- .../Commands/LoginValidatorTests.cs | 39 +++ .../Commands/RefreshTokenValidatorTests.cs | 31 +++ .../Commands/RegisterUserValidatorTests.cs | 241 ++++++++++++++++++ .../Commands/UpdateEmailValidatorTests.cs | 54 ++++ .../Commands/UpdatePasswordValidatorTests.cs | 122 +++++++++ .../Commands/UpdateProfileValidatorTests.cs | 97 +++++++ .../Commands/UpdateUsernameValidatorTests.cs | 71 ++++++ .../Features.Users.Tests.csproj | 1 + 13 files changed, 977 insertions(+), 3 deletions(-) create mode 100644 web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryValidatorTests.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryValidatorTests.cs create mode 100644 web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/LoginValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/RefreshTokenValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/RegisterUserValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/UpdateEmailValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/UpdateProfileValidatorTests.cs create mode 100644 web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameValidatorTests.cs diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryValidatorTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryValidatorTests.cs new file mode 100644 index 00000000..71d34003 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Commands/CreateBreweryValidatorTests.cs @@ -0,0 +1,171 @@ +using Features.Breweries.Commands.CreateBrewery; +using FluentValidation.TestHelper; + +namespace Features.Breweries.Tests.Commands; + +public class CreateBreweryValidatorTests +{ + private readonly CreateBreweryValidator _validator = new(); + + private static CreateBreweryLocation ValidLocation() => + new(Guid.NewGuid(), "123 Main St", null, "12345", null); + + private static CreateBreweryCommand ValidCommand() => + new(Guid.NewGuid(), "Valid Brewery", "A valid description.", ValidLocation()); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyPostedById_HasErrorForPostedById() + { + CreateBreweryCommand command = ValidCommand() with { PostedById = Guid.Empty }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.PostedById) + .WithErrorMessage("PostedById is required."); + } + + [Fact] + public void Validate_EmptyBreweryName_HasErrorForBreweryName() + { + CreateBreweryCommand command = ValidCommand() with { BreweryName = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.BreweryName) + .WithErrorMessage("Brewery name is required."); + } + + [Fact] + public void Validate_BreweryNameTooLong_HasErrorForBreweryName() + { + CreateBreweryCommand command = ValidCommand() with { BreweryName = new string('a', 257) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.BreweryName) + .WithErrorMessage("Brewery name cannot exceed 256 characters."); + } + + [Fact] + public void Validate_EmptyDescription_HasErrorForDescription() + { + CreateBreweryCommand command = ValidCommand() with { Description = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Description) + .WithErrorMessage("Description is required."); + } + + [Fact] + public void Validate_DescriptionTooLong_HasErrorForDescription() + { + CreateBreweryCommand command = ValidCommand() with { Description = new string('a', 513) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Description) + .WithErrorMessage("Description cannot exceed 512 characters."); + } + + [Fact] + public void Validate_NullLocation_HasErrorForLocationOnly() + { + CreateBreweryCommand command = ValidCommand() with { Location = null! }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Location).WithErrorMessage("Location is required."); + result.ShouldNotHaveValidationErrorFor("Location.CityId"); + result.ShouldNotHaveValidationErrorFor("Location.AddressLine1"); + result.ShouldNotHaveValidationErrorFor("Location.PostalCode"); + } + + [Fact] + public void Validate_EmptyCityId_HasErrorForCityId() + { + CreateBreweryCommand command = ValidCommand() with + { + Location = ValidLocation() with { CityId = Guid.Empty } + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Location.CityId) + .WithErrorMessage("CityId is required."); + } + + [Fact] + public void Validate_EmptyAddressLine1_HasErrorForAddressLine1() + { + CreateBreweryCommand command = ValidCommand() with + { + Location = ValidLocation() with { AddressLine1 = "" } + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Location.AddressLine1) + .WithErrorMessage("Address line 1 is required."); + } + + [Fact] + public void Validate_AddressLine1TooLong_HasErrorForAddressLine1() + { + CreateBreweryCommand command = ValidCommand() with + { + Location = ValidLocation() with { AddressLine1 = new string('a', 257) } + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Location.AddressLine1) + .WithErrorMessage("Address line 1 cannot exceed 256 characters."); + } + + [Fact] + public void Validate_EmptyPostalCode_HasErrorForPostalCode() + { + CreateBreweryCommand command = ValidCommand() with + { + Location = ValidLocation() with { PostalCode = "" } + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Location.PostalCode) + .WithErrorMessage("Postal code is required."); + } + + [Fact] + public void Validate_PostalCodeTooLong_HasErrorForPostalCode() + { + CreateBreweryCommand command = ValidCommand() with + { + Location = ValidLocation() with { PostalCode = new string('1', 21) } + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Location.PostalCode) + .WithErrorMessage("Postal code cannot exceed 20 characters."); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryValidatorTests.cs b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryValidatorTests.cs new file mode 100644 index 00000000..ab5668df --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Commands/UpdateBreweryValidatorTests.cs @@ -0,0 +1,32 @@ +using Features.Breweries.Commands.UpdateBrewery; +using FluentValidation.TestHelper; + +namespace Features.Breweries.Tests.Commands; + +public class UpdateBreweryValidatorTests +{ + private readonly UpdateBreweryValidator _validator = new(); + + private static UpdateBreweryCommand ValidCommand() => + new(Guid.NewGuid(), Guid.NewGuid(), [0x01, 0x02], "Name", "Description", null); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyRowVersion_HasErrorForRowVersion() + { + UpdateBreweryCommand command = ValidCommand() with { RowVersion = [] }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.RowVersion) + .WithErrorMessage("RowVersion is required to detect conflicting concurrent updates."); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj index 5179a631..19aa58e2 100644 --- a/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj +++ b/web/backend/Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeValidatorTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeValidatorTests.cs new file mode 100644 index 00000000..68106c5f --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeValidatorTests.cs @@ -0,0 +1,109 @@ +using Features.Breweries.Queries.GetBreweryLocationsWithinRange; +using FluentValidation.TestHelper; + +namespace Features.Breweries.Tests.Queries; + +public class GetBreweryLocationsWithinRangeValidatorTests +{ + private readonly GetBreweryLocationsWithinRangeValidator _validator = new(); + + private static GetBreweryLocationsWithinRangeQuery ValidQuery() => new(45, -73, 1000); + + [Fact] + public void Validate_ValidQuery_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate( + ValidQuery() + ); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData(-90)] + [InlineData(90)] + public void Validate_LatitudeAtBoundary_HasNoValidationErrorForLatitude(double latitude) + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Latitude = latitude }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result.ShouldNotHaveValidationErrorFor(x => x.Latitude); + } + + [Theory] + [InlineData(90.0001)] + [InlineData(-90.0001)] + public void Validate_LatitudeOutOfRange_HasErrorForLatitude(double latitude) + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Latitude = latitude }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result + .ShouldHaveValidationErrorFor(x => x.Latitude) + .WithErrorMessage("Latitude must be between -90 and 90 degrees."); + } + + [Theory] + [InlineData(-180)] + [InlineData(180)] + public void Validate_LongitudeAtBoundary_HasNoValidationErrorForLongitude(double longitude) + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Longitude = longitude }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result.ShouldNotHaveValidationErrorFor(x => x.Longitude); + } + + [Theory] + [InlineData(180.0001)] + [InlineData(-180.0001)] + public void Validate_LongitudeOutOfRange_HasErrorForLongitude(double longitude) + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Longitude = longitude }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result + .ShouldHaveValidationErrorFor(x => x.Longitude) + .WithErrorMessage("Longitude must be between -180 and 180 degrees."); + } + + [Fact] + public void Validate_RangeInMetresIsZero_HasErrorForRangeInMetres() + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { RangeInMetres = 0 }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result + .ShouldHaveValidationErrorFor(x => x.RangeInMetres) + .WithErrorMessage("RangeInMetres must be greater than zero."); + } + + [Fact] + public void Validate_RangeInMetresIsNegative_HasErrorForRangeInMetres() + { + GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { RangeInMetres = -1 }; + + TestValidationResult result = _validator.TestValidate( + query + ); + + result + .ShouldHaveValidationErrorFor(x => x.RangeInMetres) + .WithErrorMessage("RangeInMetres must be greater than zero."); + } +} diff --git a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs index c9bdeba0..897d8c71 100644 --- a/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs +++ b/web/backend/Features/Features.Breweries/Commands/CreateBrewery/CreateBreweryValidator.cs @@ -25,18 +25,23 @@ public class CreateBreweryValidator : AbstractValidator RuleFor(x => x.Location).NotNull().WithMessage("Location is required."); - RuleFor(x => x.Location.CityId).NotEmpty().WithMessage("CityId is required."); + RuleFor(x => x.Location.CityId) + .NotEmpty() + .WithMessage("CityId is required.") + .When(x => x.Location != null); RuleFor(x => x.Location.AddressLine1) .NotEmpty() .WithMessage("Address line 1 is required.") .MaximumLength(256) - .WithMessage("Address line 1 cannot exceed 256 characters."); + .WithMessage("Address line 1 cannot exceed 256 characters.") + .When(x => x.Location != null); RuleFor(x => x.Location.PostalCode) .NotEmpty() .WithMessage("Postal code is required.") .MaximumLength(20) - .WithMessage("Postal code cannot exceed 20 characters."); + .WithMessage("Postal code cannot exceed 20 characters.") + .When(x => x.Location != null); } } diff --git a/web/backend/Features/Features.Users.Tests/Commands/LoginValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/LoginValidatorTests.cs new file mode 100644 index 00000000..03b49275 --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/LoginValidatorTests.cs @@ -0,0 +1,39 @@ +using Features.Users.Commands.Authentication.Login; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class LoginValidatorTests +{ + private readonly LoginValidator _validator = new(); + + private static LoginCommand ValidCommand() => new("valid.user-99", "Password1!"); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyUsername_HasErrorForUsername() + { + LoginCommand command = ValidCommand() with { Username = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Username).WithErrorMessage("Username is required"); + } + + [Fact] + public void Validate_EmptyPassword_HasErrorForPassword() + { + LoginCommand command = ValidCommand() with { Password = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Password).WithErrorMessage("Password is required"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/RefreshTokenValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RefreshTokenValidatorTests.cs new file mode 100644 index 00000000..77b87e1c --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/RefreshTokenValidatorTests.cs @@ -0,0 +1,31 @@ +using Features.Users.Commands.Authentication.RefreshToken; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class RefreshTokenValidatorTests +{ + private readonly RefreshTokenValidator _validator = new(); + + private static RefreshTokenCommand ValidCommand() => new("a-valid-refresh-token"); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyRefreshToken_HasErrorForRefreshToken() + { + RefreshTokenCommand command = ValidCommand() with { RefreshToken = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.RefreshToken) + .WithErrorMessage("Refresh token is required"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/RegisterUserValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserValidatorTests.cs new file mode 100644 index 00000000..cda0f3da --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserValidatorTests.cs @@ -0,0 +1,241 @@ +using Features.Users.Commands.Authentication.RegisterUser; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class RegisterUserValidatorTests +{ + private readonly RegisterUserValidator _validator = new(); + + private static RegisterUserCommand ValidCommand() => + new( + "valid.user-99", + "Jane", + "Doe", + "jane.doe@example.com", + DateTime.Today.AddYears(-25), + "Password1!" + ); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyUsername_HasErrorForUsername() + { + RegisterUserCommand command = ValidCommand() with { Username = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Username).WithErrorMessage("Username is required"); + } + + [Fact] + public void Validate_UsernameTooShort_HasErrorForUsername() + { + RegisterUserCommand command = ValidCommand() with { Username = "ab" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Username) + .WithErrorMessage("Username must be between 3 and 64 characters"); + } + + [Fact] + public void Validate_UsernameTooLong_HasErrorForUsername() + { + RegisterUserCommand command = ValidCommand() with { Username = new string('a', 65) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Username) + .WithErrorMessage("Username must be between 3 and 64 characters"); + } + + [Fact] + public void Validate_UsernameHasDisallowedCharacters_HasErrorForUsername() + { + RegisterUserCommand command = ValidCommand() with { Username = "bad user!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Username) + .WithErrorMessage( + "Username can only contain letters, numbers, dots, underscores, and hyphens" + ); + } + + [Fact] + public void Validate_EmptyFirstName_HasErrorForFirstName() + { + RegisterUserCommand command = ValidCommand() with { FirstName = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.FirstName) + .WithErrorMessage("First name is required"); + } + + [Fact] + public void Validate_FirstNameTooLong_HasErrorForFirstName() + { + RegisterUserCommand command = ValidCommand() with { FirstName = new string('a', 129) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.FirstName) + .WithErrorMessage("First name cannot exceed 128 characters"); + } + + [Fact] + public void Validate_EmptyLastName_HasErrorForLastName() + { + RegisterUserCommand command = ValidCommand() with { LastName = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.LastName) + .WithErrorMessage("Last name is required"); + } + + [Fact] + public void Validate_LastNameTooLong_HasErrorForLastName() + { + RegisterUserCommand command = ValidCommand() with { LastName = new string('a', 129) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.LastName) + .WithErrorMessage("Last name cannot exceed 128 characters"); + } + + [Fact] + public void Validate_EmptyEmail_HasErrorForEmail() + { + RegisterUserCommand command = ValidCommand() with { Email = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Email).WithErrorMessage("Email is required"); + } + + [Fact] + public void Validate_InvalidEmailFormat_HasErrorForEmail() + { + RegisterUserCommand command = ValidCommand() with { Email = "not-an-email" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Email).WithErrorMessage("Invalid email format"); + } + + [Fact] + public void Validate_EmailTooLong_HasErrorForEmail() + { + string localPart = new('a', 121); + RegisterUserCommand command = ValidCommand() with { Email = $"{localPart}@example.com" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Email) + .WithErrorMessage("Email cannot exceed 128 characters"); + } + + [Fact] + public void Validate_DateOfBirthUnderMinimumAge_HasErrorForDateOfBirth() + { + RegisterUserCommand command = ValidCommand() with + { + DateOfBirth = DateTime.Today.AddYears(-18) + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.DateOfBirth) + .WithErrorMessage("You must be at least 19 years old to register"); + } + + [Fact] + public void Validate_EmptyPassword_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.Password).WithErrorMessage("Password is required"); + } + + [Fact] + public void Validate_PasswordTooShort_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "Ax1!23" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Password must be at least 8 characters"); + } + + [Fact] + public void Validate_PasswordMissingUppercase_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "password1!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Password must contain at least one uppercase letter"); + } + + [Fact] + public void Validate_PasswordMissingLowercase_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "PASSWORD1!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Password must contain at least one lowercase letter"); + } + + [Fact] + public void Validate_PasswordMissingNumber_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "Password!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Password must contain at least one number"); + } + + [Fact] + public void Validate_PasswordMissingSpecialCharacter_HasErrorForPassword() + { + RegisterUserCommand command = ValidCommand() with { Password = "Password1" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Password must contain at least one special character"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/UpdateEmailValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateEmailValidatorTests.cs new file mode 100644 index 00000000..79845850 --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateEmailValidatorTests.cs @@ -0,0 +1,54 @@ +using Features.Users.Commands.Account.UpdateEmail; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class UpdateEmailValidatorTests +{ + private readonly UpdateEmailValidator _validator = new(); + + private static UpdateEmailCommand ValidCommand() => new(Guid.NewGuid(), "user@example.com"); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate(ValidCommand()); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyNewEmail_HasErrorForNewEmail() + { + UpdateEmailCommand command = ValidCommand() with { NewEmail = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.NewEmail).WithErrorMessage("Email is required"); + } + + [Fact] + public void Validate_InvalidEmailFormat_HasErrorForNewEmail() + { + UpdateEmailCommand command = ValidCommand() with { NewEmail = "not-an-email" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewEmail) + .WithErrorMessage("Invalid email format"); + } + + [Fact] + public void Validate_NewEmailTooLong_HasErrorForNewEmail() + { + string localPart = new('a', 120); + UpdateEmailCommand command = ValidCommand() with { NewEmail = $"{localPart}@example.com" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewEmail) + .WithErrorMessage("Email cannot exceed 128 characters"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordValidatorTests.cs new file mode 100644 index 00000000..e050887b --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdatePasswordValidatorTests.cs @@ -0,0 +1,122 @@ +using Features.Users.Commands.Account.UpdatePassword; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class UpdatePasswordValidatorTests +{ + private readonly UpdatePasswordValidator _validator = new(); + + private static UpdatePasswordCommand ValidCommand() => + new(Guid.NewGuid(), "OldPass1!", "NewPass2@"); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate( + ValidCommand() + ); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyCurrentPassword_HasErrorForCurrentPassword() + { + UpdatePasswordCommand command = ValidCommand() with { CurrentPassword = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.CurrentPassword) + .WithErrorMessage("Current password is required"); + } + + [Fact] + public void Validate_EmptyNewPassword_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("New password is required"); + } + + [Fact] + public void Validate_NewPasswordTooShort_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "Ax1!23" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must be at least 8 characters"); + } + + [Fact] + public void Validate_NewPasswordMissingUppercase_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "newpass1!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one uppercase letter"); + } + + [Fact] + public void Validate_NewPasswordMissingLowercase_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "NEWPASS1!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one lowercase letter"); + } + + [Fact] + public void Validate_NewPasswordMissingNumber_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "NewPassword!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one number"); + } + + [Fact] + public void Validate_NewPasswordMissingSpecialCharacter_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with { NewPassword = "NewPassword1" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("Password must contain at least one special character"); + } + + [Fact] + public void Validate_NewPasswordSameAsCurrentPassword_HasErrorForNewPassword() + { + UpdatePasswordCommand command = ValidCommand() with + { + CurrentPassword = "SamePass1!", + NewPassword = "SamePass1!" + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewPassword) + .WithErrorMessage("New password must be different from the current password"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/UpdateProfileValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateProfileValidatorTests.cs new file mode 100644 index 00000000..613cd3da --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateProfileValidatorTests.cs @@ -0,0 +1,97 @@ +using Features.Users.Commands.Profile.UpdateProfile; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class UpdateProfileValidatorTests +{ + private readonly UpdateProfileValidator _validator = new(); + + private static UpdateProfileCommand ValidCommand() => + new(Guid.NewGuid(), "John", "Doe", DateTime.Today.AddYears(-25)); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate( + ValidCommand() + ); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyFirstName_HasErrorForFirstName() + { + UpdateProfileCommand command = ValidCommand() with { FirstName = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.FirstName) + .WithErrorMessage("First name is required"); + } + + [Fact] + public void Validate_FirstNameTooLong_HasErrorForFirstName() + { + UpdateProfileCommand command = ValidCommand() with { FirstName = new string('a', 129) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.FirstName) + .WithErrorMessage("First name cannot exceed 128 characters"); + } + + [Fact] + public void Validate_EmptyLastName_HasErrorForLastName() + { + UpdateProfileCommand command = ValidCommand() with { LastName = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.LastName) + .WithErrorMessage("Last name is required"); + } + + [Fact] + public void Validate_LastNameTooLong_HasErrorForLastName() + { + UpdateProfileCommand command = ValidCommand() with { LastName = new string('a', 129) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.LastName) + .WithErrorMessage("Last name cannot exceed 128 characters"); + } + + [Fact] + public void Validate_DefaultDateOfBirth_HasErrorForDateOfBirth() + { + UpdateProfileCommand command = ValidCommand() with { DateOfBirth = default }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.DateOfBirth) + .WithErrorMessage("Date of birth is required"); + } + + [Fact] + public void Validate_DateOfBirthExactlyNineteenYearsAgo_HasErrorForDateOfBirth() + { + UpdateProfileCommand command = ValidCommand() with + { + DateOfBirth = DateTime.Today.AddYears(-19) + }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.DateOfBirth) + .WithErrorMessage("You must be at least 19 years old"); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameValidatorTests.cs b/web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameValidatorTests.cs new file mode 100644 index 00000000..d81aa0e0 --- /dev/null +++ b/web/backend/Features/Features.Users.Tests/Commands/UpdateUsernameValidatorTests.cs @@ -0,0 +1,71 @@ +using Features.Users.Commands.Account.UpdateUsername; +using FluentValidation.TestHelper; + +namespace Features.Users.Tests.Commands; + +public class UpdateUsernameValidatorTests +{ + private readonly UpdateUsernameValidator _validator = new(); + + private static UpdateUsernameCommand ValidCommand() => new(Guid.NewGuid(), "valid.user-99"); + + [Fact] + public void Validate_ValidCommand_HasNoValidationErrors() + { + TestValidationResult result = _validator.TestValidate( + ValidCommand() + ); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyNewUsername_HasErrorForNewUsername() + { + UpdateUsernameCommand command = ValidCommand() with { NewUsername = "" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewUsername) + .WithErrorMessage("Username is required"); + } + + [Fact] + public void Validate_NewUsernameTooShort_HasErrorForNewUsername() + { + UpdateUsernameCommand command = ValidCommand() with { NewUsername = "ab" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewUsername) + .WithErrorMessage("Username must be between 3 and 64 characters"); + } + + [Fact] + public void Validate_NewUsernameTooLong_HasErrorForNewUsername() + { + UpdateUsernameCommand command = ValidCommand() with { NewUsername = new string('a', 65) }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewUsername) + .WithErrorMessage("Username must be between 3 and 64 characters"); + } + + [Fact] + public void Validate_NewUsernameHasDisallowedCharacters_HasErrorForNewUsername() + { + UpdateUsernameCommand command = ValidCommand() with { NewUsername = "bad user!" }; + + TestValidationResult result = _validator.TestValidate(command); + + result + .ShouldHaveValidationErrorFor(x => x.NewUsername) + .WithErrorMessage( + "Username can only contain letters, numbers, dots, underscores, and hyphens" + ); + } +} diff --git a/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj index 73a87028..b7e9339b 100644 --- a/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj +++ b/web/backend/Features/Features.Users.Tests/Features.Users.Tests.csproj @@ -13,6 +13,7 @@ +