Add username validation and initial friends feature

Introduced a UsernameValidator with Cyrillic and length checks, integrated into AuthService and registration flow. Added InvalidUsernameException and related interface. Updated User model to support friend requests, added FriendsController (stub), and configured Friendship entity in EF Core. Adjusted UserValidator max name length and removed navigation properties from PrivateChat.
This commit is contained in:
Artemy
2025-06-27 16:36:56 +07:00
parent 9b9cd73a96
commit ec44347bbe
14 changed files with 201 additions and 6 deletions
@@ -19,6 +19,7 @@ public class AuthServiceTests
private Mock<IJwtService> _jwtServiceMock;
private Mock<IUsersRepository> _usersRepositoryMock;
private Mock<IAdminsRepository> _adminsRepositoryMock;
private Mock<IUsernameValidator> _usernameValidatorMock;
private IAccountService _accountService;
@@ -38,12 +39,14 @@ public class AuthServiceTests
_passwordHasherMock = new Mock<IPasswordHasher>();
_jwtServiceMock = new Mock<IJwtService>();
_adminsRepositoryMock = new Mock<IAdminsRepository>();
_usernameValidatorMock = new Mock<IUsernameValidator>();
_accountService = new AuthService(
_usersRepositoryMock.Object,
_jwtServiceMock.Object,
_passwordHasherMock.Object,
_adminsRepositoryMock.Object
_adminsRepositoryMock.Object,
_usernameValidatorMock.Object
);
}
@@ -0,0 +1,43 @@
using Govor.Application.Validators; // или другой namespace
using Govor.Application.Exceptions.AuthService;
namespace Govor.API.Tests.UnitTests.Services.Validators;
[TestFixture]
public class UsernameValidatorTests
{
private UsernameValidator _validator;
[SetUp]
public void SetUp()
{
_validator = new UsernameValidator();
}
[TestCase("Иван")]
[TestCase("Алексей")]
[TestCase("Ёжик")]
public void Validate_ValidUsernames_ShouldNotThrow(string username)
{
Assert.DoesNotThrow(() => _validator.Validate(username));
}
[TestCase("Ivan")] // не кириллица
[TestCase("123Иван")] // начинается не с буквы
[TestCase("Иван123")] // содержит цифры
[TestCase("!@#$")] // спецсимволы
[TestCase("")] // пусто
[TestCase("И")] // меньше минимума
[TestCase("ИванИванИванИванИванИванИванИванИванИванИванИванИван")] // больше максимума (44 символа)
public void Validate_InvalidUsernames_ShouldThrow(string username)
{
Assert.Throws<InvalidUsernameException>(() => _validator.Validate(username));
}
[TestCase("Иван", ExpectedResult = true)]
[TestCase("1234", ExpectedResult = false)]
public bool TryValidate_ShouldReturnTrueRegardlessOfInput(string username)
{
return _validator.TryValidate(username);
}
}
+7 -1
View File
@@ -35,7 +35,8 @@ public class AuthController : Controller
var invite = _invitesService.Validate(registrationRequest.InviteLink);
var token = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, invite);
var token = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password,
invite);
_logger.LogInformation($"Register request for {registrationRequest.Name}");
return Ok(new { token });
}
@@ -49,6 +50,11 @@ public class AuthController : Controller
_logger.LogWarning(ex, $"Invite link invalid: {registrationRequest.InviteLink}");
return BadRequest("Invite link invalid.");
}
catch (InvalidUsernameException ex)
{
_logger.LogWarning(ex, $"Invalid username: {registrationRequest.Name}");
return BadRequest($"Invalid username: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during registration for user {Name}", registrationRequest.Name);
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Govor.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "User,Admin")]
public class FriendsController : Controller
{
private readonly ILogger<FriendsController> _logger;
[HttpGet("search")]
public async Task<IActionResult> Search([FromBody] string query)
{
return BadRequest("Not a valid request");
}
[HttpPost("request")]
public async Task<IActionResult> SendRequest([FromBody] Guid targetUserId)
{
return BadRequest("Not a valid request");
}
[HttpGet("requests")]
public async Task<IActionResult> GetIncomingRequests()
{
return BadRequest("Not a valid request");
}
[HttpPost("accept")]
public async Task<IActionResult> AcceptFriend([FromBody] Guid requesterId)
{
return BadRequest("Not a valid request");
}
[HttpGet]
public async Task<IActionResult> GetFriends()
{
return BadRequest("Not a valid request");
}
private Guid GetCurrentUserId()
{
var userIdClaim = HttpContext.User?.FindFirst("userID")?.Value;
_logger.LogInformation("Claims: {Claims}", string.Join(", ", HttpContext.User?.Claims.Select(c => $"{c.Type}: {c.Value}") ?? new string[0]));
if (string.IsNullOrEmpty(userIdClaim))
{
_logger.LogError("No userID claim found");
return Guid.Empty;
}
return Guid.TryParse(userIdClaim, out var userId) ? userId : Guid.Empty;
}
}
@@ -4,6 +4,7 @@ using Govor.Application.Interfaces;
using Govor.Application.Interfaces.AdminsStuff;
using Govor.Application.Interfaces.Authentication;
using Govor.Application.Services;
using Govor.Application.Validators;
using Govor.Core.Infrastructure.Extensions;
using Govor.Core.Infrastructure.Validators;
using Govor.Core.Models;
@@ -28,6 +29,7 @@ public static class ConfigurationProgramExtensions
services.AddScoped<IUsersAdministration, UsersService>();
services.AddScoped<IInvitesService, InvitesService>();
services.AddScoped<IInvitationGenerator, InvitationGenerator>();
services.AddScoped<IUsernameValidator, UsernameValidator>();
services.AddScoped<IStorageService>(sp =>
{
@@ -0,0 +1,8 @@
using Govor.Core;
namespace Govor.Application.Exceptions.AuthService;
public class InvalidUsernameException(string message) : GovorCoreException(message)
{
}
@@ -0,0 +1,8 @@
using Govor.Core.Infrastructure.Validators;
namespace Govor.Application.Interfaces.Authentication;
public interface IUsernameValidator : IObjectValidator<string>
{
}
+8 -1
View File
@@ -1,9 +1,11 @@
using System.Text.RegularExpressions;
using Govor.API.Services.Authentication.Interfaces;
using Govor.Application.Exceptions.AuthService;
using Govor.Core.Infrastructure.Extensions;
using Govor.Core.Models;
using Govor.Core.Repositories.Users;
using Govor.Application.Interfaces.Authentication;
using Govor.Core.Infrastructure.Validators;
using Govor.Core.Repositories.Admins;
namespace Govor.Application.Services;
@@ -14,21 +16,26 @@ public class AuthService : IAccountService
private readonly IJwtService _jwtService;
private readonly IUsersRepository _usersRepository;
private readonly IAdminsRepository _adminsRepository;
private readonly IUsernameValidator _usernameValidator;
public AuthService(IUsersRepository usersRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IAdminsRepository adminsRepository
IAdminsRepository adminsRepository,
IUsernameValidator usernameValidator
)
{
_usersRepository = usersRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_adminsRepository = adminsRepository;
_usernameValidator = usernameValidator;
}
public async Task<string> RegistrationAsync(string name, string password, Invitation invitation)
{
_usernameValidator.Validate(name);
if (await _usersRepository.ExistsUsernameAsync(name))
throw new UserAlreadyExistException(name);
@@ -0,0 +1,35 @@
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using Govor.Application.Exceptions.AuthService;
using Govor.Application.Interfaces.Authentication;
using Govor.Core.Infrastructure.Validators;
namespace Govor.Application.Validators;
public class UsernameValidator : IUsernameValidator
{
private readonly Regex _usernameRegex = new(@"^[А-Яа-яЁё]+$", RegexOptions.Compiled);
public void Validate(string username)
{
if(username.Length < UserValidator.MIN_LENGHT_OF_NAME || username.Length > UserValidator.MAX_LENGHT_OF_NAME)
throw new InvalidUsernameException($"Username must be between {UserValidator.MIN_LENGHT_OF_NAME} and {UserValidator.MAX_LENGHT_OF_NAME} characters.");
if (!_usernameRegex.IsMatch(username))
throw new InvalidUsernameException("The username must be in Cyrillic and start with a letter.");
}
public bool TryValidate(string username)
{
try
{
Validate(username);
return true;
}
catch
{
return false;
}
}
}
@@ -6,7 +6,7 @@ namespace Govor.Core.Infrastructure.Validators;
public class UserValidator : IObjectValidator<User>
{
public const int MIN_LENGHT_OF_NAME = 4;
public const int MAX_LENGHT_OF_NAME = 50;
public const int MAX_LENGHT_OF_NAME = 44;
public void Validate(User user)
{
-2
View File
@@ -5,7 +5,5 @@ public class PrivateChat
public Guid Id { get; set; }
public Guid UserAId { get; set; }
public Guid UserBId { get; set; }
public User UserA { get; set; }
public User UserB { get; set; }
public List<Message> Messages { get; set; } = new List<Message>();
}
+2
View File
@@ -13,4 +13,6 @@ public class User
public DateTime WasOnline {get; set;}
public Guid InviteId {get; set;}
public Invitation? Invite { get; set; }
public List<Friendship> SentFriendRequests { get; set; } = new();
public List<Friendship> ReceivedFriendRequests { get; set; } = new();
}
@@ -0,0 +1,26 @@
using Govor.Core.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Govor.Data.Configurations;
public class FriendshipConfiguration : IEntityTypeConfiguration<Friendship>
{
public void Configure(EntityTypeBuilder<Friendship> builder)
{
builder.HasKey(f => f.Id);
builder.HasOne(f => f.Requester)
.WithMany(u => u.SentFriendRequests)
.HasForeignKey(f => f.RequesterId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(f => f.Addressee)
.WithMany(u => u.ReceivedFriendRequests)
.HasForeignKey(f => f.AddresseeId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(f => f.Status)
.IsRequired();
}
}
+1
View File
@@ -25,6 +25,7 @@ public class GovorDbContext(DbContextOptions<GovorDbContext> options) : DbContex
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new FriendshipConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration());
modelBuilder.ApplyConfiguration(new InvitationConfiguration());
modelBuilder.ApplyConfiguration(new AdminConfiguration());