From be0edb0f94ffc0f2432a4333d9e7ecaea7df3ef9 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:21:51 +0700 Subject: [PATCH] Refactor user ID access in SignalR hubs and add online user tracking Introduces IHubUserAccessor and its implementation to centralize user ID retrieval from SignalR HubCallerContext, replacing duplicated logic in ChatsHub, FriendsHub, and PresenceHub. Moves extension and mapping files to a Common directory, adds UserToUserDtoMappingAction for online status mapping, and implements OnlineUserStore with tests for tracking online users. Updates dependency injection and test code to use the new abstractions. --- .../SignalR/Helpers/HubUserAccessorTests.cs | 114 ++++++++++++++++++ .../IntegrationTests/Hubs/ChatsHubTests.cs | 6 +- .../IntegrationTests/Hubs/FriendsHubTests.cs | 15 ++- .../Extensions/AddOptionExtensions.cs | 2 +- .../ConfigurationProgramExtensions.cs | 6 +- .../Extensions/ConfigurationSignalR.cs | 2 +- .../ConfiguratorLoggerExtensions.cs | 2 +- .../Mapping}/MappingProfile.cs | 7 +- .../Mapping/UserToUserDtoMappingAction.cs | 21 ++++ .../Common/SignalR/Helpers/HubUserAccessor.cs | 30 +++++ .../SignalR/Helpers/IHubUserAccessor.cs | 8 ++ Govor.API/Hubs/ChatsHub.cs | 34 ++---- Govor.API/Hubs/FriendsHub.cs | 44 +++---- Govor.API/Hubs/PresenceHub.cs | 34 ++---- Govor.API/Program.cs | 2 +- .../UserOnlineStatus/OnlineUserStoreTests.cs | 82 +++++++++++++ .../UserOnlineStatus/OnlineUserStore.cs | 13 +- 17 files changed, 326 insertions(+), 96 deletions(-) create mode 100644 Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs rename Govor.API/{ => Common}/Extensions/AddOptionExtensions.cs (91%) rename Govor.API/{ => Common}/Extensions/ConfigurationProgramExtensions.cs (97%) rename Govor.API/{ => Common}/Extensions/ConfigurationSignalR.cs (88%) rename Govor.API/{ => Common}/Extensions/ConfiguratorLoggerExtensions.cs (92%) rename Govor.API/{Extensions => Common/Mapping}/MappingProfile.cs (75%) create mode 100644 Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs create mode 100644 Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs create mode 100644 Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs create mode 100644 Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs diff --git a/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs b/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs new file mode 100644 index 0000000..698999f --- /dev/null +++ b/Govor.API.Tests/Common/SignalR/Helpers/HubUserAccessorTests.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using Govor.API.Common.SignalR.Helpers; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Govor.API.Tests.Common.SignalR.Helpers; + +[TestFixture] +[TestOf(typeof(HubUserAccessor))] +public class HubUserAccessorTests +{ + private HubUserAccessor _accessor; + private Mock> _loggerMock; + + [SetUp] + public void SetUp() + { + _loggerMock = new Mock>(); + _accessor = new HubUserAccessor(_loggerMock.Object); + } + + private HubCallerContext CreateContextWithClaims(params Claim[] claims) + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns(principal); + return contextMock.Object; + } + + [Test] + public void GetUserId_ValidClaim_ReturnsGuid() + { + // Arrange + var expectedGuid = Guid.NewGuid(); + var context = CreateContextWithClaims(new Claim("userId", expectedGuid.ToString())); + + // Act + var result = _accessor.GetUserId(context); + + // Assert + Assert.That(expectedGuid, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_InvalidClaim_ThrowsException_WhenNotSuppressed() + { + // Arrange + var context = CreateContextWithClaims(new Claim("userId", "not-a-guid")); + + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(context, suppressException: false)); + } + + [Test] + public void GetUserId_InvalidClaim_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var context = CreateContextWithClaims(new Claim("userId", "not-a-guid")); + + // Act + var result = _accessor.GetUserId(context, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_NoClaim_ThrowsException_WhenNotSuppressed() + { + // Arrange + var context = CreateContextWithClaims(); + + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(context, suppressException: false)); + } + + [Test] + public void GetUserId_NoClaim_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var context = CreateContextWithClaims(); + + // Act + var result = _accessor.GetUserId(context, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_UserIsNull_ReturnsEmptyGuid_WhenSuppressed() + { + // Arrange + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null); + + // Act + var result = _accessor.GetUserId(contextMock.Object, suppressException: true); + + // Assert + Assert.That(Guid.Empty, Is.EqualTo(result)); + } + + [Test] + public void GetUserId_UserIsNull_ThrowsException_WhenNotSuppressed() + { + // Arrange + var contextMock = new Mock(); + contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null); + // Act & Assert + Assert.Throws(() => _accessor.GetUserId(contextMock.Object, suppressException: false)); + } +} \ No newline at end of file diff --git a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs index 8b4264c..a57b78c 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/ChatsHubTests.cs @@ -1,4 +1,5 @@ using AutoFixture; +using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; @@ -13,6 +14,7 @@ public class ChatsHubTests private Mock> _loggerMock; private Mock _messageServiceMock; private Mock _userGroupsServiceMock; + private Mock _hubUserAccessorMock; private Fixture _fixture; private ChatsHub _chatsHub; @@ -26,11 +28,13 @@ public class ChatsHubTests _messageServiceMock = new Mock(); _userGroupsServiceMock = new Mock(); _loggerMock = new Mock>(); + _hubUserAccessorMock = new Mock(); _chatsHub = new ChatsHub( _loggerMock.Object, _messageServiceMock.Object, - _userGroupsServiceMock.Object + _userGroupsServiceMock.Object, + _hubUserAccessorMock.Object ); } diff --git a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs index 57010f7..ce1963c 100644 --- a/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs +++ b/Govor.API.Tests/IntegrationTests/Hubs/FriendsHubTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using AutoMapper; +using Govor.API.Common.SignalR.Helpers; using Govor.API.Hubs; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; @@ -17,7 +18,7 @@ namespace Govor.API.Tests.IntegrationTests.Hubs; public class FriendsHubTests { private Mock _friendRequestServiceMock = null!; - private Mock _currentUserServiceMock = null!; + private Mock _currentUserServiceMock = null!; private Mock _clientsMock = null!; private Mock _clientProxyMock = null!; private Mock> _loggerMock = null!; @@ -35,19 +36,23 @@ public class FriendsHubTests _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _friendRequestServiceMock = new Mock(); - _currentUserServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); _clientsMock = new Mock(); _clientProxyMock = new Mock(); _loggerMock = new Mock>(); _mapperMock = new Mock(); - _currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId); + _currentUserServiceMock.Setup(x => x.GetUserId( + It.IsAny(), + It.IsAny())) + .Returns(_userId); + _clientsMock.Setup(c => c.Group(It.IsAny())).Returns(_clientProxyMock.Object); _hub = new FriendsHub( + _loggerMock.Object, _friendRequestServiceMock.Object, _currentUserServiceMock.Object, - _loggerMock.Object, _mapperMock.Object) { Clients = _clientsMock.Object @@ -129,7 +134,7 @@ public class FriendsHubTests public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated() { // Arrange - _currentUserServiceMock.Setup(x => x.GetCurrentUserId()) + _currentUserServiceMock.Setup(x => x.GetUserId(It.IsAny(), It.IsAny())) .Throws(new UnauthorizedAccessException("userId claim is missing or invalid")); // Act diff --git a/Govor.API/Extensions/AddOptionExtensions.cs b/Govor.API/Common/Extensions/AddOptionExtensions.cs similarity index 91% rename from Govor.API/Extensions/AddOptionExtensions.cs rename to Govor.API/Common/Extensions/AddOptionExtensions.cs index aa0951c..060de2b 100644 --- a/Govor.API/Extensions/AddOptionExtensions.cs +++ b/Govor.API/Common/Extensions/AddOptionExtensions.cs @@ -1,6 +1,6 @@ using Govor.Application.Services.Authentication; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class AddOptionExtensions { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs similarity index 97% rename from Govor.API/Extensions/ConfigurationProgramExtensions.cs rename to Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs index be0667d..6a1602a 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Common/Extensions/ConfigurationProgramExtensions.cs @@ -1,3 +1,5 @@ +using Govor.API.Common.Mapping; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; @@ -34,7 +36,7 @@ using Govor.Data; using Govor.Data.Repositories; using Microsoft.EntityFrameworkCore; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfigurationProgramExtensions { @@ -83,6 +85,8 @@ public static class ConfigurationProgramExtensions // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); + + services.AddScoped(); } public static void AddRepositories(this IServiceCollection services) diff --git a/Govor.API/Extensions/ConfigurationSignalR.cs b/Govor.API/Common/Extensions/ConfigurationSignalR.cs similarity index 88% rename from Govor.API/Extensions/ConfigurationSignalR.cs rename to Govor.API/Common/Extensions/ConfigurationSignalR.cs index 69cbe0d..935eb6c 100644 --- a/Govor.API/Extensions/ConfigurationSignalR.cs +++ b/Govor.API/Common/Extensions/ConfigurationSignalR.cs @@ -1,7 +1,7 @@ using Govor.API.Filters; using Microsoft.AspNetCore.SignalR; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfigurationSignalR { diff --git a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs similarity index 92% rename from Govor.API/Extensions/ConfiguratorLoggerExtensions.cs rename to Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs index 1ef16b4..a8ccddc 100644 --- a/Govor.API/Extensions/ConfiguratorLoggerExtensions.cs +++ b/Govor.API/Common/Extensions/ConfiguratorLoggerExtensions.cs @@ -1,6 +1,6 @@ using Serilog; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Extensions; public static class ConfiguratorLoggerExtensions { diff --git a/Govor.API/Extensions/MappingProfile.cs b/Govor.API/Common/Mapping/MappingProfile.cs similarity index 75% rename from Govor.API/Extensions/MappingProfile.cs rename to Govor.API/Common/Mapping/MappingProfile.cs index 69045d6..5b15e9e 100644 --- a/Govor.API/Extensions/MappingProfile.cs +++ b/Govor.API/Common/Mapping/MappingProfile.cs @@ -1,11 +1,12 @@ using AutoMapper; +using Govor.API.Extensions.Mapping; using Govor.Contracts.DTOs; using Govor.Contracts.Responses; using Govor.Core.Models; using Govor.Core.Models.Messages; using Govor.Core.Models.Users; -namespace Govor.API.Extensions; +namespace Govor.API.Common.Mapping; public class MappingProfile : Profile { @@ -16,7 +17,9 @@ public class MappingProfile : Profile CreateMap(); CreateMap(); - CreateMap(); + CreateMap() + .AfterMap(); + CreateMap(); } } \ No newline at end of file diff --git a/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs b/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs new file mode 100644 index 0000000..c915fb3 --- /dev/null +++ b/Govor.API/Common/Mapping/UserToUserDtoMappingAction.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Contracts.DTOs; +using Govor.Core.Models.Users; + +namespace Govor.API.Extensions.Mapping; + +public class UserToUserDtoMappingAction : IMappingAction +{ + private readonly IOnlineUserStore _onlineUserStore; + + public UserToUserDtoMappingAction(IOnlineUserStore onlineUserStore) + { + _onlineUserStore = onlineUserStore; + } + + public void Process(User source, UserDto destination, ResolutionContext context) + { + destination.IsOnline = _onlineUserStore.IsOnline(source.Id); + } +} \ No newline at end of file diff --git a/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs b/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs new file mode 100644 index 0000000..2cf2ece --- /dev/null +++ b/Govor.API/Common/SignalR/Helpers/HubUserAccessor.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Common.SignalR.Helpers; + +public class HubUserAccessor : IHubUserAccessor +{ + private readonly ILogger _logger; + + public HubUserAccessor(ILogger logger) + { + _logger = logger; + } + + public Guid GetUserId(HubCallerContext context, bool suppressException = false) + { + var userIdClaim = context.User?.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + if (!suppressException) + { + _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/Common/SignalR/Helpers/IHubUserAccessor.cs b/Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs new file mode 100644 index 0000000..11f7604 --- /dev/null +++ b/Govor.API/Common/SignalR/Helpers/IHubUserAccessor.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Govor.API.Common.SignalR.Helpers; + +public interface IHubUserAccessor +{ + Guid GetUserId(HubCallerContext context, bool suppressException = false); +} diff --git a/Govor.API/Hubs/ChatsHub.cs b/Govor.API/Hubs/ChatsHub.cs index 2f50ea8..127460f 100644 --- a/Govor.API/Hubs/ChatsHub.cs +++ b/Govor.API/Hubs/ChatsHub.cs @@ -1,3 +1,4 @@ +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Interfaces; using Govor.Application.Interfaces.Messages; @@ -16,17 +17,19 @@ public class ChatsHub : Hub private readonly ILogger _logger; private readonly IMessageCommandService _messageCommandService; private readonly IUserGroupsService _userService; + private readonly IHubUserAccessor _userAccessor; - public ChatsHub(ILogger logger, IMessageCommandService messageCommandService, IUserGroupsService userService) + public ChatsHub(ILogger logger, IMessageCommandService messageCommandService, IUserGroupsService userService, IHubUserAccessor userAccessor) { _logger = logger; _messageCommandService = messageCommandService; _userService = userService; + _userAccessor = userAccessor; } public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -50,8 +53,7 @@ public class ChatsHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = - GetUserId(suppressException: true); + var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); @@ -85,7 +87,7 @@ public class ChatsHub : Hub public async Task> Send(MessageRequest request) { - var senderId = GetUserId(); + var senderId= _userAccessor.GetUserId(Context); if (string.IsNullOrWhiteSpace(request.EncryptedContent) && (request.MediaAttachments == null || !request.MediaAttachments.Any())) @@ -144,7 +146,7 @@ public class ChatsHub : Hub public async Task> Remove(RemoveMessageRequest request) { - var removerId = GetUserId(); + var removerId = _userAccessor.GetUserId(Context); _logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId); try @@ -184,7 +186,7 @@ public class ChatsHub : Hub public async Task> Edit(EditMessageRequest request) { - var editor = GetUserId(); + var editor = _userAccessor.GetUserId(Context); _logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor); var editMessageParam = new EditMessage(editor, @@ -303,22 +305,4 @@ public class ChatsHub : Hub _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); return HubResult.NotFound("Message not found."); } - - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _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 3fa025e..7aa9564 100644 --- a/Govor.API/Hubs/FriendsHub.cs +++ b/Govor.API/Hubs/FriendsHub.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using AutoMapper; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Exceptions.FriendsService; using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; @@ -13,23 +14,24 @@ public class FriendsHub : Hub { private readonly ILogger _logger; private readonly IFriendRequestCommandService _friendRequestService; - private readonly ICurrentUserService _currentUserService; + private readonly IHubUserAccessor _userAccessor; private readonly IMapper _mapper; - - public FriendsHub(IFriendRequestCommandService friendRequestService, - ICurrentUserService currentUserService, + + public FriendsHub( ILogger logger, + IFriendRequestCommandService friendRequestService, + IHubUserAccessor userAccessor, IMapper mapper) { - _friendRequestService = friendRequestService; - _currentUserService = currentUserService; _logger = logger; + _friendRequestService = friendRequestService; + _userAccessor = userAccessor; _mapper = mapper; } - + public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -46,8 +48,7 @@ public class FriendsHub : Hub } public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = - GetUserId(suppressException: true); + var userId = _userAccessor.GetUserId(Context, true); if (userId != Guid.Empty) { // Remove user from their own group @@ -76,7 +77,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.SendAsync(userId, targetUserId); await Clients.Group(targetUserId.ToString()) @@ -111,7 +112,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); await Clients.Group(userId.ToString()) .SendAsync("FriendRequestAccepted", _mapper.Map(friendship)); @@ -140,7 +141,7 @@ public class FriendsHub : Hub { try { - var userId = _currentUserService.GetCurrentUserId(); + var userId = _userAccessor.GetUserId(Context); var friendship = await _friendRequestService.RejectAsync(friendshipId, userId); await Clients.Group(userId.ToString()) .SendAsync("FriendRequestRejected", _mapper.Map(friendship)); @@ -164,21 +165,4 @@ public class FriendsHub : Hub return HubResult.Error("Unexpected error! Please try later!"); } } - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _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/PresenceHub.cs b/Govor.API/Hubs/PresenceHub.cs index fc23170..76843bc 100644 --- a/Govor.API/Hubs/PresenceHub.cs +++ b/Govor.API/Hubs/PresenceHub.cs @@ -1,5 +1,4 @@ -using Govor.Application.Interfaces; -using Govor.Application.Interfaces.Infrastructure.Extensions; +using Govor.API.Common.SignalR.Helpers; using Govor.Application.Interfaces.UserOnlineStatus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; @@ -14,17 +13,23 @@ public class PresenceHub : Hub private readonly ILogger _logger; private readonly IUserNotificationScopeService _notificationScopeService; private readonly IOnlineUserStore _onlineUserStore; - - public PresenceHub(ILogger logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore) + private readonly IHubUserAccessor _userAccessor; + + public PresenceHub( + ILogger logger, + IUserNotificationScopeService notificationScopeService, + IOnlineUserStore onlineUserStore, + IHubUserAccessor userAccessor) { _logger = logger; _notificationScopeService = notificationScopeService; _onlineUserStore = onlineUserStore; + _userAccessor = userAccessor; } public override async Task OnConnectedAsync() { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context); if (userId == Guid.Empty) { _logger.LogWarning("User connected with invalid UserID claim."); @@ -48,7 +53,7 @@ public class PresenceHub : Hub public override async Task OnDisconnectedAsync(Exception? exception) { - var userId = GetUserId(); + var userId = _userAccessor.GetUserId(Context, true); if (userId == Guid.Empty) return; _onlineUserStore.SetOfflineUser(userId); @@ -63,21 +68,4 @@ public class PresenceHub : Hub await base.OnDisconnectedAsync(exception); } - - private Guid GetUserId(bool suppressException = false) - { - var userIdClaim = Context.User?.FindFirst("userId")?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) - { - if (!suppressException) - { - _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/Program.cs b/Govor.API/Program.cs index 2e1886e..935a705 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -1,5 +1,5 @@ using System.Text; -using Govor.API.Extensions; +using Govor.API.Common.Extensions; using Govor.API.Hubs; using Govor.Application.Services.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; diff --git a/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs b/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs new file mode 100644 index 0000000..f97a677 --- /dev/null +++ b/Govor.Application.Tests/Services/UserOnlineStatus/OnlineUserStoreTests.cs @@ -0,0 +1,82 @@ +using Govor.Application.Interfaces.UserOnlineStatus; +using Govor.Application.Services.UserOnlineStatus; + +namespace Govor.Application.Tests.Services.UserOnlineStatus; + +[TestFixture] +public class OnlineUserStoreTests +{ + private IOnlineUserStore _store; + private Guid _userId1; + private Guid _userId2; + + [SetUp] + public void Setup() + { + _store = new OnlineUserStore(); + _userId1 = Guid.NewGuid(); + _userId2 = Guid.NewGuid(); + } + + [Test] + public void SetOnlineUser_UserIsMarkedOnline() + { + // Act + _store.SetOnlineUser(_userId1); + + // Assert + Assert.That(_store.IsOnline(_userId1), Is.True); + } + + [Test] + public void SetOfflineUser_UserIsNoLongerOnline() + { + // Act + _store.SetOnlineUser(_userId1); + _store.SetOfflineUser(_userId1); + + // Assert + Assert.That(_store.IsOnline(_userId1), Is.False); + } + + [Test] + public void IsOnline_ReturnsFalse_ForUnknownUser() + { + // Act & Assert + Assert.That(_store.IsOnline(Guid.NewGuid()), Is.False); + } + + [Test] + public void GetAllOnlineUsers_ReturnsAllCurrentlyOnlineUsers() + { + // Arrange + _store.SetOnlineUser(_userId1); + _store.SetOnlineUser(_userId2); + + // Act + var onlineUsers = _store.GetAllOnlineUsers(); + + // Assert + Assert.That(onlineUsers, Is.EquivalentTo(new[] { _userId1, _userId2 })); + } + + [Test] + public void SetOnlineUser_Twice_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => + { + _store.SetOnlineUser(_userId1); + _store.SetOnlineUser(_userId1); + }); + + Assert.That(_store.IsOnline(_userId1), Is.True); + } + + [Test] + public void SetOfflineUser_ForUnknownUser_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => _store.SetOfflineUser(Guid.NewGuid())); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs index a3813b9..2100472 100644 --- a/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs +++ b/Govor.Application/Services/UserOnlineStatus/OnlineUserStore.cs @@ -1,26 +1,29 @@ +using System.Collections.Concurrent; using Govor.Application.Interfaces.UserOnlineStatus; namespace Govor.Application.Services.UserOnlineStatus; public class OnlineUserStore : IOnlineUserStore { + private readonly ConcurrentDictionary _onlineUsers = new(); + public void SetOnlineUser(Guid userId) { - throw new NotImplementedException(); + _onlineUsers[userId] = DateTime.UtcNow; } public void SetOfflineUser(Guid userId) { - throw new NotImplementedException(); + _onlineUsers.TryRemove(userId, out _); } public bool IsOnline(Guid userId) { - throw new NotImplementedException(); + return _onlineUsers.ContainsKey(userId); } public IReadOnlyCollection GetAllOnlineUsers() { - throw new NotImplementedException(); + return _onlineUsers.Keys.ToList(); } -} \ No newline at end of file +}