From 437bedb117f9b5789cc9b3e78d10bb4e6ce1cc9a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:31:57 +0700 Subject: [PATCH] Refactor friend request services and add SignalR error handling Split IFriendRequestService into command and query interfaces, refactor related services and tests, and update dependency injection. Add HubResult response model and a SignalR HubExceptionFilter for consistent error handling. Move and update FriendsHub to use new command service and result pattern. Update username validation to allow digits after Cyrillic letters. Add new controllers and configuration for SignalR. Remove obsolete IFriendRequestService and related code. --- ...cs => FriendRequestCommandServiceTests.cs} | 79 ++------- .../Friends/FriendRequestQueryServiceTests.cs | 130 +++++++++++++++ .../Validators/UsernameValidatorTests.cs | 2 +- .../{ => Friends}/FriendsController.cs | 0 .../Friends/FriendsRequestQueryController.cs | 12 ++ .../Friends/FriendshipController.cs | 6 + .../ConfigurationProgramExtensions.cs | 3 +- Govor.API/Extensions/ConfigurationSignalR.cs | 15 ++ Govor.API/Filters/HubExceptionFilter.cs | 76 +++++++++ Govor.API/Govor.API.csproj | 4 - Govor.API/Hubs/ChatsHub.cs | 152 +++++++++--------- Govor.API/Hubs/FriendsHub.cs | 103 ++++++++++-- Govor.API/Program.cs | 2 +- .../Validators/UsernameValidator.cs | 2 +- .../Friends/IFriendRequestCommandService.cs | 8 + .../Friends/IFriendRequestQueryService.cs | 10 ++ .../Friends/IFriendRequestService.cs | 12 -- ...vice.cs => FriendRequestCommandService.cs} | 40 +---- .../Friends/FriendRequestQueryService.cs | 44 +++++ .../Responses/SignalR/HubResult.cs | 76 +++++++++ 20 files changed, 571 insertions(+), 205 deletions(-) rename Govor.API.Tests/UnitTests/Services/Friends/{FriendRequestServiceTests.cs => FriendRequestCommandServiceTests.cs} (75%) create mode 100644 Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs rename Govor.API/Controllers/{ => Friends}/FriendsController.cs (100%) create mode 100644 Govor.API/Controllers/Friends/FriendsRequestQueryController.cs create mode 100644 Govor.API/Controllers/Friends/FriendshipController.cs create mode 100644 Govor.API/Extensions/ConfigurationSignalR.cs create mode 100644 Govor.API/Filters/HubExceptionFilter.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs create mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs delete mode 100644 Govor.Application/Interfaces/Friends/IFriendRequestService.cs rename Govor.Application/Services/Friends/{FriendRequestService.cs => FriendRequestCommandService.cs} (63%) create mode 100644 Govor.Application/Services/Friends/FriendRequestQueryService.cs create mode 100644 Govor.Contracts/Responses/SignalR/HubResult.cs diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs similarity index 75% rename from Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs rename to Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs index 9f3f7a4..2b2baa3 100644 --- a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestServiceTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestCommandServiceTests.cs @@ -11,12 +11,12 @@ using Moq; namespace Govor.API.Tests.UnitTests.Services.Friends; [TestFixture] -public class FriendRequestServiceTests +public class FriendRequestCommandServiceTests { private Fixture _fixture; private Mock _usersRepositoryMock; private Mock _friendshipsRepositoryMock; - private IFriendRequestService _service; + private IFriendRequestCommandService _service; [SetUp] public void SetUp() @@ -31,7 +31,7 @@ public class FriendRequestServiceTests _usersRepositoryMock = new Mock(); _friendshipsRepositoryMock = new Mock(); - _service = new FriendRequestService(_friendshipsRepositoryMock.Object); + _service = new FriendRequestCommandService(_friendshipsRepositoryMock.Object); } // SendFriendRequestAsync @@ -43,7 +43,7 @@ public class FriendRequestServiceTests // Act & Assert var ex = Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(userId, userId)); + _service.SendAsync(userId, userId)); Assert.That(ex.Message, Is.EqualTo("Cannot send a request to self user")); } @@ -61,7 +61,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(() => - _service.SendFriendRequestAsync(fromUserId, toUserId)); + _service.SendAsync(fromUserId, toUserId)); } [Test] @@ -76,7 +76,7 @@ public class FriendRequestServiceTests .Returns(false); // Act - await _service.SendFriendRequestAsync(fromUserId, toUserId); + await _service.SendAsync(fromUserId, toUserId); // Assert _friendshipsRepositoryMock.Verify(repo => @@ -102,7 +102,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act: вызываем именно Accept, не Send - await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); // Assert _friendshipsRepositoryMock.Verify(r => r.UpdateAsync(It.Is(f => @@ -125,7 +125,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act & Assert - Assert.ThrowsAsync(async () => await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId)); + Assert.ThrowsAsync(async () => await _service.AcceptAsync(friendship.Id, friendship.AddresseeId)); } [Test] public void AcceptFriendRequestAsync_Throws_UnauthorizedAccessException_IfCurrentUserIdIsNotAddressee() @@ -143,7 +143,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.AcceptFriendRequestAsync(friendship.Id, wrongUserId)); + await _service.AcceptAsync(friendship.Id, wrongUserId)); } [Test] @@ -159,7 +159,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.AcceptFriendRequestAsync(requestId, userId)); + await _service.AcceptAsync(requestId, userId)); } [Test] @@ -180,7 +180,7 @@ public class FriendRequestServiceTests .Callback(f => updatedFriendship = f) .Returns(Task.CompletedTask); - await _service.AcceptFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.AcceptAsync(friendship.Id, friendship.AddresseeId); Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Accepted)); } @@ -197,7 +197,7 @@ public class FriendRequestServiceTests .Setup(r => r.GetByIdAsync(friendship.Id)) .ReturnsAsync(friendship); - await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.RejectAsync(friendship.Id, friendship.AddresseeId); // Assert _friendshipsRepositoryMock.Verify(r => r.UpdateAsync(It.Is(f => @@ -220,7 +220,7 @@ public class FriendRequestServiceTests .ReturnsAsync(friendship); // Act & Assert - Assert.ThrowsAsync(async () => await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId)); + Assert.ThrowsAsync(async () => await _service.RejectAsync(friendship.Id, friendship.AddresseeId)); } [Test] @@ -239,7 +239,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.RejectFriendRequestAsync(friendship.Id, wrongUserId)); + await _service.RejectAsync(friendship.Id, wrongUserId)); } [Test] @@ -255,7 +255,7 @@ public class FriendRequestServiceTests // Act & Assert Assert.ThrowsAsync(async () => - await _service.RejectFriendRequestAsync(requestId, userId)); + await _service.RejectAsync(requestId, userId)); } [Test] @@ -276,55 +276,10 @@ public class FriendRequestServiceTests .Callback(f => updatedFriendship = f) .Returns(Task.CompletedTask); - await _service.RejectFriendRequestAsync(friendship.Id, friendship.AddresseeId); + await _service.RejectAsync(friendship.Id, friendship.AddresseeId); Assert.That(updatedFriendship.Status, Is.EqualTo(FriendshipStatus.Rejected)); } - // GetIncomingRequestsAsync - [Test] - public async Task GetIncomingRequestsAsync_ReturnsFriendships_IfFriendshipsExists() - { - // Arrange - var userId = Guid.NewGuid(); - - var friendships = _fixture.CreateMany().ToList(); - - var user = _fixture.Build() - .With(u => u.Id, userId) - .With(u => u.ReceivedFriendRequests, friendships) - .Create(); - - friendships.ForEach(f => - { - f.AddresseeId = userId; - f.Addressee = user; - f.Status = FriendshipStatus.Pending; - }); - - _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) - .ReturnsAsync(friendships); - - // Act - var result = await _service.GetIncomingRequestsAsync(userId); - - // Assert - Assert.That(result.Count, Is.EqualTo(friendships.Count)); - Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); - } - - [Test] - public void GetIncomingRequestsAsync_ThrowsInvalidOperationException_WhenUserNotFound() - { - // Arrange - var userId = Guid.NewGuid(); - - _friendshipsRepositoryMock - .Setup(r => r.FindByUserIdAsync(userId)) - .ThrowsAsync(new NotFoundByKeyException(userId)); - - // Act & Assert - Assert.ThrowsAsync(async () => - await _service.GetIncomingRequestsAsync(userId)); - } + } \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs new file mode 100644 index 0000000..7870a5b --- /dev/null +++ b/Govor.API.Tests/UnitTests/Services/Friends/FriendRequestQueryServiceTests.cs @@ -0,0 +1,130 @@ +using AutoFixture; +using Govor.Application.Interfaces.Friends; +using Govor.Application.Services.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Core.Repositories.Users; +using Govor.Data.Repositories.Exceptions; +using Moq; + +namespace Govor.API.Tests.UnitTests.Services.Friends; + +[TestFixture] +public class FriendRequestQueryServiceTests +{ + private Fixture _fixture; + private Mock _usersRepositoryMock; + private Mock _friendshipsRepositoryMock; + private IFriendRequestQueryService _service; + + [SetUp] + public void SetUp() + { + _fixture = new Fixture(); + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _usersRepositoryMock = new Mock(); + _friendshipsRepositoryMock = new Mock(); + + _service = new FriendRequestQueryService(_friendshipsRepositoryMock.Object); + } + + // GetIncomingRequestsAsync + [Test] + public async Task GetIncomingRequestsAsync_ReturnsFriendships_IfFriendshipsExists() + { + // Arrange + var userId = Guid.NewGuid(); + + var friendships = _fixture.CreateMany().ToList(); + + var user = _fixture.Build() + .With(u => u.Id, userId) + .With(u => u.ReceivedFriendRequests, friendships) + .Create(); + + friendships.ForEach(f => + { + f.AddresseeId = userId; + f.Addressee = user; + f.Status = FriendshipStatus.Pending; + }); + + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); + + // Act + var result = await _service.GetIncomingAsync(userId); + + // Assert + Assert.That(result.Count, Is.EqualTo(friendships.Count)); + Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); + } + + [Test] + public void GetIncomingRequestsAsync_ThrowsInvalidOperationException_WhenUserNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(r => r.FindByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.GetIncomingAsync(userId)); + } + + // GetResponsesAsync + [Test] + public async Task GetResponsesAsync_ReturnsFriendships_IfFriendshipsExists() + { + // Arrange + var userId = Guid.NewGuid(); + + var friendships = _fixture.CreateMany().ToList(); + + var user = _fixture.Build() + .With(u => u.Id, userId) + .With(u => u.ReceivedFriendRequests, friendships) + .Create(); + + friendships.ForEach(f => + { + f.RequesterId = userId; + f.Requester = user; + f.Status = FriendshipStatus.Rejected; + }); + + _friendshipsRepositoryMock.Setup(f => f.FindByUserIdAsync(userId)) + .ReturnsAsync(friendships); + + // Act + var result = await _service.GetResponsesAsync(userId); + + // Assert + Assert.That(result.Count, Is.EqualTo(friendships.Count)); + Assert.That(result.Select(u => u.Id), Is.EquivalentTo(friendships.Select(f => f.Id))); + } + + [Test] + public void GetResponsesAsync_ThrowsInvalidOperationException_WhenUserNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + + _friendshipsRepositoryMock + .Setup(r => r.FindByUserIdAsync(userId)) + .ThrowsAsync(new NotFoundByKeyException(userId)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await _service.GetResponsesAsync(userId)); + } + +} \ No newline at end of file diff --git a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs index 991319f..1e4571a 100644 --- a/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs +++ b/Govor.API.Tests/UnitTests/Services/Validators/UsernameValidatorTests.cs @@ -17,6 +17,7 @@ public class UsernameValidatorTests [TestCase("Иван")] [TestCase("Алексей")] [TestCase("Ёжик")] + [TestCase("Иван123")] // содержит цифры public void Validate_ValidUsernames_ShouldNotThrow(string username) { Assert.DoesNotThrow(() => _validator.Validate(username)); @@ -24,7 +25,6 @@ public class UsernameValidatorTests [TestCase("Ivan")] // не кириллица [TestCase("123Иван")] // начинается не с буквы - [TestCase("Иван123")] // содержит цифры [TestCase("!@#$")] // спецсимволы [TestCase("")] // пусто [TestCase("И")] // меньше минимума diff --git a/Govor.API/Controllers/FriendsController.cs b/Govor.API/Controllers/Friends/FriendsController.cs similarity index 100% rename from Govor.API/Controllers/FriendsController.cs rename to Govor.API/Controllers/Friends/FriendsController.cs diff --git a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs new file mode 100644 index 0000000..0863894 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Friends; + +[ApiController] +[Authorize] +[Route("api/friends")] +public class FriendsRequestQueryController : Controller +{ + +} \ No newline at end of file diff --git a/Govor.API/Controllers/Friends/FriendshipController.cs b/Govor.API/Controllers/Friends/FriendshipController.cs new file mode 100644 index 0000000..fbfe156 --- /dev/null +++ b/Govor.API/Controllers/Friends/FriendshipController.cs @@ -0,0 +1,6 @@ +namespace Govor.API.Controllers.Friends; + +public class FriendshipController +{ + +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index fc394e4..88ec6d8 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -41,7 +41,8 @@ public static class ConfigurationProgramExtensions // Friends services services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(sp => diff --git a/Govor.API/Extensions/ConfigurationSignalR.cs b/Govor.API/Extensions/ConfigurationSignalR.cs new file mode 100644 index 0000000..69cbe0d --- /dev/null +++ b/Govor.API/Extensions/ConfigurationSignalR.cs @@ -0,0 +1,15 @@ +using Govor.API.Filters; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Extensions; + +public static class ConfigurationSignalR +{ + public static void AddSignalRConf(this IServiceCollection services) + { + services.AddSignalR(options => + { + options.AddFilter(); + }); + } +} \ No newline at end of file diff --git a/Govor.API/Filters/HubExceptionFilter.cs b/Govor.API/Filters/HubExceptionFilter.cs new file mode 100644 index 0000000..fa814fb --- /dev/null +++ b/Govor.API/Filters/HubExceptionFilter.cs @@ -0,0 +1,76 @@ +using Govor.Contracts.Responses.SignalR; +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Filters; + +public class HubExceptionFilter : IHubFilter +{ + private readonly ILogger _logger; + + public HubExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext context, + Func> next) + { + try + { + return await next(context); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Bad request in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.BadRequest, ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.Unauthorized, ex.Message); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, "Not found in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.NotFound, ex.Message); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid operation in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.Conflict, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error in {Method}", context.HubMethodName); + return CreateHubErrorResult(context, HubResultStatus.ServerError, "Internal server error"); + } + } + + private static object CreateHubErrorResult(HubInvocationContext context, HubResultStatus status, string message) + { + var returnType = context.HubMethod.ReturnType; + + // Поддержка Task> + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var resultType = returnType.GenericTypeArguments[0]; + + if (resultType.IsGenericType && resultType.GetGenericTypeDefinition() == typeof(HubResult<>)) + { + var hubResultType = resultType; + var errorResult = Activator.CreateInstance(hubResultType)!; + + hubResultType.GetProperty(nameof(HubResult.Status))! + .SetValue(errorResult, status); + hubResultType.GetProperty(nameof(HubResult.ErrorMessage))! + .SetValue(errorResult, message); + + return Task.FromResult(errorResult); + } + } + + // Ничего не возвращаем, если не поддерживается + return null; + } +} \ No newline at end of file diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index 3614dc4..b88211f 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -23,9 +23,5 @@ - - - - diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 0deabc4..f8e380b 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -16,14 +16,14 @@ public class ChatsHub : Hub private readonly ILogger _logger; private readonly IMessageService _messageService; private readonly IUserGroupsService _userService; - + public ChatsHub(ILogger logger, IMessageService messageService, IUserGroupsService userService) { _logger = logger; _messageService = messageService; _userService = userService; } - + public override async Task OnConnectedAsync() { var userId = GetUserId(); @@ -36,28 +36,34 @@ public class ChatsHub : Hub // Add user to their own group (for private messages and notifications) await Groups.AddToGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", userId, Context.ConnectionId); - + _logger.LogInformation("User {UserId} connected with ConnectionId {ConnectionId} and added to their group", + userId, Context.ConnectionId); + var userGroups = await _userService.GetUserGroupsAsync(userId); foreach (var group in userGroups) { - await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); + await Groups.AddToGroupAsync(Context.ConnectionId, $"group_{group.Id}"); } await base.OnConnectedAsync(); } - + public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) + var userId = + GetUserId(suppressException: true); // Suppress exception if userID is not found (e.g. connection aborted early) if (userId != Guid.Empty) { // Remove user from their own group await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); - _logger.LogInformation("User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, Context.ConnectionId); + _logger.LogInformation( + "User {UserId} disconnected with ConnectionId {ConnectionId} and removed from their group", userId, + Context.ConnectionId); - - var userGroups = await _userService.GetUserGroupsAsync(userId); // This might be problematic if the service relies on the user being connected + var userGroups = + await _userService + .GetUserGroupsAsync( + userId); // This might be problematic if the service relies on the user being connected foreach (var group in userGroups) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"group_{group.Id}"); @@ -65,11 +71,15 @@ public class ChatsHub : Hub } else if (exception != null) { - _logger.LogWarning(exception, "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + _logger.LogWarning(exception, + "User disconnected with an exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); } else { - _logger.LogInformation("User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", Context.ConnectionId); + _logger.LogInformation( + "User disconnected with no exception and invalid UserID claim. ConnectionId: {ConnectionId}", + Context.ConnectionId); } await base.OnDisconnectedAsync(exception); @@ -77,88 +87,78 @@ public class ChatsHub : Hub public async Task Send(MessageRequest request) { - if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) + if (string.IsNullOrWhiteSpace(request.EncryptedContent) && + (request.MediaAttachments == null || !request.MediaAttachments.Any())) { - _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", GetUserId()); + _logger.LogWarning("Empty message (no content and no attachments) received from user {UserId}", + GetUserId()); throw new ArgumentException("Message cannot be empty (must have content or attachments)."); } var senderId = GetUserId(); - _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", senderId, request.RecipientId, request.RecipientType); + _logger.LogInformation("Message send initiated by {SenderId} to {RecipientId} of type {RecipientType}", + senderId, request.RecipientId, request.RecipientType); - var sendMessageParams = new SendMessage( - EncryptContent: request.EncryptedContent, - ReplyToMessageId: request.ReplyToMessageId, - FromUserId: senderId, - RecipientId: request.RecipientId, - RecipientType: request.RecipientType, - SendAt: DateTime.UtcNow, - Media: request.MediaAttachments?.Select(f => new SendMedia( - f.MediaId, f.EncryptedKey)) ?? Array.Empty() - ); + var sendMessageParams = new SendMessage(EncryptContent: request.EncryptedContent, + ReplyToMessageId: request.ReplyToMessageId, FromUserId: senderId, RecipientId: request.RecipientId, + RecipientType: request.RecipientType, SendAt: DateTime.UtcNow, + Media: request.MediaAttachments?.Select(f => new SendMedia(f.MediaId, f.EncryptedKey)) ?? + Array.Empty()); - try + var result = await _messageService.SendMessageAsync(sendMessageParams); + + if (!result.IsSuccess || result.Message.Id == Guid.Empty) { - var result = await _messageService.SendMessageAsync(sendMessageParams); - - if (!result.IsSuccess || result.Message.Id == Guid.Empty) - { - _logger.LogError(result.Exception, "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, request.RecipientId, result.Exception?.Message ?? "Unknown error"); - if (result.Exception != null) throw result.Exception; - throw new HubException("Failed to send message due to an internal error."); - } - - var messageResponse = new UserMessageResponse // Assuming a response DTO - { - MessageId = result.Message.Id, - SenderId = result.Message.SenderId, - RecipientId = result.Message.RecipientId, - RecipientType = result.Message.RecipientType, - EncryptedContent = result.Message.EncryptedContent, - SentAt = result.Message.SentAt, - IsEdited = false, - MediaAttachments = result.Message.MediaAttachments - .Select(m => m.MediaFile).ToList(), - ReplyToMessageId = request.ReplyToMessageId - }; - - // Notify recipient (user or group) - if (request.RecipientType == RecipientType.User) - { - // Send to the recipient's personal group - await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); - } - else if (request.RecipientType == RecipientType.Group) - { - // Send to all members of the group, including the sender if they are part of the group via a different connection - await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); - } - - // Notify sender (confirmation) on their connection - await Clients.Caller.SendAsync("MessageSent", messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else - - _logger.LogInformation("Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", result.Message.Id, senderId, request.RecipientId, request.RecipientType); + _logger.LogError(result.Exception, + "Failed to send message from {SenderId} to {RecipientId}. Error: {ErrorMessage}", senderId, + request.RecipientId, result.Exception?.Message ?? "Unknown error"); + if (result.Exception != null) + throw result.Exception; + throw new HubException("Failed to send message due to an internal error."); } - catch (Exception ex) + + var messageResponse = new UserMessageResponse // Assuming a response DTO { - _logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId}", senderId, request.RecipientId); - // Consider sending a specific error message to the caller instead of a generic HubException or rethrowing. - // For example: await Clients.Caller.SendAsync("SendMessageFailed", new { Error = ex.Message }); - throw new HubException("An error occurred while sending the message.", ex); + MessageId = result.Message.Id, + SenderId = result.Message.SenderId, + RecipientId = result.Message.RecipientId, + RecipientType = result.Message.RecipientType, + EncryptedContent = result.Message.EncryptedContent, + SentAt = result.Message.SentAt, + IsEdited = false, + MediaAttachments = result.Message.MediaAttachments.Select(m => m.MediaFile).ToList(), + ReplyToMessageId = request.ReplyToMessageId + }; + + // Notify recipient (user or group) + if (request.RecipientType == RecipientType.User) + { + // Send to the recipient's personal group + await Clients.Group(request.RecipientId.ToString()).SendAsync("ReceiveMessage", messageResponse); } + else if (request.RecipientType == RecipientType.Group) + { + // Send to all members of the group, including the sender if they are part of the group via a different connection + await Clients.Group($"group_{request.RecipientId}").SendAsync("ReceiveMessage", messageResponse); + } + + // Notify sender (confirmation) on their connection + await Clients.Caller.SendAsync("MessageSent", + messageResponse); // Or use "ReceiveMessage" if the sender should also just get it like anyone else + + _logger.LogInformation( + "Message {MessageId} sent successfully from {SenderId} to {RecipientId} ({RecipientType})", + result.Message.Id, senderId, request.RecipientId, request.RecipientType); } public async Task Remove(RemoveMessageRequest request) { - } - + public async Task Edit(EditMessageRequest request) { - } - - + private Guid GetUserId(bool suppressException = false) { var userIdClaim = Context.User?.FindFirst("userId")?.Value; @@ -169,8 +169,10 @@ public class ChatsHub : Hub _logger.LogError("Could not retrieve sender userId. Claim was: {UserIDClaim}", userIdClaim); throw new UnauthorizedAccessException("userID claim is missing or invalid."); } + return Guid.Empty; } + return userId; } } \ No newline at end of file diff --git a/Govor.API/Hubs/FriendsHub.cs b/Govor.API/Hubs/FriendsHub.cs index d1a9b30..6fe173a 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,38 +1,113 @@ +using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.Contracts.Responses.SignalR; using Microsoft.AspNetCore.SignalR; namespace Govor.API.Hubs; public class FriendsHub : Hub { - private readonly IFriendRequestService _friendRequestService; + private readonly ILogger _logger; + private readonly IFriendRequestCommandService _friendRequestService; private readonly ICurrentUserService _currentUserService; - public FriendsHub(IFriendRequestService friendRequestService, ICurrentUserService currentUserService) + public FriendsHub(IFriendRequestCommandService friendRequestService, ICurrentUserService currentUserService, ILogger logger) { _friendRequestService = friendRequestService; _currentUserService = currentUserService; + _logger = logger; } - public async Task SendRequest(Guid targetUserId) + public async Task> SendRequest(Guid targetUserId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.SendFriendRequestAsync(userId, targetUserId); - await Clients.User(targetUserId.ToString()).SendAsync("FriendRequestReceived", userId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.SendAsync(userId, targetUserId); + await Clients.User(targetUserId.ToString()) + .SendAsync("FriendRequestReceived", userId); + + _logger.LogInformation($"Friend request received for user {targetUserId} from {userId}."); + return HubResult.Created(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (RequestAlreadySentException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Conflict(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } - public async Task AcceptRequest(Guid friendshipId) + public async Task> AcceptRequest(Guid friendshipId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.AcceptFriendRequestAsync(friendshipId, userId); - await Clients.User(userId.ToString()).SendAsync("FriendRequestAccepted", friendshipId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.AcceptAsync(friendshipId, userId); + await Clients.User(userId.ToString()) + .SendAsync("FriendRequestAccepted", friendshipId); + + _logger.LogInformation($"Friend request accepted for user {userId} from {userId}."); + return HubResult.Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } - public async Task RejectRequest(Guid friendshipId) + public async Task> RejectRequest(Guid friendshipId) { - var userId = _currentUserService.GetCurrentUserId(); - await _friendRequestService.RejectFriendRequestAsync(friendshipId, userId); - await Clients.User(userId.ToString()).SendAsync("FriendRequestRejected", friendshipId); + try + { + var userId = _currentUserService.GetCurrentUserId(); + await _friendRequestService.RejectAsync(friendshipId, userId); + await Clients.User(userId.ToString()) + .SendAsync("FriendRequestRejected", friendshipId); + + _logger.LogInformation($"Friend request rejected for user {userId} from {userId}."); + return HubResult.Ok(); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, ex.Message); + return HubResult.Unauthorized(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return HubResult.Error("Unexpected error! Please try later!"); + } } } \ No newline at end of file diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 761a772..18cf04a 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -29,7 +29,7 @@ builder.Services.AddCors(options => builder.Services.Configure(configuration.GetSection(nameof(JwtOption))); // Add services -builder.Services.AddSignalR(); +builder.Services.AddSignalRConf();// signalR builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => diff --git a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs index 2a41e37..6b810c4 100644 --- a/Govor.Application/Infrastructure/Validators/UsernameValidator.cs +++ b/Govor.Application/Infrastructure/Validators/UsernameValidator.cs @@ -8,7 +8,7 @@ namespace Govor.Application.Infrastructure.Validators; public class UsernameValidator : IUsernameValidator { - private readonly Regex _usernameRegex = new(@"^[А-Яа-яЁё]+$", RegexOptions.Compiled); + private readonly Regex _usernameRegex = new(@"^[А-Яа-яЁё]+[А-Яа-яЁё0-9]*$", RegexOptions.Compiled); public void Validate(string username) { diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs new file mode 100644 index 0000000..21f9d24 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestCommandService.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.Friends; + +public interface IFriendRequestCommandService +{ + Task SendAsync(Guid fromUserId, Guid toUserId); + Task AcceptAsync(Guid requestId, Guid currentUserId); + Task RejectAsync(Guid requestId, Guid currentUserId); +} diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs new file mode 100644 index 0000000..93e9525 --- /dev/null +++ b/Govor.Application/Interfaces/Friends/IFriendRequestQueryService.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Application.Interfaces.Friends; + + +public interface IFriendRequestQueryService +{ + Task> GetIncomingAsync(Guid userId); + Task> GetResponsesAsync(Guid userId); +} diff --git a/Govor.Application/Interfaces/Friends/IFriendRequestService.cs b/Govor.Application/Interfaces/Friends/IFriendRequestService.cs deleted file mode 100644 index d6d9e37..0000000 --- a/Govor.Application/Interfaces/Friends/IFriendRequestService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Govor.Core.Models; - -namespace Govor.Application.Interfaces.Friends; - -public interface IFriendRequestService -{ - Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId); - Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId); - Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId); - Task> GetIncomingRequestsAsync(Guid userId); - Task> GetResponsesAsync(Guid userId); -} \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestService.cs b/Govor.Application/Services/Friends/FriendRequestCommandService.cs similarity index 63% rename from Govor.Application/Services/Friends/FriendRequestService.cs rename to Govor.Application/Services/Friends/FriendRequestCommandService.cs index f174074..e4e8780 100644 --- a/Govor.Application/Services/Friends/FriendRequestService.cs +++ b/Govor.Application/Services/Friends/FriendRequestCommandService.cs @@ -6,16 +6,16 @@ using Govor.Data.Repositories.Exceptions; namespace Govor.Application.Services.Friends; -public class FriendRequestService : IFriendRequestService +public class FriendRequestCommandService : IFriendRequestCommandService { private readonly IFriendshipsRepository _friendshipsRepository; - public FriendRequestService(IFriendshipsRepository friendshipsRepository) + public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository) { _friendshipsRepository = friendshipsRepository; } - - public async Task SendFriendRequestAsync(Guid fromUserId, Guid toUserId) + + public async Task SendAsync(Guid fromUserId, Guid toUserId) { if (fromUserId == toUserId) throw new InvalidOperationException("Cannot send a request to self user"); @@ -32,7 +32,7 @@ public class FriendRequestService : IFriendRequestService }); } - public async Task AcceptFriendRequestAsync(Guid requestId, Guid currentUserId) + public async Task AcceptAsync(Guid requestId, Guid currentUserId) { try { @@ -53,7 +53,7 @@ public class FriendRequestService : IFriendRequestService } } - public async Task RejectFriendRequestAsync(Guid requestId, Guid currentUserId) + public async Task RejectAsync(Guid requestId, Guid currentUserId) { try { @@ -73,32 +73,4 @@ public class FriendRequestService : IFriendRequestService throw new InvalidOperationException("Friendship not found! You cant reject request!", ex); } } - - public async Task> GetIncomingRequestsAsync(Guid userId) - { - try - { - var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); - return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList() - ?? new List(); - } - catch (NotFoundByKeyException ex) - { - throw new InvalidOperationException("User not exist", ex); - } - } - - public async Task> GetResponsesAsync(Guid userId) - { - try - { - var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); - return friendships.Where(f => f.RequesterId == userId && f.Status != FriendshipStatus.Accepted).ToList() - ?? new List(); - } - catch (NotFoundByKeyException ex) - { - throw new InvalidOperationException("User not exist", ex); - } - } } \ No newline at end of file diff --git a/Govor.Application/Services/Friends/FriendRequestQueryService.cs b/Govor.Application/Services/Friends/FriendRequestQueryService.cs new file mode 100644 index 0000000..de66537 --- /dev/null +++ b/Govor.Application/Services/Friends/FriendRequestQueryService.cs @@ -0,0 +1,44 @@ +using Govor.Application.Interfaces.Friends; +using Govor.Core.Models; +using Govor.Core.Repositories.Friendships; +using Govor.Data.Repositories.Exceptions; + +namespace Govor.Application.Services.Friends; + +public class FriendRequestQueryService : IFriendRequestQueryService +{ + private readonly IFriendshipsRepository _friendshipsRepository; + + public FriendRequestQueryService(IFriendshipsRepository friendshipsRepository) + { + _friendshipsRepository = friendshipsRepository; + } + + public async Task> GetIncomingAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.AddresseeId == userId && f.Status == FriendshipStatus.Pending).ToList() + ?? new List(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } + + public async Task> GetResponsesAsync(Guid userId) + { + try + { + var friendships = await _friendshipsRepository.FindByUserIdAsync(userId); + return friendships.Where(f => f.RequesterId == userId && f.Status != FriendshipStatus.Accepted).ToList() + ?? new List(); + } + catch (NotFoundByKeyException ex) + { + throw new InvalidOperationException("User not exist", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Contracts/Responses/SignalR/HubResult.cs b/Govor.Contracts/Responses/SignalR/HubResult.cs new file mode 100644 index 0000000..a75ca80 --- /dev/null +++ b/Govor.Contracts/Responses/SignalR/HubResult.cs @@ -0,0 +1,76 @@ +namespace Govor.Contracts.Responses.SignalR; + +public class HubResult +{ + public HubResultStatus Status { get; set; } + public T? Result { get; set; } + public string? ErrorMessage { get; set; } + + public static HubResult Ok(T? result = default) => new() + { + Status = HubResultStatus.Success, + Result = result + }; + + public static HubResult Created(T? result = default) => new() + { + Status = HubResultStatus.Created, + Result = result + }; + + public static HubResult NoContent() => new() + { + Status = HubResultStatus.NoContent + }; + + public static HubResult BadRequest(string message, T? details = default) => new() + { + Status = HubResultStatus.BadRequest, + ErrorMessage = message, + Result = details + }; + + public static HubResult NotFound(string message, T? details = default) => new() + { + Status = HubResultStatus.NotFound, + ErrorMessage = message, + Result = details + }; + + public static HubResult Unauthorized(string message) => new() + { + Status = HubResultStatus.Unauthorized, + ErrorMessage = message + }; + + public static HubResult Conflict(string message) => new() + { + Status = HubResultStatus.Conflict, + ErrorMessage = message, + }; + + public static HubResult UnprocessableEntity(string message) => new() + { + Status = HubResultStatus.UnprocessableEntity, + ErrorMessage = message + }; + + public static HubResult Error(string message) => new() + { + Status = HubResultStatus.ServerError, + ErrorMessage = message + }; +} + +public enum HubResultStatus +{ + Success = 200, + Created = 201, + NoContent = 204, + BadRequest = 400, + Unauthorized = 401, + NotFound = 404, + Conflict = 409, + UnprocessableEntity = 422, + ServerError = 500, +} \ No newline at end of file