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.
This commit is contained in:
Artemy
2025-07-23 22:21:51 +07:00
parent 31fdf4cb37
commit be0edb0f94
17 changed files with 326 additions and 96 deletions
@@ -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<ILogger<HubUserAccessor>> _loggerMock;
[SetUp]
public void SetUp()
{
_loggerMock = new Mock<ILogger<HubUserAccessor>>();
_accessor = new HubUserAccessor(_loggerMock.Object);
}
private HubCallerContext CreateContextWithClaims(params Claim[] claims)
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
var contextMock = new Mock<HubCallerContext>();
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<UnauthorizedAccessException>(() => _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<UnauthorizedAccessException>(() => _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<HubCallerContext>();
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<HubCallerContext>();
contextMock.Setup(c => c.User).Returns((ClaimsPrincipal?)null);
// Act & Assert
Assert.Throws<UnauthorizedAccessException>(() => _accessor.GetUserId(contextMock.Object, suppressException: false));
}
}
@@ -1,4 +1,5 @@
using AutoFixture; using AutoFixture;
using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs; using Govor.API.Hubs;
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages;
@@ -13,6 +14,7 @@ public class ChatsHubTests
private Mock<ILogger<ChatsHub>> _loggerMock; private Mock<ILogger<ChatsHub>> _loggerMock;
private Mock<IMessageCommandService> _messageServiceMock; private Mock<IMessageCommandService> _messageServiceMock;
private Mock<IUserGroupsService> _userGroupsServiceMock; private Mock<IUserGroupsService> _userGroupsServiceMock;
private Mock<IHubUserAccessor> _hubUserAccessorMock;
private Fixture _fixture; private Fixture _fixture;
private ChatsHub _chatsHub; private ChatsHub _chatsHub;
@@ -26,11 +28,13 @@ public class ChatsHubTests
_messageServiceMock = new Mock<IMessageCommandService>(); _messageServiceMock = new Mock<IMessageCommandService>();
_userGroupsServiceMock = new Mock<IUserGroupsService>(); _userGroupsServiceMock = new Mock<IUserGroupsService>();
_loggerMock = new Mock<ILogger<ChatsHub>>(); _loggerMock = new Mock<ILogger<ChatsHub>>();
_hubUserAccessorMock = new Mock<IHubUserAccessor>();
_chatsHub = new ChatsHub( _chatsHub = new ChatsHub(
_loggerMock.Object, _loggerMock.Object,
_messageServiceMock.Object, _messageServiceMock.Object,
_userGroupsServiceMock.Object _userGroupsServiceMock.Object,
_hubUserAccessorMock.Object
); );
} }
@@ -1,5 +1,6 @@
using AutoFixture; using AutoFixture;
using AutoMapper; using AutoMapper;
using Govor.API.Common.SignalR.Helpers;
using Govor.API.Hubs; using Govor.API.Hubs;
using Govor.Application.Exceptions.FriendsService; using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Friends;
@@ -17,7 +18,7 @@ namespace Govor.API.Tests.IntegrationTests.Hubs;
public class FriendsHubTests public class FriendsHubTests
{ {
private Mock<IFriendRequestCommandService> _friendRequestServiceMock = null!; private Mock<IFriendRequestCommandService> _friendRequestServiceMock = null!;
private Mock<ICurrentUserService> _currentUserServiceMock = null!; private Mock<IHubUserAccessor> _currentUserServiceMock = null!;
private Mock<IHubCallerClients> _clientsMock = null!; private Mock<IHubCallerClients> _clientsMock = null!;
private Mock<IClientProxy> _clientProxyMock = null!; private Mock<IClientProxy> _clientProxyMock = null!;
private Mock<ILogger<FriendsHub>> _loggerMock = null!; private Mock<ILogger<FriendsHub>> _loggerMock = null!;
@@ -35,19 +36,23 @@ public class FriendsHubTests
_fixture.Behaviors.Add(new OmitOnRecursionBehavior()); _fixture.Behaviors.Add(new OmitOnRecursionBehavior());
_friendRequestServiceMock = new Mock<IFriendRequestCommandService>(); _friendRequestServiceMock = new Mock<IFriendRequestCommandService>();
_currentUserServiceMock = new Mock<ICurrentUserService>(); _currentUserServiceMock = new Mock<IHubUserAccessor>();
_clientsMock = new Mock<IHubCallerClients>(); _clientsMock = new Mock<IHubCallerClients>();
_clientProxyMock = new Mock<IClientProxy>(); _clientProxyMock = new Mock<IClientProxy>();
_loggerMock = new Mock<ILogger<FriendsHub>>(); _loggerMock = new Mock<ILogger<FriendsHub>>();
_mapperMock = new Mock<IMapper>(); _mapperMock = new Mock<IMapper>();
_currentUserServiceMock.Setup(x => x.GetCurrentUserId()).Returns(_userId); _currentUserServiceMock.Setup(x => x.GetUserId(
It.IsAny<HubCallerContext>(),
It.IsAny<bool>()))
.Returns(_userId);
_clientsMock.Setup(c => c.Group(It.IsAny<string>())).Returns(_clientProxyMock.Object); _clientsMock.Setup(c => c.Group(It.IsAny<string>())).Returns(_clientProxyMock.Object);
_hub = new FriendsHub( _hub = new FriendsHub(
_loggerMock.Object,
_friendRequestServiceMock.Object, _friendRequestServiceMock.Object,
_currentUserServiceMock.Object, _currentUserServiceMock.Object,
_loggerMock.Object,
_mapperMock.Object) _mapperMock.Object)
{ {
Clients = _clientsMock.Object Clients = _clientsMock.Object
@@ -129,7 +134,7 @@ public class FriendsHubTests
public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated() public async Task SendRequest_ShouldReturnUnauthorized_WhenCurrentUserIsNotAuthenticated()
{ {
// Arrange // Arrange
_currentUserServiceMock.Setup(x => x.GetCurrentUserId()) _currentUserServiceMock.Setup(x => x.GetUserId(It.IsAny<HubCallerContext>(), It.IsAny<bool>()))
.Throws(new UnauthorizedAccessException("userId claim is missing or invalid")); .Throws(new UnauthorizedAccessException("userId claim is missing or invalid"));
// Act // Act
@@ -1,6 +1,6 @@
using Govor.Application.Services.Authentication; using Govor.Application.Services.Authentication;
namespace Govor.API.Extensions; namespace Govor.API.Common.Extensions;
public static class AddOptionExtensions public static class AddOptionExtensions
{ {
@@ -1,3 +1,5 @@
using Govor.API.Common.Mapping;
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.AdminsStuff;
using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Extensions;
using Govor.Application.Infrastructure.Validators; using Govor.Application.Infrastructure.Validators;
@@ -34,7 +36,7 @@ using Govor.Data;
using Govor.Data.Repositories; using Govor.Data.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Govor.API.Extensions; namespace Govor.API.Common.Extensions;
public static class ConfigurationProgramExtensions public static class ConfigurationProgramExtensions
{ {
@@ -83,6 +85,8 @@ public static class ConfigurationProgramExtensions
// Auto Mapper // Auto Mapper
services.AddAutoMapper(typeof(MappingProfile)); services.AddAutoMapper(typeof(MappingProfile));
services.AddScoped<IHubUserAccessor, HubUserAccessor>();
} }
public static void AddRepositories(this IServiceCollection services) public static void AddRepositories(this IServiceCollection services)
@@ -1,7 +1,7 @@
using Govor.API.Filters; using Govor.API.Filters;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Extensions; namespace Govor.API.Common.Extensions;
public static class ConfigurationSignalR public static class ConfigurationSignalR
{ {
@@ -1,6 +1,6 @@
using Serilog; using Serilog;
namespace Govor.API.Extensions; namespace Govor.API.Common.Extensions;
public static class ConfiguratorLoggerExtensions public static class ConfiguratorLoggerExtensions
{ {
@@ -1,11 +1,12 @@
using AutoMapper; using AutoMapper;
using Govor.API.Extensions.Mapping;
using Govor.Contracts.DTOs; using Govor.Contracts.DTOs;
using Govor.Contracts.Responses; using Govor.Contracts.Responses;
using Govor.Core.Models; using Govor.Core.Models;
using Govor.Core.Models.Messages; using Govor.Core.Models.Messages;
using Govor.Core.Models.Users; using Govor.Core.Models.Users;
namespace Govor.API.Extensions; namespace Govor.API.Common.Mapping;
public class MappingProfile : Profile public class MappingProfile : Profile
{ {
@@ -16,7 +17,9 @@ public class MappingProfile : Profile
CreateMap<MessageReaction, MessageReactionResponse>(); CreateMap<MessageReaction, MessageReactionResponse>();
CreateMap<MessageView, MessageViewResponse>(); CreateMap<MessageView, MessageViewResponse>();
CreateMap<User, UserDto>(); CreateMap<User, UserDto>()
.AfterMap<UserToUserDtoMappingAction>();
CreateMap<Friendship, FriendshipDto>(); CreateMap<Friendship, FriendshipDto>();
} }
} }
@@ -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<User, UserDto>
{
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);
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Common.SignalR.Helpers;
public class HubUserAccessor : IHubUserAccessor
{
private readonly ILogger<HubUserAccessor> _logger;
public HubUserAccessor(ILogger<HubUserAccessor> 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;
}
}
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.SignalR;
namespace Govor.API.Common.SignalR.Helpers;
public interface IHubUserAccessor
{
Guid GetUserId(HubCallerContext context, bool suppressException = false);
}
+9 -25
View File
@@ -1,3 +1,4 @@
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Exceptions.VerifyFriendship; using Govor.Application.Exceptions.VerifyFriendship;
using Govor.Application.Interfaces; using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Messages; using Govor.Application.Interfaces.Messages;
@@ -16,17 +17,19 @@ public class ChatsHub : Hub
private readonly ILogger<ChatsHub> _logger; private readonly ILogger<ChatsHub> _logger;
private readonly IMessageCommandService _messageCommandService; private readonly IMessageCommandService _messageCommandService;
private readonly IUserGroupsService _userService; private readonly IUserGroupsService _userService;
private readonly IHubUserAccessor _userAccessor;
public ChatsHub(ILogger<ChatsHub> logger, IMessageCommandService messageCommandService, IUserGroupsService userService) public ChatsHub(ILogger<ChatsHub> logger, IMessageCommandService messageCommandService, IUserGroupsService userService, IHubUserAccessor userAccessor)
{ {
_logger = logger; _logger = logger;
_messageCommandService = messageCommandService; _messageCommandService = messageCommandService;
_userService = userService; _userService = userService;
_userAccessor = userAccessor;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
var userId = GetUserId(); var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty) if (userId == Guid.Empty)
{ {
_logger.LogWarning("User connected with invalid UserID claim."); _logger.LogWarning("User connected with invalid UserID claim.");
@@ -50,8 +53,7 @@ public class ChatsHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = var userId = _userAccessor.GetUserId(Context, true);
GetUserId(suppressException: true);
if (userId != Guid.Empty) if (userId != Guid.Empty)
{ {
await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString()); await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId.ToString());
@@ -85,7 +87,7 @@ public class ChatsHub : Hub
public async Task<HubResult<UserMessageResponse>> Send(MessageRequest request) public async Task<HubResult<UserMessageResponse>> Send(MessageRequest request)
{ {
var senderId = GetUserId(); var senderId= _userAccessor.GetUserId(Context);
if (string.IsNullOrWhiteSpace(request.EncryptedContent) && if (string.IsNullOrWhiteSpace(request.EncryptedContent) &&
(request.MediaAttachments == null || !request.MediaAttachments.Any())) (request.MediaAttachments == null || !request.MediaAttachments.Any()))
@@ -144,7 +146,7 @@ public class ChatsHub : Hub
public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request) public async Task<HubResult<MessageRemovedResponse>> Remove(RemoveMessageRequest request)
{ {
var removerId = GetUserId(); var removerId = _userAccessor.GetUserId(Context);
_logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId); _logger.LogInformation("Removing message {MessageId} by user {RemoverId}", request.MessageId, removerId);
try try
@@ -184,7 +186,7 @@ public class ChatsHub : Hub
public async Task<HubResult<MessageEditResponse>> Edit(EditMessageRequest request) public async Task<HubResult<MessageEditResponse>> Edit(EditMessageRequest request)
{ {
var editor = GetUserId(); var editor = _userAccessor.GetUserId(Context);
_logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor); _logger.LogInformation("Editing message {MessageId} by user {EditorId}", request.MessageId, editor);
var editMessageParam = new EditMessage(editor, var editMessageParam = new EditMessage(editor,
@@ -303,22 +305,4 @@ public class ChatsHub : Hub
_logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId); _logger.LogWarning(ex, "{Msg}: {UserId} -> {TargetId}", msg, userId, targetId);
return HubResult<T>.NotFound("Message not found."); return HubResult<T>.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;
}
} }
+14 -30
View File
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using AutoMapper; using AutoMapper;
using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Exceptions.FriendsService; using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Friends;
using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Infrastructure.Extensions;
@@ -13,23 +14,24 @@ public class FriendsHub : Hub
{ {
private readonly ILogger<FriendsHub> _logger; private readonly ILogger<FriendsHub> _logger;
private readonly IFriendRequestCommandService _friendRequestService; private readonly IFriendRequestCommandService _friendRequestService;
private readonly ICurrentUserService _currentUserService; private readonly IHubUserAccessor _userAccessor;
private readonly IMapper _mapper; private readonly IMapper _mapper;
public FriendsHub(IFriendRequestCommandService friendRequestService, public FriendsHub(
ICurrentUserService currentUserService,
ILogger<FriendsHub> logger, ILogger<FriendsHub> logger,
IFriendRequestCommandService friendRequestService,
IHubUserAccessor userAccessor,
IMapper mapper) IMapper mapper)
{ {
_friendRequestService = friendRequestService;
_currentUserService = currentUserService;
_logger = logger; _logger = logger;
_friendRequestService = friendRequestService;
_userAccessor = userAccessor;
_mapper = mapper; _mapper = mapper;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
var userId = GetUserId(); var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty) if (userId == Guid.Empty)
{ {
_logger.LogWarning("User connected with invalid UserID claim."); _logger.LogWarning("User connected with invalid UserID claim.");
@@ -46,8 +48,7 @@ public class FriendsHub : Hub
} }
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = var userId = _userAccessor.GetUserId(Context, true);
GetUserId(suppressException: true);
if (userId != Guid.Empty) if (userId != Guid.Empty)
{ {
// Remove user from their own group // Remove user from their own group
@@ -76,7 +77,7 @@ public class FriendsHub : Hub
{ {
try try
{ {
var userId = _currentUserService.GetCurrentUserId(); var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.SendAsync(userId, targetUserId); var friendship = await _friendRequestService.SendAsync(userId, targetUserId);
await Clients.Group(targetUserId.ToString()) await Clients.Group(targetUserId.ToString())
@@ -111,7 +112,7 @@ public class FriendsHub : Hub
{ {
try try
{ {
var userId = _currentUserService.GetCurrentUserId(); var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId); var friendship = await _friendRequestService.AcceptAsync(friendshipId, userId);
await Clients.Group(userId.ToString()) await Clients.Group(userId.ToString())
.SendAsync("FriendRequestAccepted", _mapper.Map<FriendshipDto>(friendship)); .SendAsync("FriendRequestAccepted", _mapper.Map<FriendshipDto>(friendship));
@@ -140,7 +141,7 @@ public class FriendsHub : Hub
{ {
try try
{ {
var userId = _currentUserService.GetCurrentUserId(); var userId = _userAccessor.GetUserId(Context);
var friendship = await _friendRequestService.RejectAsync(friendshipId, userId); var friendship = await _friendRequestService.RejectAsync(friendshipId, userId);
await Clients.Group(userId.ToString()) await Clients.Group(userId.ToString())
.SendAsync("FriendRequestRejected", _mapper.Map<FriendshipDto>(friendship)); .SendAsync("FriendRequestRejected", _mapper.Map<FriendshipDto>(friendship));
@@ -164,21 +165,4 @@ public class FriendsHub : Hub
return HubResult<object>.Error("Unexpected error! Please try later!"); return HubResult<object>.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;
}
} }
+11 -23
View File
@@ -1,5 +1,4 @@
using Govor.Application.Interfaces; using Govor.API.Common.SignalR.Helpers;
using Govor.Application.Interfaces.Infrastructure.Extensions;
using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserOnlineStatus;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -14,17 +13,23 @@ public class PresenceHub : Hub
private readonly ILogger<PresenceHub> _logger; private readonly ILogger<PresenceHub> _logger;
private readonly IUserNotificationScopeService _notificationScopeService; private readonly IUserNotificationScopeService _notificationScopeService;
private readonly IOnlineUserStore _onlineUserStore; private readonly IOnlineUserStore _onlineUserStore;
private readonly IHubUserAccessor _userAccessor;
public PresenceHub(ILogger<PresenceHub> logger, IUserNotificationScopeService notificationScopeService, IOnlineUserStore onlineUserStore)
public PresenceHub(
ILogger<PresenceHub> logger,
IUserNotificationScopeService notificationScopeService,
IOnlineUserStore onlineUserStore,
IHubUserAccessor userAccessor)
{ {
_logger = logger; _logger = logger;
_notificationScopeService = notificationScopeService; _notificationScopeService = notificationScopeService;
_onlineUserStore = onlineUserStore; _onlineUserStore = onlineUserStore;
_userAccessor = userAccessor;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
var userId = GetUserId(); var userId = _userAccessor.GetUserId(Context);
if (userId == Guid.Empty) if (userId == Guid.Empty)
{ {
_logger.LogWarning("User connected with invalid UserID claim."); _logger.LogWarning("User connected with invalid UserID claim.");
@@ -48,7 +53,7 @@ public class PresenceHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
var userId = GetUserId(); var userId = _userAccessor.GetUserId(Context, true);
if (userId == Guid.Empty) return; if (userId == Guid.Empty) return;
_onlineUserStore.SetOfflineUser(userId); _onlineUserStore.SetOfflineUser(userId);
@@ -63,21 +68,4 @@ public class PresenceHub : Hub
await base.OnDisconnectedAsync(exception); 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;
}
} }
+1 -1
View File
@@ -1,5 +1,5 @@
using System.Text; using System.Text;
using Govor.API.Extensions; using Govor.API.Common.Extensions;
using Govor.API.Hubs; using Govor.API.Hubs;
using Govor.Application.Services.Authentication; using Govor.Application.Services.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -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()));
}
}
@@ -1,26 +1,29 @@
using System.Collections.Concurrent;
using Govor.Application.Interfaces.UserOnlineStatus; using Govor.Application.Interfaces.UserOnlineStatus;
namespace Govor.Application.Services.UserOnlineStatus; namespace Govor.Application.Services.UserOnlineStatus;
public class OnlineUserStore : IOnlineUserStore public class OnlineUserStore : IOnlineUserStore
{ {
private readonly ConcurrentDictionary<Guid, DateTime> _onlineUsers = new();
public void SetOnlineUser(Guid userId) public void SetOnlineUser(Guid userId)
{ {
throw new NotImplementedException(); _onlineUsers[userId] = DateTime.UtcNow;
} }
public void SetOfflineUser(Guid userId) public void SetOfflineUser(Guid userId)
{ {
throw new NotImplementedException(); _onlineUsers.TryRemove(userId, out _);
} }
public bool IsOnline(Guid userId) public bool IsOnline(Guid userId)
{ {
throw new NotImplementedException(); return _onlineUsers.ContainsKey(userId);
} }
public IReadOnlyCollection<Guid> GetAllOnlineUsers() public IReadOnlyCollection<Guid> GetAllOnlineUsers()
{ {
throw new NotImplementedException(); return _onlineUsers.Keys.ToList();
} }
} }