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