diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5a200e37..86db1e9a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -152,4 +152,4 @@ jobs: - name: Run Performance Tests env: BASE_URL: ${{ env.SERVER_URL }} - run: k6 run server/Tests/performance/load-test.js \ No newline at end of file + run: k6 run server/Tests/Performance/load-test.js \ No newline at end of file diff --git a/server/Tests/Integration/ControllerTests/AuthControllerTests.cs b/server/Tests/Integration/ControllerTests/AuthControllerTests.cs index 0612b695..8aae1278 100644 --- a/server/Tests/Integration/ControllerTests/AuthControllerTests.cs +++ b/server/Tests/Integration/ControllerTests/AuthControllerTests.cs @@ -202,7 +202,7 @@ public async Task Login_ShouldReturn400_WhenLoginFails() var response = await _client.PostAsJsonAsync("api/auth/login", new LoginCommand { Email = "nobody@nowhere.com", - Password = "wrongpassword" + Password = "wrong_password" }, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); diff --git a/server/Tests/Integration/RepositoryTests/ChatMessageRepositoryTests.cs b/server/Tests/Integration/RepositoryTests/ChatMessageRepositoryTests.cs index 9cba6566..c9f94db2 100644 --- a/server/Tests/Integration/RepositoryTests/ChatMessageRepositoryTests.cs +++ b/server/Tests/Integration/RepositoryTests/ChatMessageRepositoryTests.cs @@ -47,7 +47,7 @@ public async Task AddAsync_ShouldPersistMessage() var msg = MakeMessage(); var result = await _repo.AddAsync(msg); Assert.NotEqual(Guid.Empty, result.Id); - var fromDb = await _context.ChatMessages.FindAsync(result.Id, TestContext.Current.CancellationToken); + var fromDb = await _context.ChatMessages.FindAsync([result.Id], TestContext.Current.CancellationToken); Assert.NotNull(fromDb); } @@ -72,8 +72,7 @@ public async Task DeleteAsync_ByEntity_ShouldRemoveMessage() var msg = await _repo.AddAsync(MakeMessage()); var result = await _repo.DeleteAsync(msg); Assert.True(result); - Assert.Null(await _context.ChatMessages.FindAsync(msg.Id, TestContext.Current.CancellationToken)); - } + Assert.Null(await _context.ChatMessages.FindAsync([msg.Id], TestContext.Current.CancellationToken)); } [Fact] public async Task DeleteAsync_ById_ShouldReturnTrue_WhenExists() diff --git a/server/Tests/Integration/RepositoryTests/ChatRoomRepositoryTests.cs b/server/Tests/Integration/RepositoryTests/ChatRoomRepositoryTests.cs index 7203175c..b016d2a3 100644 --- a/server/Tests/Integration/RepositoryTests/ChatRoomRepositoryTests.cs +++ b/server/Tests/Integration/RepositoryTests/ChatRoomRepositoryTests.cs @@ -41,7 +41,7 @@ public async Task AddAsync_ShouldPersistRoom() { var room = await _repo.AddAsync(MakeRoom()); Assert.NotEqual(Guid.Empty, room.Id); - Assert.NotNull(await _context.ChatRooms.FindAsync(room.Id, TestContext.Current.CancellationToken)); + Assert.NotNull(await _context.ChatRooms.FindAsync([room.Id], TestContext.Current.CancellationToken)); } [Fact] diff --git a/server/Tests/Integration/RepositoryTests/NotificationRepositoryTests.cs b/server/Tests/Integration/RepositoryTests/NotificationRepositoryTests.cs index c2c40581..187e1671 100644 --- a/server/Tests/Integration/RepositoryTests/NotificationRepositoryTests.cs +++ b/server/Tests/Integration/RepositoryTests/NotificationRepositoryTests.cs @@ -44,8 +44,7 @@ public async Task AddAsync_ShouldPersistNotification() { var n = await _repo.AddAsync(MakeNotification()); Assert.NotEqual(Guid.Empty, n.Id); - Assert.NotNull(await _context.Notifications.FindAsync(n.Id, TestContext.Current.CancellationToken)); - } + Assert.NotNull(await _context.Notifications.FindAsync([n.Id], TestContext.Current.CancellationToken)); } [Fact] public async Task FindByIdAsync_ShouldReturnNotification_WhenExists() diff --git a/server/Tests/Unit/AuthControllerTests.cs b/server/Tests/Unit/AuthControllerTests.cs new file mode 100644 index 00000000..0f0700d3 --- /dev/null +++ b/server/Tests/Unit/AuthControllerTests.cs @@ -0,0 +1,216 @@ +using System.Security.Claims; +using Api.Controllers; +using Application.Common.Interfaces.Features; +using Application.Common.Results; +using Application.DTOs.Auth; +using Application.DTOs.Entities; +using Application.DTOs.Responses; +using Application.Features.Auth.Login; +using Application.Features.Auth.Register; +using Domain.Enums; +using Domain.Settings; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; + +namespace Unit; + +public class AuthControllerTests +{ + private readonly IAuthFeature _authFeature = Substitute.For(); + private readonly AppSettings _appSettings; + private readonly AuthController _controller; + + public AuthControllerTests() + { + _appSettings = new AppSettings( + new CorsSettings { AllowedOrigins = new[] { "https://localhost" } }, + new JwtSettings + { + Secret = new string('A', 32), + Issuer = "issuer", + Audience = "audience", + AccessTokenLifetime = 15, + RefreshTokenLifetime = 60 + }, + new DbSettings + { + PSqlConnectionString = "Host=localhost;Database=test;Username=test;Password=test", + RedisConnectionString = "localhost:6379" + }); + + _controller = new AuthController(_authFeature, _appSettings) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + private void Authenticate(Guid userId, params string[] roles) + { + var claims = new List { new(ClaimTypes.NameIdentifier, userId.ToString()) }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) + } + }; + } + + [Fact] + public async Task Login_ShouldReturnNoContent_WhenUserIsAlreadyAuthenticated() + { + Authenticate(Guid.NewGuid()); + + var result = await _controller.Login(new LoginCommand { Email = "test@example.com", Password = "password" }); + + Assert.IsType(result); + } + + [Fact] + public async Task Login_ShouldReturnUnauthorized_WhenLoginFailsWithUnauthorized() + { + _authFeature.HandleLogin(Arg.Any()) + .Returns(Result.Failure("invalid", ResultStatus.Unauthorized)); + + var result = await _controller.Login(new LoginCommand { Email = "test@example.com", Password = "password" }); + + Assert.IsType(result); + } + + [Fact] + public async Task Login_ShouldReturnBadRequest_WhenLoginFailsWithFailure() + { + _authFeature.HandleLogin(Arg.Any()) + .Returns(Result.Failure("failure", ResultStatus.Failure)); + + var result = await _controller.Login(new LoginCommand { Email = "test@example.com", Password = "password" }); + + Assert.IsType(result); + } + + [Fact] + public async Task Login_ShouldReturnOkAndAppendCookies_WhenLoginSucceeds() + { + var userDto = new UserDto { Email = "user@example.com", Username = "User Name" }; + var responseDto = new LoginResponseDto + { + AccessToken = "access-token", + RefreshToken = "refresh-token", + User = userDto + }; + + _authFeature.HandleLogin(Arg.Any()) + .Returns(Result.Success(responseDto)); + + var result = await _controller.Login(new LoginCommand { Email = "test@example.com", Password = "password" }); + + var ok = Assert.IsType(result); + Assert.Same(userDto, ok.Value); + var setCookieHeaders = _controller.Response.Headers["Set-Cookie"].ToString(); + Assert.Contains("accessToken=access-token", setCookieHeaders); + Assert.Contains("refreshToken=refresh-token", setCookieHeaders); + } + + [Fact] + public async Task Register_ShouldReturnUnauthorized_WhenUserIsNotAllowedToRegisterAdmin() + { + Authenticate(Guid.NewGuid()); + + var request = new RegisterUserCommand + { + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + Password = "password", + Role = RoleType.Admin + }; + + var result = await _controller.Register(request); + + var unauthorized = Assert.IsType(result); + Assert.Contains("not authorized", unauthorized.Value!.ToString()!); + } + + [Fact] + public async Task Register_ShouldReturnOk_WhenRegisterSucceeds() + { + var request = new RegisterUserCommand + { + FirstName = "Test", + LastName = "User", + Email = "test@example.com", + Password = "password", + Role = RoleType.User + }; + + _authFeature.HandleRegisterUser(request) + .Returns(Result.Success("ok")); + + var result = await _controller.Register(request); + + var ok = Assert.IsType(result); + Assert.IsType(ok.Value); + } + + [Fact] + public async Task GetMe_ShouldReturnOk_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + var dto = new UserDto { Email = "me@example.com", Username = "Me" }; + _authFeature.HandleMeRequest(userId) + .Returns(Result.Success(dto)); + + var result = await _controller.GetMe(); + + var ok = Assert.IsType(result); + Assert.Same(dto, ok.Value); + } + + [Fact] + public async Task GetMe_ShouldReturnUnauthorized_WhenUserIdIsInvalid() + { + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) } + }; + + var result = await _controller.GetMe(); + + Assert.IsType(result); + } + + [Fact] + public async Task Logout_ShouldReturnOk_WhenRevokeRefreshTokenSucceeds() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + _authFeature.RevokeRefreshToken(userId) + .Returns(Task.FromResult(Result.Success())); + + var result = await _controller.Logout(); + + Assert.IsType(result); + } + + [Fact] + public async Task Logout_ShouldReturnUnauthorized_WhenGetUserIdThrows() + { + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) } + }; + + var result = await _controller.Logout(); + + Assert.IsType(result); + } +} diff --git a/server/Tests/Unit/ChatControllerTests.cs b/server/Tests/Unit/ChatControllerTests.cs new file mode 100644 index 00000000..b466f5e5 --- /dev/null +++ b/server/Tests/Unit/ChatControllerTests.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using Application.Common.Interfaces; +using Application.Common.Interfaces.Features; +using Application.Common.Results; +using Application.DTOs.Chat; +using Api.Controllers; +using Domain.Interfaces.Repositories; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; + +namespace Unit; + +public class ChatControllerTests +{ + private readonly IChatFeature _chatFeature = Substitute.For(); + private readonly IChatRoomRepository _roomRepository = Substitute.For(); + private readonly ISimpleSse _backplane = Substitute.For(); + private readonly ChatController _controller; + + public ChatControllerTests() + { + _controller = new ChatController(_chatFeature, _roomRepository, _backplane) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + private void Authenticate(Guid userId) + { + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal( + new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) }, "Test") + ); + _controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + } + + [Fact] + public async Task SendMessage_ShouldReturnOk_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + var request = new SendMessageRequest(Guid.NewGuid(), "Hello world"); + var dto = new ChatMessageDto(request.RoomId, request.RoomId, null, request.Content, DateTime.UtcNow); + + _chatFeature.CreateMessageAsync(userId, request) + .Returns(Result.Success(dto)); + + var result = await _controller.SendMessage(request); + + var ok = Assert.IsType(result); + Assert.Same(dto, ok.Value); + await _backplane.Received(1).SendToGroupAsync(request.RoomId, request, "message"); + } + + [Fact] + public async Task SendMessage_ShouldReturnUnauthorized_WhenUserIsNotAuthenticated() + { + var request = new SendMessageRequest(Guid.NewGuid(), "Hello"); + + var result = await _controller.SendMessage(request); + + Assert.IsType(result); + } + + [Fact] + public async Task GetMessages_ShouldReturnOk_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + _chatFeature.GetMessagesAsync(userId, Arg.Any(), 0, 50) + .Returns(Result>.Success(Array.Empty())); + + var result = await _controller.GetMessages(Guid.NewGuid()); + + var ok = Assert.IsType(result); + Assert.IsType>>(ok.Value); + } + + [Fact] + public async Task GetMessages_ShouldReturnForbidden_WhenFeatureReturnsUnauthorized() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + _chatFeature.GetMessagesAsync(userId, Arg.Any(), 0, 50) + .Returns(Result>.Failure("Unauthorized", ResultStatus.Unauthorized)); + + var result = await _controller.GetMessages(Guid.NewGuid()); + + Assert.IsType(result); + } + + [Fact] + public async Task CreateRoom_ShouldReturnCreated_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + + var request = new CreateRoomRequest("Test Room", "Description"); + var roomDto = new ChatRoomDto { Id = Guid.NewGuid(), Name = request.Name, Description = request.Description, CreatedAt = DateTime.UtcNow }; + + _chatFeature.CreateRoomAsync(userId, request) + .Returns(Result.Success(roomDto)); + + var result = await _controller.CreateRoom(request); + + var created = Assert.IsType(result); + Assert.Equal(nameof(ChatController.GetMessages), created.ActionName); + Assert.Same(roomDto, created.Value); + } + + [Fact] + public async Task GetAllRooms_ShouldReturnOkWithRoomList() + { + var room = Domain.Entities.ChatRoom.Create("Shared", Guid.NewGuid()); + _roomRepository.GetAllRoomsAsync().Returns(Task.FromResult((IEnumerable)new[] { room })); + + var result = await _controller.GetAllRooms(); + + var ok = Assert.IsType(result); + Assert.Equal(new[] { room }, Assert.IsAssignableFrom>(ok.Value)); + } + + [Fact] + public async Task GetMyRooms_ShouldReturnUnauthorized_WhenUserIsNotAuthenticated() + { + var result = await _controller.GetMyRooms(); + Assert.IsType(result); + } + + [Fact] + public async Task SearchForRoom_ShouldReturnNotFound_WhenFeatureReturnsFailure() + { + Authenticate(Guid.NewGuid()); + _chatFeature.SearchRoomByNameAsync(Arg.Any()) + .Returns(Result>.Failure("No room found")); + + var result = await _controller.SearchForRoom("missing"); + + Assert.IsType(result); + } + + [Fact] + public async Task JoinRoom_ShouldReturnBadRequest_WhenFeatureReturnsFailure() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + _chatFeature.JoinRoomAsync(userId, Arg.Any()) + .Returns(Result.Failure("already member", ResultStatus.Failure)); + + var result = await _controller.JoinRoom(Guid.NewGuid()); + + Assert.IsType(result); + } + + [Fact] + public async Task GetRoomMembers_ShouldReturnOk_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + var members = new[] { new RoomMemberDto { UserId = Guid.NewGuid(), FirstName = "A", LastName = "B", Email = "a@b.com" } }; + + _chatFeature.GetRoomMembersAsync(userId, Arg.Any()) + .Returns(Result>.Success(members)); + + var result = await _controller.GetRoomMembers(Guid.NewGuid()); + + var ok = Assert.IsType(result); + Assert.Equal(members, Assert.IsAssignableFrom>(ok.Value)); + } + + [Fact] + public async Task LeaveRoom_ShouldReturnOk_WhenFeatureReturnsSuccess() + { + var userId = Guid.NewGuid(); + Authenticate(userId); + _chatFeature.LeaveRoomAsync(userId, Arg.Any()) + .Returns(Result.Success("left")); + + var result = await _controller.LeaveRoom(Guid.NewGuid()); + + Assert.IsType(result); + } + + [Fact] + public void GetConnectionCount_ShouldThrowNotImplementedException() + { + Assert.Throws(() => _controller.GetConnectionCount(Guid.NewGuid())); + } +} diff --git a/server/Tests/Unit/CookiesHelperTests.cs b/server/Tests/Unit/CookiesHelperTests.cs new file mode 100644 index 00000000..93ce2ee9 --- /dev/null +++ b/server/Tests/Unit/CookiesHelperTests.cs @@ -0,0 +1,58 @@ +using Infrastructure.Utils; + +namespace Unit; + +public class CookieHelperTests +{ + [Fact] + public void CreateAccessTokenCookieOptions_ShouldUseMinutesAsMaxAge() + { + var options = CookieHelper.CreateAccessTokenCookieOptions(10); + + Assert.Equal(TimeSpan.FromMinutes(10), options.MaxAge); + Assert.True(options.HttpOnly); + Assert.Equal("/", options.Path); + } + + [Fact] + public void CreateRefreshTokenCookieOptions_ShouldUseDays_WhenNotDevelopmentEnvironment() + { + var original = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + var options = CookieHelper.CreateRefreshTokenCookieOptions(7); + + Assert.Equal(TimeSpan.FromDays(7), options.MaxAge); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", original); + } + } + + [Fact] + public void CreateRefreshTokenCookieOptions_ShouldUseMinutes_WhenDevelopmentEnvironment() + { + var original = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + var options = CookieHelper.CreateRefreshTokenCookieOptions(10); + + Assert.Equal(TimeSpan.FromMinutes(10), options.MaxAge); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", original); + } + } + + [Fact] + public void CreateExpiredCookieOptions_ShouldReturnZeroMaxAge() + { + var options = CookieHelper.CreateExpiredCookieOptions(); + + Assert.Equal(TimeSpan.Zero, options.MaxAge); + } +} diff --git a/server/Tests/Unit/NotificationServiceTests.cs b/server/Tests/Unit/NotificationServiceTests.cs new file mode 100644 index 00000000..5fb70009 --- /dev/null +++ b/server/Tests/Unit/NotificationServiceTests.cs @@ -0,0 +1,77 @@ +using Application.Common.Interfaces; +using Application.Common.Interfaces.Services; +using Application.Services; +using Domain.Entities; +using Domain.Enums; +using Domain.Interfaces.Repositories; +using NSubstitute; + +namespace Unit; + +public class NotificationServiceTests +{ + private readonly INotificationRepository _notificationRepository = Substitute.For(); + private readonly IChatRoomRepository _roomRepository = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly ISimpleSse _backplane = Substitute.For(); + private readonly NotificationService _service; + + public NotificationServiceTests() + { + _service = new NotificationService( + _notificationRepository, + _roomRepository, + _userRepository, + _backplane); + } + + [Fact] + public async Task NotifyPokeAsync_ShouldCreateNotificationAndSendEvent() + { + var pokerId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var poker = User.Create("Poke", "Master", "poker@example.com", new byte[] { 1, 2, 3 }); + + _userRepository.FindByIdAsync(pokerId).Returns(poker); + _notificationRepository.AddAsync(Arg.Any()) + .Returns(call => Task.FromResult(call.Arg())); + + await _service.NotifyPokeAsync(pokerId, targetUserId); + + await _notificationRepository.Received(1).AddAsync(Arg.Is(n => + n.RecipientId == targetUserId && + n.Type == NotificationType.Poke && + n.Payload.Contains(pokerId.ToString()))); + + await _backplane.Received(1).SendToUserAsync(targetUserId, Arg.Any(), "notification"); + } + + [Fact] + public async Task NotifyNewMessageAsync_ShouldNotifyAllExceptSender() + { + var senderId = Guid.NewGuid(); + var roomId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + + var sender = User.Create("Sender", "One", "sender@example.com", new byte[] { 1, 2, 3 }); + var room = ChatRoom.Create("Room", Guid.NewGuid()); + room = room with { Name = "Room" }; + var otherUser = User.Create("Target", "User", "target@example.com", new byte[] { 4, 5, 6 }); + var member = ChatRoomMember.Create(roomId, otherUser.Id) with { User = otherUser, Room = room }; + + _roomRepository.GetMembersAsync(roomId).Returns(new[] { member, ChatRoomMember.Create(roomId, senderId) with { User = sender, Room = room } }); + _roomRepository.FindByIdAsync(roomId).Returns(room); + _userRepository.FindByIdAsync(senderId).Returns(sender); + _notificationRepository.AddAsync(Arg.Any()) + .Returns(call => Task.FromResult(call.Arg())); + + await _service.NotifyNewMessageAsync(senderId, roomId, messageId, "Hello"); + + await _notificationRepository.Received(1).AddAsync(Arg.Is(n => + n.RecipientId == otherUser.Id && + n.Type == NotificationType.NewMessage && + n.Payload.Contains("Hello"))); + + await _backplane.Received(1).SendToUserAsync(otherUser.Id, Arg.Any(), "notification"); + } +} diff --git a/server/Tests/Unit/ServiceManagerTests.cs b/server/Tests/Unit/ServiceManagerTests.cs new file mode 100644 index 00000000..70cc2c54 --- /dev/null +++ b/server/Tests/Unit/ServiceManagerTests.cs @@ -0,0 +1,88 @@ +using Api.Config; +using Application.Common.Interfaces; +using Application.Common.Interfaces.Features; +using Application.Common.Interfaces.Services; +using Domain.Settings; +using Infrastructure.Auth; +using Infrastructure.Sse; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using StackExchange.Redis; + +namespace Unit; + +public class ServiceManagerTests +{ + [Fact] + public void ConfigureAndInitializeServices_ShouldRegisterRequiredServices() + { + var services = new ServiceCollection(); + var env = Substitute.For(); + env.EnvironmentName.Returns(Environments.Development); + + var appSettings = new AppSettings( + new CorsSettings { AllowedOrigins = new[] { "https://localhost" } }, + new JwtSettings + { + Secret = new string('A', 32), + Issuer = "issuer", + Audience = "audience", + AccessTokenLifetime = 60, + RefreshTokenLifetime = 120 + }, + new DbSettings + { + PSqlConnectionString = "Host=localhost;Database=test;Username=test;Password=test", + RedisConnectionString = "localhost:6379" + } + ); + + var manager = new ServiceManager(services, appSettings, env); + manager.ConfigureAndInitializeServices(); + + var serviceTypes = services.Select(d => d.ServiceType).ToHashSet(); + + Assert.Contains(typeof(AppSettings), serviceTypes); + Assert.Contains(typeof(DbSettings), serviceTypes); + Assert.Contains(typeof(JwtSettings), serviceTypes); + Assert.Contains(typeof(CorsSettings), serviceTypes); + Assert.Contains(typeof(IJwt), serviceTypes); + Assert.Contains(typeof(IChatFeature), serviceTypes); + Assert.Contains(typeof(INotificationService), serviceTypes); + Assert.Contains(typeof(IFeatureStateProvider), serviceTypes); + Assert.Contains(typeof(ISimpleSse), serviceTypes); + Assert.Contains(typeof(IConnectionMultiplexer), serviceTypes); + } + + [Fact] + public void ConfigureAndInitializeServices_ShouldThrow_WhenJwtSettingsAreInvalid() + { + var services = new ServiceCollection(); + var env = Substitute.For(); + env.EnvironmentName.Returns(Environments.Production); + + var appSettings = new AppSettings( + new CorsSettings { AllowedOrigins = new[] { "https://localhost" } }, + new JwtSettings + { + Secret = "short", + Issuer = "issuer", + Audience = "audience", + AccessTokenLifetime = 60, + RefreshTokenLifetime = 120 + }, + new DbSettings + { + PSqlConnectionString = "Host=localhost;Database=test;Username=test;Password=test", + RedisConnectionString = "localhost:6379" + } + ); + + var manager = new ServiceManager(services, appSettings, env); + + var exception = Assert.Throws(() => manager.ConfigureAndInitializeServices()); + Assert.Contains("JWT Secret", exception.Message); + } +}