Skip to content

Commit

Permalink
Add Advanced HTTP Methods for Users (#61)
Browse files Browse the repository at this point in the history
* Add `IsDeleted` flag for users

* Forbid login for deleted users

* Improve users get methods

Get methods now don't return deleted users

* Add `onlyDeleted` flag for get users

* Implement delete user method

* Implement get user by id

* Add DTOs and validation for user edit

* Remove pointless tests

* Implement `UsersService.UpdateUserAsync`

* Implement `UsersService.UpdateUserRoles`

* Remove regions

* Implement HTTP methods for users
  • Loading branch information
romandykyi authored Dec 23, 2023
1 parent 2b3c93d commit 92d8159
Show file tree
Hide file tree
Showing 20 changed files with 1,995 additions and 129 deletions.
6 changes: 6 additions & 0 deletions Core/Dtos/Users/ChangeRolesDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace EUniversity.Core.Dtos.Users;

[ValidateNever] // Remove data annotations validation
public record ChangeRolesDto(bool? IsStudent = null, bool? IsTeacher = null, bool? IsAdministrator = null);
9 changes: 9 additions & 0 deletions Core/Dtos/Users/EditUserDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using EUniversity.Core.Dtos.Auth;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace EUniversity.Core.Dtos.Users;

[ValidateNever] // Remove data annotations validation
public record EditUserDto(string UserName,
string Email, string FirstName, string LastName, string? MiddleName = null) :
RegisterDto(Email, FirstName, LastName, MiddleName);
13 changes: 13 additions & 0 deletions Core/Dtos/Users/UserViewDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace EUniversity.Core.Dtos.Users;

public class UserViewDto
{
public string Id { get; set; } = null!;
public string Email { get; set; } = null!;
public string UserName { get; set; } = null!;
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string? MiddleName { get; set; }
public bool IsDeleted { get; set; }
public IEnumerable<string> Roles { get; set; } = null!;
}
9 changes: 9 additions & 0 deletions Core/Models/ApplicationUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public class ApplicationUser : IdentityUser, IEntity<string>
public const int MaxUserNameLength = 256;
public const int MaxEmailLength = 256;

public const string AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._";

// Attributes here are used for restricting length of names in the database,
// not for validation:

Expand All @@ -21,4 +23,11 @@ public class ApplicationUser : IdentityUser, IEntity<string>
public string LastName { get; set; } = null!;
[StringLength(MaxNameLength)]
public string? MiddleName { get; set; }

/// <summary>
/// Determines whether the user is about to be deleted.
/// If <see cref="true"/>, then user doesn't exist for client,
/// but can be restored.
/// </summary>
public bool IsDeleted { get; set; } = false;
}
58 changes: 57 additions & 1 deletion Core/Services/Users/IUsersService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,66 @@ Task<Page<UserPreviewDto>> GetUsersInRoleAsync(string role, PaginationProperties
/// </summary>
/// <param name="properties">Optional pagination properties to use.</param>
/// <param name="filter">Optional filter to be applied.</param>
/// <param name="onlyDeleted">
/// Optional flag. If <see langword="true"/> then only deleted users
/// will be returned, otherwise only not deleted users will be returned.</param>
/// <returns>
/// Page with all users.
/// </returns>
Task<Page<UserPreviewDto>> GetAllUsersAsync(PaginationProperties? properties = null, IFilter<ApplicationUser>? filter = null);
Task<Page<UserPreviewDto>> GetAllUsersAsync(PaginationProperties? properties = null,
IFilter<ApplicationUser>? filter = null, bool onlyDeleted = false);

/// <summary>
/// Gets a user by its ID.
/// </summary>
/// <param name="id"></param>
/// <returns>
/// A task that represents the asynchronous operation.
/// Returns <see langword="null" /> if user is not found.
/// </returns>
Task<UserViewDto?> GetByIdAsync(string id);

/// <summary>
/// Deletes a user identified by its unique identifier asynchronously.
/// </summary>
/// <remarks>
/// This method performs a 'soft' deleted after which user is still remains in
/// the database but its 'IsDeleted' flag is set to <see langword="true"/>.
/// </remarks>
/// <param name="id">The unique identifier of the user to delete.</param>
/// <returns>
/// A task that represents the asynchronous operation. If the entity
/// is deleted successfully, it returns <see langword="true" />.
/// If the user with the specified identifier is not found,
/// it returns <see langword="false" />.
/// </returns>
Task<bool> DeleteUserAsync(string userId);

/// <summary>
/// Updates an existing user identified by its unique identifier asynchronously.
/// </summary>
/// <param name="userId">The unique identifier of the user to update.</param>
/// <param name="editUserDto">The DTO containing data for updating the user.</param>
/// <returns>
/// A task that represents the asynchronous operation. If the
/// user is updated successfully, it returns <see langword="true" />.
/// If the user with the specified identifier is not found(or deleted),
/// it returns <see langword="false" />.
/// </returns>
Task<bool> UpdateUserAsync(string userId, EditUserDto editUserDto);

/// <summary>
/// Updates an existing user's roles.
/// </summary>
/// <param name="userId">The unique identifier of the user whose roles will be updated.</param>
/// <param name="dto">The DTO containing data for updating the user's roles.</param>
/// <returns>
/// A task that represents the asynchronous operation. If the
/// user's roles are updated successfully, it returns <see langword="true" />.
/// If the user with the specified identifier is not found(or deleted),
/// it returns <see langword="false" />.
/// </returns>
public Task<bool> UpdateUserRolesAsync(string userId, ChangeRolesDto dto);

/// <summary>
/// Gets a page with groups of the student.
Expand Down
39 changes: 39 additions & 0 deletions Core/Validation/Users/EditUserDtoValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Models;
using FluentValidation;
using FluentValidation.Results;

namespace EUniversity.Core.Validation.Users;

public class EditUserDtoValidator : RegisterDtoValidator<EditUserDto>
{
public EditUserDtoValidator() : base()
{
RuleFor(r => r.UserName).NotEmpty()
.WithErrorCode(ValidationErrorCodes.PropertyRequired)
.WithMessage("Username is required")
.DependentRules(() =>
{
RuleFor(r => r.UserName).Custom((userName, context) =>
{
if (userName.All(c => ApplicationUser.AllowedUserNameCharacters.Contains(c)))
{
return;
}
ValidationFailure failure = new()
{
AttemptedValue = userName,
ErrorCode = ValidationErrorCodes.InvalidUserName,
ErrorMessage = "Username is invalid",
PropertyName = context.PropertyPath,
Severity = Severity.Error
};
context.AddFailure(failure);
});
});
RuleFor(x => x.UserName)
.MaximumLength(ApplicationUser.MaxUserNameLength)
.WithErrorCode(ValidationErrorCodes.PropertyTooLarge)
.WithMessage($"Username cannot have more than {ApplicationUser.MaxEmailLength} characters");
}
}
5 changes: 4 additions & 1 deletion Core/Validation/Users/RegisterDtoValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

namespace EUniversity.Core.Validation.Users;

public class RegisterDtoValidator : AbstractValidator<RegisterDto>
public class RegisterDtoValidator : RegisterDtoValidator<RegisterDto> { }

public class RegisterDtoValidator<T> : AbstractValidator<T>
where T : RegisterDto
{
public RegisterDtoValidator()
{
Expand Down
1 change: 1 addition & 0 deletions Core/Validation/ValidationErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static class ValidationErrorCodes
public const string PropertyTooLarge = "PropertyTooLargeError";
public const string InvalidProperty = "InvalidPropertyError";
public const string InvalidEmail = "InvalidEmailError";
public const string InvalidUserName = "InvalidUserNameError";
public const string InvalidRange = "InvalidRangeError";
public const string Equal = "EqualError";
public const string EmptyCollection = "EmptyCollectionError";
Expand Down
101 changes: 0 additions & 101 deletions EUniversity.Tests/Services/AuthServiceUnitTests.cs

This file was deleted.

94 changes: 94 additions & 0 deletions EUniversity.Tests/Validation/Users/EditUserDtoValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using EUniversity.Core.Dtos.Users;
using EUniversity.Core.Models;
using EUniversity.Core.Validation;
using EUniversity.Core.Validation.Users;
using FluentValidation.TestHelper;

namespace EUniversity.Tests.Validation.Users;

public class EditUserDtoValidatorTests
{
private EditUserDtoValidator _validator;

[OneTimeSetUp]
public void OneTimeSetUp()
{
_validator = new EditUserDtoValidator();
}

[Test]
[TestCase("12345")]
[TestCase("waltuh")]
[TestCase("jp-900")]
[TestCase("Saul_Go0dmaN.CrimiNal-LAWyer")]
public void UserName_Valid_Succeeds(string userName)
{
// Arrange
EditUserDto dto = new(userName, RegisterDtoValidatorTests.DefaultEmail,
RegisterDtoValidatorTests.DefaultFirstName, RegisterDtoValidatorTests.DefaultLastName,
RegisterDtoValidatorTests.DefaultMiddleName);

// Act
var result = _validator.TestValidate(dto);

// Assert
result.ShouldNotHaveAnyValidationErrors();
}

[Test]
[TestCase("Invąlid")]
[TestCase("SomethingIs300$")]
[TestCase("nOS_Svnm+-/*")]
[TestCase("I have spaces")]
[TestCase("IWantToBeValid!?")]
public void UserName_ContainsInvalidCharacters_FailsWithInvalidUserNameError(string userName)
{
// Arrange
EditUserDto dto = new(userName, RegisterDtoValidatorTests.DefaultEmail,
RegisterDtoValidatorTests.DefaultFirstName, RegisterDtoValidatorTests.DefaultLastName,
RegisterDtoValidatorTests.DefaultMiddleName);

// Act
var result = _validator.TestValidate(dto);

// Assert
result.ShouldHaveValidationErrorFor(x => x.UserName)
.WithErrorCode(ValidationErrorCodes.InvalidUserName)
.Only();
}

[Test]
public void UserName_Empty_FailsWithPropertyRequiredError()
{
// Arrange
EditUserDto dto = new(string.Empty, RegisterDtoValidatorTests.DefaultEmail,
RegisterDtoValidatorTests.DefaultFirstName, RegisterDtoValidatorTests.DefaultLastName,
RegisterDtoValidatorTests.DefaultMiddleName);

// Act
var result = _validator.TestValidate(dto);

// Assert
result.ShouldHaveValidationErrorFor(x => x.UserName)
.WithErrorCode(ValidationErrorCodes.PropertyRequired)
.Only();
}

[Test]
public void UserName_TooLarge_FailsWithPropertyTooLargeError()
{
// Arrange
string largeUserName = new('0', ApplicationUser.MaxUserNameLength + 1);
EditUserDto dto = new(largeUserName, RegisterDtoValidatorTests.DefaultEmail,
RegisterDtoValidatorTests.DefaultFirstName, RegisterDtoValidatorTests.DefaultLastName,
RegisterDtoValidatorTests.DefaultMiddleName);

// Act
var result = _validator.TestValidate(dto);

// Assert
result.ShouldHaveValidationErrorFor(x => x.UserName)
.WithErrorCode(ValidationErrorCodes.PropertyTooLarge)
.Only();
}
}
Loading

0 comments on commit 92d8159

Please sign in to comment.