diff --git a/WardrobeManager.Api.Tests/Database/DatabaseContextTests.cs b/WardrobeManager.Api.Tests/Database/DatabaseContextTests.cs new file mode 100644 index 0000000..b1bf828 --- /dev/null +++ b/WardrobeManager.Api.Tests/Database/DatabaseContextTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.EntityFrameworkCore; +using WardrobeManager.Api.Database; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Database; + +public class DatabaseContextTests +{ + private DatabaseContext _context; + + [SetUp] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new DatabaseContext(options); + } + + [TearDown] + public async Task TearDown() + { + await _context.DisposeAsync(); + } + + [Test] + public void DatabaseContext_HasUsersDbSet() + { + // Assert + _context.Users.Should().NotBeNull(); + } + + [Test] + public void DatabaseContext_HasClothingItemsDbSet() + { + // Assert + _context.ClothingItems.Should().NotBeNull(); + } + + [Test] + public void DatabaseContext_HasLogsDbSet() + { + // Assert + _context.Logs.Should().NotBeNull(); + } + + [Test] + public async Task DatabaseContext_WhenUserAddedWithClothingItems_SavesRelationship() + { + // Arrange + var user = new User + { + Id = "test-user", + UserName = "testuser", + ServerClothingItems = new List + { + new ClothingItem("T-Shirt", ClothingCategory.TShirt, Season.Fall, + WearLocation.HomeAndOutside, false, 3, null) + } + }; + + // Act + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + + // Assert + var savedUser = await _context.Users + .Include(u => u.ServerClothingItems) + .FirstOrDefaultAsync(u => u.Id == "test-user"); + + using (new AssertionScope()) + { + savedUser.Should().NotBeNull(); + savedUser!.ServerClothingItems.Should().HaveCount(1); + savedUser.ServerClothingItems.First().Name.Should().Be("T-Shirt"); + } + } + + [Test] + public async Task DatabaseContext_WhenUserDeleted_CascadeDeletesClothingItems() + { + // Arrange + var user = new User + { + Id = "cascade-test-user", + UserName = "cascadeuser", + ServerClothingItems = new List + { + new ClothingItem("Item 1", ClothingCategory.Jeans, Season.Fall, + WearLocation.Outside, false, 0, null) + } + }; + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + + // Act + _context.Users.Remove(user); + await _context.SaveChangesAsync(); + + // Assert - cascade delete should have removed the clothing items too + var clothingItemCount = await _context.ClothingItems + .Where(c => c.UserId == "cascade-test-user") + .CountAsync(); + clothingItemCount.Should().Be(0); + } + + [Test] + public async Task DatabaseContext_CanAddAndRetrieveLogs() + { + // Arrange + var log = new Log("Test log", "Description", LogType.Info, LogOrigin.Backend); + + // Act + await _context.Logs.AddAsync(log); + await _context.SaveChangesAsync(); + + // Assert + var savedLog = await _context.Logs.FirstOrDefaultAsync(l => l.Title == "Test log"); + using (new AssertionScope()) + { + savedLog.Should().NotBeNull(); + savedLog!.Description.Should().Be("Description"); + savedLog.Type.Should().Be(LogType.Info); + } + } +} diff --git a/WardrobeManager.Api.Tests/Database/DatabaseInitializerTests.cs b/WardrobeManager.Api.Tests/Database/DatabaseInitializerTests.cs new file mode 100644 index 0000000..81cd4cf --- /dev/null +++ b/WardrobeManager.Api.Tests/Database/DatabaseInitializerTests.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using WardrobeManager.Api.Database; +using WardrobeManager.Api.Database.Entities; + +namespace WardrobeManager.Api.Tests.Database; + +public class DatabaseInitializerTests +{ + private Mock> _mockRoleManager; + private DatabaseContext _context; + private SqliteConnection _connection; + + [SetUp] + public void Setup() + { + // Use a persistent in-memory SQLite connection so migrations can run + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + _context = new DatabaseContext(dbOptions); + + // Setup mock RoleManager + var roleStore = new Mock>(); + _mockRoleManager = new Mock>( + roleStore.Object, null!, null!, null!, null!); + } + + [TearDown] + public async Task TearDown() + { + await _context.DisposeAsync(); + await _connection.DisposeAsync(); + } + + private IServiceScope BuildScope() + { + var mockServiceProvider = new Mock(); + mockServiceProvider + .Setup(sp => sp.GetService(typeof(DatabaseContext))) + .Returns(_context); + mockServiceProvider + .Setup(sp => sp.GetService(typeof(RoleManager))) + .Returns(_mockRoleManager.Object); + + var mockScope = new Mock(); + mockScope.Setup(s => s.ServiceProvider).Returns(mockServiceProvider.Object); + return mockScope.Object; + } + + [Test] + public async Task InitializeAsync_WhenNoUsersExist_CreatesAdminAndUserRoles() + { + // Arrange + _mockRoleManager.Setup(r => r.RoleExistsAsync("Admin")).ReturnsAsync(false); + _mockRoleManager.Setup(r => r.RoleExistsAsync("User")).ReturnsAsync(false); + _mockRoleManager + .Setup(r => r.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + var scope = BuildScope(); + + // Act + await DatabaseInitializer.InitializeAsync(scope); + + // Assert + _mockRoleManager.Verify(r => r.CreateAsync(It.Is(role => role.Name == "Admin")), Times.Once); + _mockRoleManager.Verify(r => r.CreateAsync(It.Is(role => role.Name == "User")), Times.Once); + } + + [Test] + public async Task InitializeAsync_WhenRolesAlreadyExist_DoesNotRecreateRoles() + { + // Arrange + _mockRoleManager.Setup(r => r.RoleExistsAsync("Admin")).ReturnsAsync(true); + _mockRoleManager.Setup(r => r.RoleExistsAsync("User")).ReturnsAsync(true); + + var scope = BuildScope(); + + // Act + await DatabaseInitializer.InitializeAsync(scope); + + // Assert + _mockRoleManager.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task InitializeAsync_WhenUsersExist_SkipsRoleCreation() + { + // Arrange - Apply migrations first so we can add a user + await _context.Database.MigrateAsync(); + var user = new User { Id = "existing-user", UserName = "existinguser" }; + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + + var scope = BuildScope(); + + // Act - InitializeAsync will call MigrateAsync (no-op, already migrated) then exit early + await DatabaseInitializer.InitializeAsync(scope); + + // Assert - RoleManager should NOT be called since users already exist (early return) + _mockRoleManager.Verify(r => r.RoleExistsAsync(It.IsAny()), Times.Never); + } +} diff --git a/WardrobeManager.Api.Tests/Database/Entities/ClothingItemTests.cs b/WardrobeManager.Api.Tests/Database/Entities/ClothingItemTests.cs new file mode 100644 index 0000000..1fd2140 --- /dev/null +++ b/WardrobeManager.Api.Tests/Database/Entities/ClothingItemTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Database.Entities; + +public class ClothingItemTests +{ + private ClothingItem _clothingItem; + + [SetUp] + public void Setup() + { + _clothingItem = new ClothingItem("T-Shirt", ClothingCategory.TShirt, Season.Fall, + WearLocation.HomeAndOutside, false, 3, null); + } + + [Test] + public void Wear_WhenCalledOnce_IncrementsCounters() + { + // Arrange - item has 0 wears + + // Act + _clothingItem.Wear(); + + // Assert + using (new AssertionScope()) + { + _clothingItem.TimesWornTotal.Should().Be(1); + _clothingItem.TimesWornSinceWash.Should().Be(1); + } + } + + [Test] + public void Wear_WhenCalledMultipleTimes_IncrementsCountersCorrectly() + { + // Arrange + const int wearCount = 5; + + // Act + for (int i = 0; i < wearCount; i++) + { + _clothingItem.Wear(); + } + + // Assert + using (new AssertionScope()) + { + _clothingItem.TimesWornTotal.Should().Be(wearCount); + _clothingItem.TimesWornSinceWash.Should().Be(wearCount); + } + } + + [Test] + public void Wash_AfterWearing_ResetsTimesWornSinceWash() + { + // Arrange + _clothingItem.Wear(); + _clothingItem.Wear(); + + // Act + _clothingItem.Wash(); + + // Assert + using (new AssertionScope()) + { + _clothingItem.TimesWornSinceWash.Should().Be(0); + _clothingItem.TimesWornTotal.Should().Be(2); // total does not reset on wash + } + } + + [Test] + public void Wear_WhenCalled_UpdatesLastWornDate() + { + // Arrange + var beforeWear = DateTime.UtcNow; + + // Act + _clothingItem.Wear(); + + // Assert + _clothingItem.LastWorn.Should().BeOnOrAfter(beforeWear); + } + + [Test] + public void ClothingItem_WhenCreated_HasCorrectDefaults() + { + // Arrange - item created in Setup + + // Assert + using (new AssertionScope()) + { + _clothingItem.Name.Should().Be("T-Shirt"); + _clothingItem.Category.Should().Be(ClothingCategory.TShirt); + _clothingItem.Season.Should().Be(Season.Fall); + _clothingItem.WearLocation.Should().Be(WearLocation.HomeAndOutside); + _clothingItem.Favourited.Should().BeFalse(); + _clothingItem.DesiredTimesWornBeforeWash.Should().Be(3); + _clothingItem.TimesWornTotal.Should().Be(0); + _clothingItem.TimesWornSinceWash.Should().Be(0); + _clothingItem.Size.Should().Be(ClothingSize.NotSpecified); // added in US-003 + } + } +} diff --git a/WardrobeManager.Api.Tests/Endpoints/ClothingEndpointsTests.cs b/WardrobeManager.Api.Tests/Endpoints/ClothingEndpointsTests.cs new file mode 100644 index 0000000..a048948 --- /dev/null +++ b/WardrobeManager.Api.Tests/Endpoints/ClothingEndpointsTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Endpoints; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Endpoints; + +public class ClothingEndpointsTests +{ + private Mock _mockClothingService; + + [SetUp] + public void Setup() + { + _mockClothingService = new Mock(); + } + + #region GetClothing + + [Test] + public async Task GetClothing_WhenUserHasClothingItems_ReturnsOkWithItems() + { + // Arrange + var user = new User { Id = "test-user-id" }; + var clothingItemDtos = new List + { + new ClothingItemDTO { Id = 1, Name = "T-Shirt", Category = ClothingCategory.TShirt } + }; + + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + _mockClothingService + .Setup(s => s.GetAllClothingAsync(user.Id)) + .ReturnsAsync(clothingItemDtos); + + // Act + var result = await ClothingEndpoints.GetClothingAsync(httpContext, _mockClothingService.Object); + + // Assert + var okResult = result as Ok?>; + using (new AssertionScope()) + { + okResult.Should().NotBeNull(); + okResult!.Value.Should().HaveCount(1); + } + } + + [Test] + public async Task GetClothing_WhenUserHasNoClothingItems_ReturnsOkWithEmptyList() + { + // Arrange + var user = new User { Id = "empty-user-id" }; + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + _mockClothingService + .Setup(s => s.GetAllClothingAsync(user.Id)) + .ReturnsAsync(new List()); + + // Act + var result = await ClothingEndpoints.GetClothingAsync(httpContext, _mockClothingService.Object); + + // Assert + var okResult = result as Ok?>; + using (new AssertionScope()) + { + okResult.Should().NotBeNull(); + okResult!.Value.Should().BeEmpty(); + } + } + + #endregion + + #region AddNewClothingItem + + [Test] + public async Task AddNewClothingItem_WhenItemIsValid_ReturnsOk() + { + // Arrange + var user = new User { Id = "test-user-id" }; + var newItem = new NewClothingItemDTO { Name = "Jeans", Category = ClothingCategory.Jeans }; + + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + _mockClothingService + .Setup(s => s.AddNewClothingItem(user.Id, newItem)) + .Returns(Task.CompletedTask); + + // Act + var result = await ClothingEndpoints.AddNewClothingItemAsync(newItem, httpContext, _mockClothingService.Object, null!); + + // Assert + result.Should().BeOfType(); + } + + [Test] + public async Task AddNewClothingItem_WhenItemNameIsEmpty_ReturnsBadRequest() + { + // Arrange + var user = new User { Id = "test-user-id" }; + var newItem = new NewClothingItemDTO { Name = "", Category = ClothingCategory.Jeans }; + + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + // Act + var result = await ClothingEndpoints.AddNewClothingItemAsync(newItem, httpContext, _mockClothingService.Object, null!); + + // Assert + result.Should().BeOfType>(); + } + + #endregion + + #region DeleteClothingItem + + [Test] + public async Task DeleteClothingItem_WhenCalled_ReturnsOk() + { + // Arrange + var user = new User { Id = "test-user-id" }; + var itemId = 42; + + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + _mockClothingService + .Setup(s => s.DeleteClothingItem(user.Id, itemId)) + .Returns(Task.CompletedTask); + + // Act + var result = await ClothingEndpoints.DeleteClothingItemAsync(itemId, httpContext, _mockClothingService.Object, null!); + + // Assert + _mockClothingService.Verify(s => s.DeleteClothingItem(user.Id, itemId), Times.Once); + result.Should().BeOfType(); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Endpoints/IdentityEndpointsTests.cs b/WardrobeManager.Api.Tests/Endpoints/IdentityEndpointsTests.cs new file mode 100644 index 0000000..e21bf2f --- /dev/null +++ b/WardrobeManager.Api.Tests/Endpoints/IdentityEndpointsTests.cs @@ -0,0 +1,178 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Moq; +using System.Security.Claims; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Endpoints; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Api.Tests.Endpoints; + +public class IdentityEndpointsTests +{ + private Mock _mockUserService; + + [SetUp] + public void Setup() + { + _mockUserService = new Mock(); + } + + #region DoesAdminUserExist + + [Test] + public async Task DoesAdminUserExist_WhenAdminExists_ReturnsTrueResult() + { + // Arrange + _mockUserService.Setup(s => s.DoesAdminUserExist()).ReturnsAsync(true); + + // Act + var result = await IdentityEndpoints.DoesAdminUserExist(_mockUserService.Object); + + // Assert + var okResult = result as Ok; + using (new AssertionScope()) + { + okResult.Should().NotBeNull(); + okResult!.Value.Should().BeTrue(); + } + } + + [Test] + public async Task DoesAdminUserExist_WhenAdminDoesNotExist_ReturnsFalseResult() + { + // Arrange + _mockUserService.Setup(s => s.DoesAdminUserExist()).ReturnsAsync(false); + + // Act + var result = await IdentityEndpoints.DoesAdminUserExist(_mockUserService.Object); + + // Assert + var okResult = result as Ok; + using (new AssertionScope()) + { + okResult.Should().NotBeNull(); + okResult!.Value.Should().BeFalse(); + } + } + + #endregion + + #region CreateAdminIfMissing + + [Test] + public async Task CreateAdminIfMissing_WhenCreatedSuccessfully_ReturnsCreated() + { + // Arrange + var credentials = new AdminUserCredentials { email = "admin@test.com", password = "SecurePass1!" }; + _mockUserService + .Setup(s => s.CreateAdminIfMissing("admin@test.com", "SecurePass1!")) + .ReturnsAsync((true, "Admin user created!")); + + // Act + var result = await IdentityEndpoints.CreateAdminIfMissing(_mockUserService.Object, credentials); + + // Assert + result.Should().BeOfType(); + } + + [Test] + public async Task CreateAdminIfMissing_WhenAdminAlreadyExists_ReturnsConflict() + { + // Arrange + var credentials = new AdminUserCredentials { email = "admin@test.com", password = "SecurePass1!" }; + _mockUserService + .Setup(s => s.CreateAdminIfMissing("admin@test.com", "SecurePass1!")) + .ReturnsAsync((false, "Admin user already exists!")); + + // Act + var result = await IdentityEndpoints.CreateAdminIfMissing(_mockUserService.Object, credentials); + + // Assert + result.Should().BeOfType>(); + } + + #endregion + + #region LogoutAsync + + [Test] + public async Task LogoutAsync_WhenBodyIsNotNull_CallsSignOutAndReturnsOk() + { + // Arrange + var userStore = new Mock>(); + var mockSignInManager = new Mock>( + new Mock>(userStore.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object, + new Mock().Object, + new Mock>().Object, + null!, null!, null!, null!); + + // Act + var result = await IdentityEndpoints.LogoutAsync(mockSignInManager.Object, new object()); + + // Assert + mockSignInManager.Verify(s => s.SignOutAsync(), Times.Once); + result.Should().BeOfType(); + } + + [Test] + public async Task LogoutAsync_WhenBodyIsNull_ReturnsUnauthorized() + { + // Arrange + var userStore = new Mock>(); + var mockSignInManager = new Mock>( + new Mock>(userStore.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object, + new Mock().Object, + new Mock>().Object, + null!, null!, null!, null!); + + // Act + var result = await IdentityEndpoints.LogoutAsync(mockSignInManager.Object, null!); + + // Assert + mockSignInManager.Verify(s => s.SignOutAsync(), Times.Never); + result.Should().BeOfType(); + } + + #endregion + + #region RolesAsync + + [Test] + public async Task RolesAsync_WhenUserIsAuthenticated_ReturnsJsonWithRoles() + { + // Arrange + var claims = new[] + { + new Claim(ClaimTypes.Name, "test@test.com"), + new Claim(ClaimTypes.Role, "Admin") + }; + var identity = new ClaimsIdentity(claims, "TestScheme"); + var principal = new ClaimsPrincipal(identity); + + // Act + var result = await IdentityEndpoints.RolesAsync(principal); + + // Assert + result.Should().NotBeOfType(); + } + + [Test] + public async Task RolesAsync_WhenUserIsNotAuthenticated_ReturnsUnauthorized() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity()); // anonymous + + // Act + var result = await IdentityEndpoints.RolesAsync(principal); + + // Assert + result.Should().BeOfType(); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Endpoints/ImageEndpointsTests.cs b/WardrobeManager.Api.Tests/Endpoints/ImageEndpointsTests.cs new file mode 100644 index 0000000..d291d13 --- /dev/null +++ b/WardrobeManager.Api.Tests/Endpoints/ImageEndpointsTests.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using WardrobeManager.Api.Endpoints; +using WardrobeManager.Api.Services.Interfaces; + +namespace WardrobeManager.Api.Tests.Endpoints; + +public class ImageEndpointsTests +{ + private Mock _mockFileService; + + [SetUp] + public void Setup() + { + _mockFileService = new Mock(); + } + + [Test] + public async Task GetImage_WhenCalled_ReturnsFileFromService() + { + // Arrange + var imageId = "test-image-guid"; + var imageBytes = new byte[] { 0xFF, 0xD8, 0xFF }; + _mockFileService.Setup(s => s.GetImage(imageId)).ReturnsAsync(imageBytes); + + // Act + var result = await ImageEndpoints.GetImage(imageId, _mockFileService.Object); + + // Assert + _mockFileService.Verify(s => s.GetImage(imageId), Times.Once); + result.Should().NotBeNull(); + } +} diff --git a/WardrobeManager.Api.Tests/Endpoints/MiscEndpointsTests.cs b/WardrobeManager.Api.Tests/Endpoints/MiscEndpointsTests.cs new file mode 100644 index 0000000..a5a709e --- /dev/null +++ b/WardrobeManager.Api.Tests/Endpoints/MiscEndpointsTests.cs @@ -0,0 +1,98 @@ +using AutoMapper; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using System.Security.Claims; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Endpoints; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Endpoints; + +public class MiscEndpointsTests +{ + private Mock _mockLoggingService; + private Mock _mockMapper; + + [SetUp] + public void Setup() + { + _mockLoggingService = new Mock(); + _mockMapper = new Mock(); + } + + #region Ping + + [Test] + public void Ping_WhenUserIsAuthenticated_ReturnsAuthenticatedMessage() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var claims = new[] { new Claim(ClaimTypes.Name, "test@test.com") }; + var identity = new ClaimsIdentity(claims, "TestScheme"); // non-null AuthType means authenticated + httpContext.User = new ClaimsPrincipal(identity); + + // Act + var result = MiscEndpoints.Ping(httpContext); + + // Assert + var valueResult = result as IValueHttpResult; + using (new AssertionScope()) + { + valueResult.Should().NotBeNull(); + valueResult!.Value.Should().Be("Authenticated: WardrobeManager API is active."); + } + } + + [Test] + public void Ping_WhenUserIsNotAuthenticated_ReturnsUnauthenticatedMessage() + { + // Arrange + var httpContext = new DefaultHttpContext(); + // DefaultHttpContext has an anonymous user by default + + // Act + var result = MiscEndpoints.Ping(httpContext); + + // Assert + var valueResult = result as IValueHttpResult; + using (new AssertionScope()) + { + valueResult.Should().NotBeNull(); + valueResult!.Value.Should().Be("Unauthenticated: WardrobeManager API is active."); + } + } + + #endregion + + #region AddLogAsync + + [Test] + public async Task AddLogAsync_WhenCalled_MapsLogDtoAndCreatesLog() + { + // Arrange + var logDto = new LogDTO + { + Title = "Test Log", + Description = "Test description", + Type = LogType.Info, + Origin = LogOrigin.Frontend + }; + var mappedLog = new Log("Test Log", "Test description", LogType.Info, LogOrigin.Frontend); + _mockMapper.Setup(m => m.Map(logDto)).Returns(mappedLog); + + // Act + var result = await MiscEndpoints.AddLogAsync(logDto, _mockLoggingService.Object, _mockMapper.Object); + + // Assert + _mockMapper.Verify(m => m.Map(logDto), Times.Once); + _mockLoggingService.Verify(s => s.CreateDatabaseAndConsoleLog(mappedLog), Times.Once); + result.Should().BeOfType(); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Endpoints/UserEndpointsTests.cs b/WardrobeManager.Api.Tests/Endpoints/UserEndpointsTests.cs new file mode 100644 index 0000000..d8fa219 --- /dev/null +++ b/WardrobeManager.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Endpoints; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; + +namespace WardrobeManager.Api.Tests.Endpoints; + +public class UserEndpointsTests +{ + private Mock _mockUserService; + + [SetUp] + public void Setup() + { + _mockUserService = new Mock(); + } + + #region GetUser + + [Test] + public async Task GetUser_WhenCalled_ReturnsOkWithUser() + { + // Arrange + var user = new User { Id = "test-user", Name = "Test User" }; + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + + // Act + var result = await UserEndpoints.GetUser(_mockUserService.Object, httpContext); + + // Assert + var okResult = result as Ok; + using (new AssertionScope()) + { + okResult.Should().NotBeNull(); + okResult!.Value.Should().BeSameAs(user); + } + } + + #endregion + + #region EditUser + + [Test] + public async Task EditUser_WhenCalled_CallsUpdateUserAndReturnsOk() + { + // Arrange + var user = new User { Id = "test-user" }; + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + var editedUser = new EditedUserDTO("New Name", "new-pic"); + _mockUserService + .Setup(s => s.UpdateUser(user.Id, editedUser)) + .Returns(Task.CompletedTask); + + // Act + var result = await UserEndpoints.EditUser(editedUser, _mockUserService.Object, httpContext); + + // Assert + _mockUserService.Verify(s => s.UpdateUser(user.Id, editedUser), Times.Once); + var okResult = result as Ok; + okResult.Should().NotBeNull(); + } + + #endregion + + #region DeleteUser + + [Test] + public async Task DeleteUser_WhenCalled_CallsDeleteUserAndReturnsOk() + { + // Arrange + var user = new User { Id = "test-user" }; + var httpContext = new DefaultHttpContext(); + httpContext.Items["user"] = user; + _mockUserService + .Setup(s => s.DeleteUser(user.Id)) + .Returns(Task.CompletedTask); + + // Act + var result = await UserEndpoints.DeleteUser(_mockUserService.Object, httpContext); + + // Assert + _mockUserService.Verify(s => s.DeleteUser(user.Id), Times.Once); + var okResult = result as Ok; + okResult.Should().NotBeNull(); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Helpers/AsyncQueryHelper.cs b/WardrobeManager.Api.Tests/Helpers/AsyncQueryHelper.cs new file mode 100644 index 0000000..c36afac --- /dev/null +++ b/WardrobeManager.Api.Tests/Helpers/AsyncQueryHelper.cs @@ -0,0 +1,27 @@ +using System.Collections; +using System.Linq.Expressions; + +namespace WardrobeManager.Api.Tests.Helpers; + +// Wraps a plain IEnumerable so that EF Core's ToListAsync() can consume it +// without a real database provider. +internal class AsyncQueryHelper(IEnumerable data) : IQueryable, IAsyncEnumerable +{ + private readonly IQueryable _queryable = data.AsQueryable(); + + public IEnumerator GetEnumerator() => _queryable.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public Type ElementType => _queryable.ElementType; + public Expression Expression => _queryable.Expression; + public IQueryProvider Provider => _queryable.Provider; + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new AsyncEnumeratorWrapper(data.GetEnumerator()); +} + +internal class AsyncEnumeratorWrapper(IEnumerator inner) : IAsyncEnumerator +{ + public T Current => inner.Current; + public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; } + public ValueTask MoveNextAsync() => ValueTask.FromResult(inner.MoveNext()); +} diff --git a/WardrobeManager.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs b/WardrobeManager.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..eb54ffc --- /dev/null +++ b/WardrobeManager.Api.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Testing; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Middleware; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Middleware; + +public class GlobalExceptionHandlerTests +{ + private Mock _mockLoggingService; + private FakeLogger _fakeLogger; + private Mock _mockScopeFactory; + + [SetUp] + public void Setup() + { + _mockLoggingService = new Mock(); + _fakeLogger = new FakeLogger(); + + // Wire up: ScopeFactory β†’ Scope β†’ ServiceProvider β†’ LoggingService + var mockServiceProvider = new Mock(); + mockServiceProvider + .Setup(sp => sp.GetService(typeof(ILoggingService))) + .Returns(_mockLoggingService.Object); + + var mockScope = new Mock(); + mockScope.Setup(s => s.ServiceProvider).Returns(mockServiceProvider.Object); + + _mockScopeFactory = new Mock(); + _mockScopeFactory.Setup(f => f.CreateScope()).Returns(mockScope.Object); + } + + [Test] + public async Task TryHandleAsync_WhenExceptionOccurs_LogsToDatabase() + { + // Arrange + var handler = new GlobalExceptionHandler(_fakeLogger, _mockScopeFactory.Object); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + var exception = new Exception("Test error message"); + + // Act + await handler.TryHandleAsync(httpContext, exception, CancellationToken.None); + + // Assert + _mockLoggingService.Verify(s => + s.CreateDatabaseAndConsoleLog(It.Is(l => + l.Type == LogType.UncaughtException && + l.Origin == LogOrigin.Backend && + l.Title == "Test error message")), Times.Once); + } + + [Test] + public async Task TryHandleAsync_WhenExceptionOccurs_SetsResponseStatusTo500() + { + // Arrange + var handler = new GlobalExceptionHandler(_fakeLogger, _mockScopeFactory.Object); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + + // Act + await handler.TryHandleAsync(httpContext, new Exception("Error"), CancellationToken.None); + + // Assert + httpContext.Response.StatusCode.Should().Be(500); + } + + [Test] + public async Task TryHandleAsync_WhenExceptionOccurs_ReturnsTrue() + { + // Arrange + var handler = new GlobalExceptionHandler(_fakeLogger, _mockScopeFactory.Object); + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + + // Act + var result = await handler.TryHandleAsync(httpContext, new Exception("Error"), CancellationToken.None); + + // Assert + result.Should().BeTrue(); + } +} diff --git a/WardrobeManager.Api.Tests/Middleware/LoggingMiddlewareTests.cs b/WardrobeManager.Api.Tests/Middleware/LoggingMiddlewareTests.cs new file mode 100644 index 0000000..56c3d3a --- /dev/null +++ b/WardrobeManager.Api.Tests/Middleware/LoggingMiddlewareTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Middleware; +using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Middleware; + +public class LoggingMiddlewareTests +{ + private Mock _mockLoggingService; + private bool _nextWasCalled; + private RequestDelegate _next; + + [SetUp] + public void Setup() + { + _mockLoggingService = new Mock(); + _nextWasCalled = false; + _next = ctx => + { + _nextWasCalled = true; + return Task.CompletedTask; + }; + } + + [Test] + public async Task InvokeAsync_WhenCalled_LogsRequestAndCallsNext() + { + // Arrange + var middleware = new LoggingMiddleware(_next); + + var services = new ServiceCollection(); + services.AddSingleton(_mockLoggingService.Object); + var httpContext = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + using (new AssertionScope()) + { + _mockLoggingService.Verify(s => + s.CreateDatabaseAndConsoleLog(It.Is(l => + l.Type == LogType.RequestLog && l.Origin == LogOrigin.Backend)), Times.Once); + _nextWasCalled.Should().BeTrue(); + } + } + + [Test] + public async Task InvokeAsync_WhenCalled_AlwaysCallsNextMiddleware() + { + // Arrange + var middleware = new LoggingMiddleware(_next); + + var services = new ServiceCollection(); + services.AddSingleton(_mockLoggingService.Object); + var httpContext = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + _nextWasCalled.Should().BeTrue(); + } +} diff --git a/WardrobeManager.Api.Tests/Middleware/UserCreationMiddlewareTests.cs b/WardrobeManager.Api.Tests/Middleware/UserCreationMiddlewareTests.cs new file mode 100644 index 0000000..ce8e693 --- /dev/null +++ b/WardrobeManager.Api.Tests/Middleware/UserCreationMiddlewareTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Middleware; +using WardrobeManager.Api.Services.Interfaces; + +namespace WardrobeManager.Api.Tests.Middleware; + +public class UserCreationMiddlewareTests +{ + private Mock _mockUserService; + private ServiceProvider _serviceProvider; + + [SetUp] + public void Setup() + { + _mockUserService = new Mock(); + var services = new ServiceCollection(); + services.AddSingleton(_mockUserService.Object); + _serviceProvider = services.BuildServiceProvider(); + } + + [TearDown] + public void TearDown() + { + _serviceProvider.Dispose(); + } + + [Test] + public async Task InvokeAsync_WhenCalled_AlwaysCallsNext() + { + // Arrange + var nextWasCalled = false; + RequestDelegate next = ctx => + { + nextWasCalled = true; + return Task.CompletedTask; + }; + var middleware = new UserCreationMiddleware(next); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + _mockUserService.Setup(s => s.GetUser(It.IsAny())).ReturnsAsync((User?)null); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + nextWasCalled.Should().BeTrue(); + } + + [Test] + public async Task InvokeAsync_WhenCalled_PassesContextToNext() + { + // Arrange + HttpContext? capturedContext = null; + RequestDelegate next = ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + }; + var middleware = new UserCreationMiddleware(next); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + _mockUserService.Setup(s => s.GetUser(It.IsAny())).ReturnsAsync((User?)null); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + capturedContext.Should().BeSameAs(httpContext); + } + + [Test] + public async Task InvokeAsync_WhenUserExists_SetsUserOnContext() + { + // Arrange + var user = new User { Id = "test-user-id" }; + _mockUserService.Setup(s => s.GetUser(It.IsAny())).ReturnsAsync(user); + + RequestDelegate next = ctx => Task.CompletedTask; + var middleware = new UserCreationMiddleware(next); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + httpContext.Items["user"].Should().BeSameAs(user); + } + + [Test] + public async Task InvokeAsync_WhenUserDoesNotExist_DoesNotSetUserOnContext() + { + // Arrange + _mockUserService.Setup(s => s.GetUser(It.IsAny())).ReturnsAsync((User?)null); + + RequestDelegate next = ctx => Task.CompletedTask; + var middleware = new UserCreationMiddleware(next); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + httpContext.Items.ContainsKey("user").Should().BeFalse(); + } +} diff --git a/WardrobeManager.Api.Tests/Repositories/ClothingRepositoryTests.cs b/WardrobeManager.Api.Tests/Repositories/ClothingRepositoryTests.cs new file mode 100644 index 0000000..c97c4f7 --- /dev/null +++ b/WardrobeManager.Api.Tests/Repositories/ClothingRepositoryTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.EntityFrameworkCore; +using WardrobeManager.Api.Database; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Repositories.Implementation; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Repositories; + +public class ClothingRepositoryTests +{ + private DatabaseContext _context; + private ClothingRepository _repo; + private const string DefaultUserId = "test-user-id"; + + [SetUp] + public async Task Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new DatabaseContext(options); + + // ClothingRepository constructor only takes DatabaseContext + _repo = new ClothingRepository(_context); + + // Seed a user so foreign key constraint is satisfied + var user = new User { Id = DefaultUserId, UserName = "testuser" }; + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + } + + [TearDown] + public async Task TearDown() + { + await _context.DisposeAsync(); + } + + private async Task AddSampleItemAsync(string userId = DefaultUserId, string name = "T-Shirt") + { + var item = new ClothingItem(name, ClothingCategory.TShirt, Season.Fall, + WearLocation.HomeAndOutside, false, 3, null) + { + UserId = userId + }; + await _context.ClothingItems.AddAsync(item); + await _context.SaveChangesAsync(); + return item; + } + + #region GetAsync(userId, itemId) + + [Test] + public async Task GetAsync_ByUserIdAndItemId_WhenExists_ReturnsItem() + { + // Arrange + var item = await AddSampleItemAsync(); + + // Act + var result = await _repo.GetAsync(DefaultUserId, item.Id); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result!.Id.Should().Be(item.Id); + result.UserId.Should().Be(DefaultUserId); + } + } + + [Test] + public async Task GetAsync_ByUserIdAndItemId_WhenItemBelongsToDifferentUser_ReturnsNull() + { + // Arrange + var item = await AddSampleItemAsync("different-user-id"); + + // Act + var result = await _repo.GetAsync(DefaultUserId, item.Id); + + // Assert + result.Should().BeNull(); + } + + [Test] + public async Task GetAsync_ByUserIdAndItemId_WhenItemDoesNotExist_ReturnsNull() + { + // Arrange - no items in database + + // Act + var result = await _repo.GetAsync(DefaultUserId, 9999); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetAllAsync(userId) + + [Test] + public async Task GetAllAsync_ByUserId_WhenItemsExist_ReturnsOnlyUsersItems() + { + // Arrange + await AddSampleItemAsync(DefaultUserId, "T-Shirt 1"); + await AddSampleItemAsync(DefaultUserId, "T-Shirt 2"); + await AddSampleItemAsync("other-user-id", "Other T-Shirt"); + + // Act + var result = await _repo.GetAllAsync(DefaultUserId); + + // Assert + using (new AssertionScope()) + { + result.Should().HaveCount(2); + result.Should().OnlyContain(i => i.UserId == DefaultUserId); + } + } + + [Test] + public async Task GetAllAsync_ByUserId_WhenNoItemsExist_ReturnsEmptyList() + { + // Arrange - no items for this user + + // Act + var result = await _repo.GetAllAsync(DefaultUserId); + + // Assert + result.Should().BeEmpty(); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Repositories/GenericRepositoryTests.cs b/WardrobeManager.Api.Tests/Repositories/GenericRepositoryTests.cs new file mode 100644 index 0000000..964fd0d --- /dev/null +++ b/WardrobeManager.Api.Tests/Repositories/GenericRepositoryTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.EntityFrameworkCore; +using WardrobeManager.Api.Database; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Repositories.Implementation; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Api.Tests.Repositories; + +public class GenericRepositoryTests +{ + private DatabaseContext _context; + private GenericRepository _repo; + + [SetUp] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new DatabaseContext(options); + _repo = new GenericRepository(_context); + } + + [TearDown] + public async Task TearDown() + { + await _context.DisposeAsync(); + } + + #region GetAsync + + [Test] + public async Task GetAsync_WhenEntityExists_ReturnsEntity() + { + // Arrange + var log = new Log("Test Title", "Test Desc", LogType.Info, LogOrigin.Backend); + await _context.Logs.AddAsync(log); + await _context.SaveChangesAsync(); + + // Act + var result = await _repo.GetAsync(log.Id); + + // Assert + using (new AssertionScope()) + { + result.Should().NotBeNull(); + result!.Title.Should().Be("Test Title"); + } + } + + [Test] + public async Task GetAsync_WhenEntityDoesNotExist_ReturnsNull() + { + // Arrange - empty database + + // Act + var result = await _repo.GetAsync(999); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetAllAsync + + [Test] + public async Task GetAllAsync_WhenEntitiesExist_ReturnsAllEntities() + { + // Arrange + await _context.Logs.AddRangeAsync( + new Log("Log 1", "Desc 1", LogType.Info, LogOrigin.Backend), + new Log("Log 2", "Desc 2", LogType.Error, LogOrigin.Frontend) + ); + await _context.SaveChangesAsync(); + + // Act + var result = await _repo.GetAllAsync(); + + // Assert + result.Should().HaveCount(2); + } + + [Test] + public async Task GetAllAsync_WhenEmpty_ReturnsEmptyList() + { + // Arrange - empty database + + // Act + var result = await _repo.GetAllAsync(); + + // Assert + result.Should().BeEmpty(); + } + + #endregion + + #region CreateAsync + + [Test] + public async Task CreateAsync_WhenCalled_AddsEntityToDatabase() + { + // Arrange + var log = new Log("New Log", "Description", LogType.Warning, LogOrigin.Database); + + // Act + await _repo.CreateAsync(log); + await _repo.SaveAsync(); + + // Assert + var savedLog = await _context.Logs.FirstOrDefaultAsync(l => l.Title == "New Log"); + savedLog.Should().NotBeNull(); + } + + #endregion + + #region CreateManyAsync + + [Test] + public async Task CreateManyAsync_WhenCalled_AddsAllEntitiesToDatabase() + { + // Arrange + var logs = new List + { + new("Log A", "Desc A", LogType.Info, LogOrigin.Backend), + new("Log B", "Desc B", LogType.Info, LogOrigin.Backend), + new("Log C", "Desc C", LogType.Info, LogOrigin.Backend), + }; + + // Act + await _repo.CreateManyAsync(logs); + await _repo.SaveAsync(); + + // Assert + var count = await _context.Logs.CountAsync(); + count.Should().Be(3); + } + + #endregion + + #region Remove + + [Test] + public async Task Remove_WhenEntityExists_RemovesFromDatabase() + { + // Arrange + var log = new Log("To Remove", "Desc", LogType.Info, LogOrigin.Unknown); + await _context.Logs.AddAsync(log); + await _context.SaveChangesAsync(); + + // Act + _repo.Remove(log); + await _repo.SaveAsync(); + + // Assert + var deletedLog = await _context.Logs.FindAsync(log.Id); + deletedLog.Should().BeNull(); + } + + #endregion + + #region Update + + [Test] + public async Task Update_WhenEntityIsModified_SavesChanges() + { + // Arrange + var log = new Log("Original", "Desc", LogType.Info, LogOrigin.Backend); + await _context.Logs.AddAsync(log); + await _context.SaveChangesAsync(); + + // Act + log.Title = "Updated"; + _repo.Update(log); + await _repo.SaveAsync(); + + // Assert + var updatedLog = await _context.Logs.FindAsync(log.Id); + updatedLog!.Title.Should().Be("Updated"); + } + + #endregion + + #region SaveAsync + + [Test] + public async Task SaveAsync_WhenCalled_PersistsChanges() + { + // Arrange + var log = new Log("Unsaved Log", "Desc", LogType.Info, LogOrigin.Backend); + await _context.Logs.AddAsync(log); + + // Changes not yet saved + var countBeforeSave = await _context.Logs.CountAsync(); + + // Act + await _repo.SaveAsync(); + + // Assert + var countAfterSave = await _context.Logs.CountAsync(); + countAfterSave.Should().Be(countBeforeSave + 1); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Services/DataDirectoryServiceTests.cs b/WardrobeManager.Api.Tests/Services/DataDirectoryServiceTests.cs new file mode 100644 index 0000000..88994d1 --- /dev/null +++ b/WardrobeManager.Api.Tests/Services/DataDirectoryServiceTests.cs @@ -0,0 +1,123 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Moq; +using WardrobeManager.Api.Services.Implementation; + +namespace WardrobeManager.Api.Tests.Services; + +public class DataDirectoryServiceTests +{ + private Mock _mockConfiguration; + private Mock _mockWebHostEnvironment; + private DataDirectoryService _service; + private string _tempBaseDir; + + [SetUp] + public void Setup() + { + _mockConfiguration = new Mock(); + _mockWebHostEnvironment = new Mock(); + + _tempBaseDir = Path.Combine(Path.GetTempPath(), $"wardrobe-data-test-{Guid.NewGuid()}"); + + // Use Production environment so the DataDirectory is used directly as-is + _mockWebHostEnvironment.Setup(e => e.EnvironmentName).Returns("Production"); + _mockConfiguration.Setup(c => c["WM_DATA_DIRECTORY"]).Returns(_tempBaseDir); + + _service = new DataDirectoryService(_mockConfiguration.Object, _mockWebHostEnvironment.Object); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempBaseDir)) Directory.Delete(_tempBaseDir, true); + } + + [Test] + public void GetBaseDataDirectory_WhenCalled_CreatesAndReturnsDirectory() + { + // Act + var result = _service.GetBaseDataDirectory(); + + // Assert + using (new AssertionScope()) + { + result.Should().Be(_tempBaseDir); + Directory.Exists(result).Should().BeTrue(); + } + } + + [Test] + public void GetDatabaseDirectory_WhenCalled_CreatesAndReturnsDbSubdirectory() + { + // Act + var result = _service.GetDatabaseDirectory(); + + // Assert + using (new AssertionScope()) + { + result.Should().EndWith("db"); + result.Should().StartWith(_tempBaseDir); + Directory.Exists(result).Should().BeTrue(); + } + } + + [Test] + public void GetImagesDirectory_WhenCalled_CreatesAndReturnsImagesSubdirectory() + { + // Act + var result = _service.GetImagesDirectory(); + + // Assert + using (new AssertionScope()) + { + result.Should().EndWith("images"); + result.Should().StartWith(_tempBaseDir); + Directory.Exists(result).Should().BeTrue(); + } + } + + [Test] + public void GetUploadsDirectory_WhenCalled_CreatesAndReturnsUploadsSubdirectory() + { + // Act + var result = _service.GetUploadsDirectory(); + + // Assert + using (new AssertionScope()) + { + result.Should().EndWith("uploads"); + result.Should().StartWith(_tempBaseDir); + Directory.Exists(result).Should().BeTrue(); + } + } + + [Test] + public void GetDeletedUploadsDirectory_WhenCalled_CreatesAndReturnsDeletedSubdirectory() + { + // Act + var result = _service.GetDeletedUploadsDirectory(); + + // Assert + using (new AssertionScope()) + { + result.Should().EndWith("deleted"); + result.Should().StartWith(_tempBaseDir); + Directory.Exists(result).Should().BeTrue(); + } + } + + [Test] + public void DataDirectoryService_WhenDataDirectoryNotConfigured_ThrowsException() + { + // Arrange + var mockConfig = new Mock(); + mockConfig.Setup(c => c["WM_DATA_DIRECTORY"]).Returns((string?)null); + + // Act & Assert + var action = () => new DataDirectoryService(mockConfig.Object, _mockWebHostEnvironment.Object); + action.Should().Throw().WithMessage("*configuration value not set*"); + } +} diff --git a/WardrobeManager.Api.Tests/Services/FileServiceTests.cs b/WardrobeManager.Api.Tests/Services/FileServiceTests.cs new file mode 100644 index 0000000..e27df72 --- /dev/null +++ b/WardrobeManager.Api.Tests/Services/FileServiceTests.cs @@ -0,0 +1,175 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Testing; +using Moq; +using WardrobeManager.Api.Services.Implementation; +using WardrobeManager.Api.Services.Interfaces; + +namespace WardrobeManager.Api.Tests.Services; + +public class FileServiceTests +{ + private Mock _mockDataDirectoryService; + private Mock _mockWebHostEnvironment; + private Mock _mockConfiguration; + private FakeLogger _fakeLogger; + private FileService _service; + private string _tempDir; + private string _tempDeletedDir; + private string _webRootDir; + + [SetUp] + public void Setup() + { + _mockDataDirectoryService = new Mock(); + _mockWebHostEnvironment = new Mock(); + _mockConfiguration = new Mock(); + _fakeLogger = new FakeLogger(); + + _tempDir = Path.Combine(Path.GetTempPath(), $"wardrobe-test-{Guid.NewGuid()}"); + _tempDeletedDir = Path.Combine(Path.GetTempPath(), $"wardrobe-deleted-{Guid.NewGuid()}"); + _webRootDir = Path.Combine(Path.GetTempPath(), $"wardrobe-webroot-{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDir); + Directory.CreateDirectory(_tempDeletedDir); + Directory.CreateDirectory(Path.Combine(_webRootDir, "images")); + + File.WriteAllBytes(Path.Combine(_webRootDir, "images", "notfound.jpg"), new byte[] { 0xFF, 0xD8, 0xFF }); + + _mockDataDirectoryService.Setup(s => s.GetUploadsDirectory()).Returns(_tempDir); + _mockDataDirectoryService.Setup(s => s.GetDeletedUploadsDirectory()).Returns(_tempDeletedDir); + _mockWebHostEnvironment.Setup(e => e.WebRootPath).Returns(_webRootDir); + _mockConfiguration.Setup(c => c["WM_MAX_IMAGE_UPLOAD_SIZE_IN_MB"]).Returns("5"); + + _service = new FileService(_mockDataDirectoryService.Object, _mockWebHostEnvironment.Object, _fakeLogger, _mockConfiguration.Object); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); + if (Directory.Exists(_tempDeletedDir)) Directory.Delete(_tempDeletedDir, true); + if (Directory.Exists(_webRootDir)) Directory.Delete(_webRootDir, true); + } + + #region ParseGuid + + [Test] + public void ParseGuid_WhenGiven_ReturnsStringRepresentation() + { + var guid = new Guid("12345678-1234-1234-1234-123456789012"); + var result = _service.ParseGuid(guid); + result.Should().Be("12345678-1234-1234-1234-123456789012"); + } + + [Test] + public void ParseGuid_WhenGuidHasCurlyBraces_RemovesCurlyBraces() + { + var guid = new Guid("{12345678-1234-1234-1234-123456789012}"); + var result = _service.ParseGuid(guid); + using (new AssertionScope()) + { + result.Should().NotContain("{"); + result.Should().NotContain("}"); + } + } + + #endregion + + #region SaveImage + + [Test] + public async Task SaveImage_WhenGuidIsNull_DoesNotSaveFile() + { + var initialFileCount = Directory.GetFiles(_tempDir).Length; + await _service.SaveImage(null, "base64data"); + Directory.GetFiles(_tempDir).Length.Should().Be(initialFileCount); + } + + [Test] + public async Task SaveImage_WhenImageBase64IsEmpty_DoesNotSaveFile() + { + var guid = Guid.NewGuid(); + var initialFileCount = Directory.GetFiles(_tempDir).Length; + await _service.SaveImage(guid, string.Empty); + Directory.GetFiles(_tempDir).Length.Should().Be(initialFileCount); + } + + [Test] + public async Task SaveImage_WhenImageIsValid_SavesFile() + { + var guid = Guid.NewGuid(); + var imageBytes = new byte[] { 1, 2, 3, 4, 5 }; + var base64 = Convert.ToBase64String(imageBytes); + await _service.SaveImage(guid, base64); + var expectedPath = Path.Combine(_tempDir, _service.ParseGuid(guid)); + File.Exists(expectedPath).Should().BeTrue(); + } + + [Test] + public async Task SaveImage_WhenImageIsTooLarge_ThrowsException() + { + var guid = Guid.NewGuid(); + // Create image larger than 5MB (the configured limit: 5 * 1024 * 1024 bytes) + var largeImageBytes = new byte[6 * 1024 * 1024]; + var base64 = Convert.ToBase64String(largeImageBytes); + await _service.Invoking(s => s.SaveImage(guid, base64)) + .Should().ThrowAsync() + .WithMessage("*File size too large*"); + } + + #endregion + + #region GetImage + + [Test] + public async Task GetImage_WhenFileExists_ReturnsFileBytes() + { + var guid = Guid.NewGuid().ToString(); + var imageBytes = new byte[] { 10, 20, 30 }; + await File.WriteAllBytesAsync(Path.Combine(_tempDir, guid), imageBytes); + var result = await _service.GetImage(guid); + result.Should().BeEquivalentTo(imageBytes); + } + + [Test] + public async Task GetImage_WhenFileDoesNotExist_ReturnsNotFoundImageBytes() + { + var notFoundBytes = new byte[] { 0xFF, 0xD8, 0xFF }; + File.WriteAllBytes(Path.Combine(_webRootDir, "images", "notfound.jpg"), notFoundBytes); + var result = await _service.GetImage("nonexistent-guid"); + result.Should().BeEquivalentTo(notFoundBytes); + } + + #endregion + + #region DeleteImage + + [Test] + public async Task DeleteImage_WhenFileExists_MovesToDeletedDirectory() + { + var guid = Guid.NewGuid(); + var guidString = _service.ParseGuid(guid); + var sourceFile = Path.Combine(_tempDir, guidString); + File.WriteAllBytes(sourceFile, new byte[] { 1, 2, 3 }); + + await _service.DeleteImage(guid); + + using (new AssertionScope()) + { + File.Exists(sourceFile).Should().BeFalse(); + File.Exists(Path.Combine(_tempDeletedDir, guidString)).Should().BeTrue(); + } + } + + [Test] + public async Task DeleteImage_WhenFileDoesNotExist_LogsError() + { + var guid = Guid.NewGuid(); + await _service.DeleteImage(guid); + _fakeLogger.Collector.LatestRecord.Level.Should().Be(Microsoft.Extensions.Logging.LogLevel.Error); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/Services/UserServiceTests.cs b/WardrobeManager.Api.Tests/Services/UserServiceTests.cs new file mode 100644 index 0000000..5575967 --- /dev/null +++ b/WardrobeManager.Api.Tests/Services/UserServiceTests.cs @@ -0,0 +1,257 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Identity; +using Moq; +using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Api.Services.Implementation; +using WardrobeManager.Api.Tests.Helpers; +using WardrobeManager.Shared.DTOs; + +namespace WardrobeManager.Api.Tests.Services; + +public class UserServiceTests +{ + private Mock> _mockUserManager; + private Mock> _mockRoleManager; + private UserService _service; + + [SetUp] + public void Setup() + { + var userStore = new Mock>(); + _mockUserManager = new Mock>( + userStore.Object, null!, null!, null!, null!, null!, null!, null!, null!); + + var roleStore = new Mock>(); + _mockRoleManager = new Mock>( + roleStore.Object, null!, null!, null!, null!); + + _service = new UserService(_mockUserManager.Object, _mockRoleManager.Object); + } + + #region GetUser + + [Test] + public async Task GetUser_WhenUserExists_ReturnsUser() + { + // Arrange + var user = new User { Id = "test-id" }; + _mockUserManager.Setup(m => m.FindByIdAsync("test-id")).ReturnsAsync(user); + + // Act + var result = await _service.GetUser("test-id"); + + // Assert + _mockUserManager.Verify(m => m.FindByIdAsync("test-id"), Times.Once); + result.Should().BeEquivalentTo(user); + } + + [Test] + public async Task GetUser_WhenUserDoesNotExist_ReturnsNull() + { + // Arrange + _mockUserManager.Setup(m => m.FindByIdAsync("nonexistent")).ReturnsAsync((User?)null); + + // Act + var result = await _service.GetUser("nonexistent"); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region DoesUserExist + + [Test] + public async Task DoesUserExist_WhenUserExists_ReturnsTrue() + { + // Arrange + var user = new User { Id = "test-id" }; + _mockUserManager.Setup(m => m.FindByIdAsync("test-id")).ReturnsAsync(user); + + // Act + var result = await _service.DoesUserExist("test-id"); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task DoesUserExist_WhenUserDoesNotExist_ReturnsFalse() + { + // Arrange + _mockUserManager.Setup(m => m.FindByIdAsync("nonexistent")).ReturnsAsync((User?)null); + + // Act + var result = await _service.DoesUserExist("nonexistent"); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region UpdateUser + + [Test] + public async Task UpdateUser_WhenCalled_UpdatesUserFields() + { + // Arrange + var user = new User { Id = "test-id", Name = "Old Name", ProfilePictureBase64 = "old-pic" }; + var editedUser = new EditedUserDTO("New Name", "new-pic"); + _mockUserManager.Setup(m => m.FindByIdAsync("test-id")).ReturnsAsync(user); + _mockUserManager.Setup(m => m.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); + + // Act + await _service.UpdateUser("test-id", editedUser); + + // Assert + _mockUserManager.Verify(m => m.UpdateAsync(It.Is(u => + u.Name == "New Name" && u.ProfilePictureBase64 == "new-pic")), Times.Once); + } + + #endregion + + #region DeleteUser + + [Test] + public async Task DeleteUser_WhenCalled_DeletesUser() + { + // Arrange + var user = new User { Id = "test-id" }; + _mockUserManager.Setup(m => m.FindByIdAsync("test-id")).ReturnsAsync(user); + _mockUserManager.Setup(m => m.DeleteAsync(user)).ReturnsAsync(IdentityResult.Success); + + // Act + await _service.DeleteUser("test-id"); + + // Assert + _mockUserManager.Verify(m => m.DeleteAsync(user), Times.Once); + } + + #endregion + + #region DoesAdminUserExist + + [Test] + public async Task DoesAdminUserExist_WhenAdminExists_ReturnsTrue() + { + // Arrange + var adminUser = new User { Id = "admin-id" }; + var users = new AsyncQueryHelper(new List { adminUser }); + _mockUserManager.Setup(m => m.Users).Returns(users); + _mockRoleManager.Setup(m => m.RoleExistsAsync("Admin")).ReturnsAsync(true); + _mockUserManager.Setup(m => m.IsInRoleAsync(adminUser, "Admin")).ReturnsAsync(true); + + // Act + var result = await _service.DoesAdminUserExist(); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task DoesAdminUserExist_WhenNoAdminExists_ReturnsFalse() + { + // Arrange + var regularUser = new User { Id = "user-id" }; + var users = new AsyncQueryHelper(new List { regularUser }); + _mockUserManager.Setup(m => m.Users).Returns(users); + _mockRoleManager.Setup(m => m.RoleExistsAsync("Admin")).ReturnsAsync(true); + _mockUserManager.Setup(m => m.IsInRoleAsync(regularUser, "Admin")).ReturnsAsync(false); + + // Act + var result = await _service.DoesAdminUserExist(); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public async Task DoesAdminUserExist_WhenNoUsersExist_ReturnsFalse() + { + // Arrange + var users = new AsyncQueryHelper(new List()); + _mockUserManager.Setup(m => m.Users).Returns(users); + _mockRoleManager.Setup(m => m.RoleExistsAsync("Admin")).ReturnsAsync(true); + + // Act + var result = await _service.DoesAdminUserExist(); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region CreateAdminIfMissing + + [Test] + public async Task CreateAdminIfMissing_WhenAdminAlreadyExists_ReturnsFalseWithMessage() + { + // Arrange - admin user already exists + var adminUser = new User { Id = "admin-id" }; + var users = new AsyncQueryHelper(new List { adminUser }); + _mockUserManager.Setup(m => m.Users).Returns(users); + _mockRoleManager.Setup(m => m.RoleExistsAsync("Admin")).ReturnsAsync(true); + _mockUserManager.Setup(m => m.IsInRoleAsync(adminUser, "Admin")).ReturnsAsync(true); + + // Act + var result = await _service.CreateAdminIfMissing("admin@test.com", "password"); + + // Assert + using (new AssertionScope()) + { + result.Item1.Should().BeFalse(); + result.Item2.Should().Be("Admin user already exists!"); + } + } + + [Test] + public async Task CreateAdminIfMissing_WhenAdminDoesNotExist_CreatesAdminAndReturnsTrue() + { + // Arrange - no admin user exists + var users = new AsyncQueryHelper(new List()); + _mockUserManager.Setup(m => m.Users).Returns(users); + _mockRoleManager.Setup(m => m.RoleExistsAsync("Admin")).ReturnsAsync(true); + _mockUserManager + .Setup(m => m.CreateAsync(It.IsAny(), "SecurePass1!")) + .ReturnsAsync(IdentityResult.Success); + _mockUserManager + .Setup(m => m.AddToRoleAsync(It.IsAny(), "Admin")) + .ReturnsAsync(IdentityResult.Success); + + // Act + var result = await _service.CreateAdminIfMissing("admin@test.com", "SecurePass1!"); + + // Assert + using (new AssertionScope()) + { + result.Item1.Should().BeTrue(); + result.Item2.Should().Be("Admin user created!"); + } + } + + #endregion + + #region CreateUser + + [Test] + public async Task CreateUser_WhenCalled_CreatesNewUserWithDefaultClothingItems() + { + // Arrange + _mockUserManager + .Setup(m => m.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + // Act + await _service.CreateUser(); + + // Assert + _mockUserManager.Verify(m => m.CreateAsync(It.Is(u => + u.ServerClothingItems.Count == 2)), Times.Once); + } + + #endregion +} diff --git a/WardrobeManager.Api.Tests/WardrobeManager.Api.Tests.csproj b/WardrobeManager.Api.Tests/WardrobeManager.Api.Tests.csproj index 7db2de3..ffcbfc3 100644 --- a/WardrobeManager.Api.Tests/WardrobeManager.Api.Tests.csproj +++ b/WardrobeManager.Api.Tests/WardrobeManager.Api.Tests.csproj @@ -1,4 +1,4 @@ -ο»Ώ + net10.0 @@ -9,18 +9,20 @@ - + + + - + - - - + + + - + diff --git a/WardrobeManager.Api/Middleware/GlobalExceptionHandler.cs b/WardrobeManager.Api/Middleware/GlobalExceptionHandler.cs index 9031460..117b3a8 100644 --- a/WardrobeManager.Api/Middleware/GlobalExceptionHandler.cs +++ b/WardrobeManager.Api/Middleware/GlobalExceptionHandler.cs @@ -10,7 +10,7 @@ namespace WardrobeManager.Api.Middleware; -internal sealed class GlobalExceptionHandler : IExceptionHandler +public sealed class GlobalExceptionHandler : IExceptionHandler { private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; diff --git a/WardrobeManager.Presentation.Tests/ApiServiceTests.cs b/WardrobeManager.Presentation.Tests/ApiServiceTests.cs index 4cfe012..946ebbb 100644 --- a/WardrobeManager.Presentation.Tests/ApiServiceTests.cs +++ b/WardrobeManager.Presentation.Tests/ApiServiceTests.cs @@ -95,4 +95,247 @@ public async Task DoesAdminUserExists_DoesNotExist_FunctionsCorrectly() await _apiService.Invoking(s => s.DoesAdminUserExist()).Should().ThrowAsync(); _mockHttpClientFactory.Verify(s => s.CreateClient("Auth"), Times.Once); } + + [Test] + public async Task CheckApiConnection_WhenApiIsUp_ReturnsTrue() + { + // Arrange + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK + }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/ping")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act + var result = await _apiService.CheckApiConnection(); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task CheckApiConnection_WhenApiIsDown_ReturnsFalse() + { + // Arrange - simulate a connection failure + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + // Act + var result = await _apiService.CheckApiConnection(); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public async Task AddLog_WhenCalled_PostsToAddLogEndpoint() + { + // Arrange + var logDto = new WardrobeManager.Shared.DTOs.LogDTO + { + Title = "Test", + Description = "Test description", + Type = WardrobeManager.Shared.Enums.LogType.Info, + Origin = WardrobeManager.Shared.Enums.LogOrigin.Frontend + }; + var mockResponse = new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/add-log")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act + var result = await _apiService.AddLog(logDto); + + // Assert + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Test] + public async Task CreateAdminUserIfMissing_WhenSuccessful_ReturnsTrueWithMessage() + { + // Arrange + var credentials = new WardrobeManager.Shared.Models.AdminUserCredentials + { + email = "admin@test.com", + password = "SecurePass1!" + }; + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.Created, + Content = new StringContent(string.Empty) + }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/create-admin-user-if-missing")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act + var result = await _apiService.CreateAdminUserIfMissing(credentials); + + // Assert + using (new FluentAssertions.Execution.AssertionScope()) + { + result.Item1.Should().BeTrue(); + result.Item2.Should().Be("Admin user created!"); + } + } + + [Test] + public async Task CreateAdminUserIfMissing_WhenAdminAlreadyExists_ReturnsFalseWithMessage() + { + // Arrange + var credentials = new WardrobeManager.Shared.Models.AdminUserCredentials + { + email = "admin@test.com", + password = "SecurePass1!" + }; + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.Conflict, + Content = new StringContent("Admin user already exists!") + }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/create-admin-user-if-missing")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act + var result = await _apiService.CreateAdminUserIfMissing(credentials); + + // Assert + using (new FluentAssertions.Execution.AssertionScope()) + { + result.Item1.Should().BeFalse(); + result.Item2.Should().Be("Admin user already exists!"); + } + } + + [Test] + public async Task GetAllClothingItemsAsync_WhenCalled_ReturnsClothingItems() + { + // Arrange + var items = new List + { + new WardrobeManager.Shared.DTOs.ClothingItemDTO { Id = 1, Name = "T-Shirt" }, + new WardrobeManager.Shared.DTOs.ClothingItemDTO { Id = 2, Name = "Jeans" } + }; + var mockResponse = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(items) + }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/clothing")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act + var result = await _apiService.GetAllClothingItemsAsync(); + + // Assert + result.Should().HaveCount(2); + } + + [Test] + public async Task AddNewClothingItemAsync_WhenCalled_PostsToClothingAddEndpoint() + { + // Arrange + var newItem = new WardrobeManager.Shared.DTOs.NewClothingItemDTO + { + Name = "My T-Shirt", + Category = WardrobeManager.Shared.Enums.ClothingCategory.TShirt + }; + var mockResponse = new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/clothing/add")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act & Assert (no exception = success) + await _apiService.Invoking(s => s.AddNewClothingItemAsync(newItem)).Should().NotThrowAsync(); + _mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri!.AbsolutePath.Contains("/clothing/add")), + ItExpr.IsAny()); + } + + [Test] + public async Task DeleteClothingItemAsync_WhenCalled_PostsToClothingDeleteEndpoint() + { + // Arrange + var itemId = 42; + var mockResponse = new HttpResponseMessage { StatusCode = HttpStatusCode.OK }; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.AbsolutePath.Contains("/clothing/delete")), + ItExpr.IsAny() + ) + .ReturnsAsync(mockResponse); + + // Act & Assert (no exception = success) + await _apiService.Invoking(s => s.DeleteClothingItemAsync(itemId)).Should().NotThrowAsync(); + _mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri!.AbsolutePath.Contains("/clothing/delete")), + ItExpr.IsAny()); + } } \ No newline at end of file diff --git a/WardrobeManager.Presentation.Tests/Helpers/FakeNavigationManager.cs b/WardrobeManager.Presentation.Tests/Helpers/FakeNavigationManager.cs new file mode 100644 index 0000000..074b2ed --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Helpers/FakeNavigationManager.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Components; + +namespace WardrobeManager.Presentation.Tests.Helpers; + +/// +/// A testable concrete implementation of the abstract NavigationManager, +/// used to allow ViewModels that depend on NavigationManager to be instantiated +/// in unit tests without a real Blazor host. +/// +public class FakeNavigationManager : NavigationManager +{ + public FakeNavigationManager(string baseUri = "https://localhost/") + { + Initialize(baseUri, baseUri); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + // No-op in tests + } +} diff --git a/WardrobeManager.Presentation.Tests/Identity/CookieAuthenticationStateProviderTests.cs b/WardrobeManager.Presentation.Tests/Identity/CookieAuthenticationStateProviderTests.cs new file mode 100644 index 0000000..3f92701 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Identity/CookieAuthenticationStateProviderTests.cs @@ -0,0 +1,343 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Moq; +using Moq.Protected; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using WardrobeManager.Presentation.Identity; +using WardrobeManager.Presentation.Identity.Models; + +namespace WardrobeManager.Presentation.Tests.Identity; + +public class CookieAuthenticationStateProviderTests +{ + private Mock _mockHttpClientFactory; + private Mock _mockHandler; + private CookieAuthenticationStateProvider _provider; + + [SetUp] + public void Setup() + { + _mockHttpClientFactory = new Mock(); + _mockHandler = new Mock(); + + var httpClient = new HttpClient(_mockHandler.Object) + { + BaseAddress = new Uri("https://localhost:5000") + }; + + _mockHttpClientFactory.Setup(f => f.CreateClient("Auth")).Returns(httpClient); + _provider = new CookieAuthenticationStateProvider(_mockHttpClientFactory.Object); + } + + #region RegisterAsync + + [Test] + public async Task RegisterAsync_WhenSuccessful_ReturnsSucceededResult() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("register")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + var result = await _provider.RegisterAsync("test@test.com", "password"); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Test] + public async Task RegisterAsync_WhenFailed_ReturnsFailedResultWithErrors() + { + // Arrange + var errorBody = """{"errors":{"DuplicateEmail":["Email already taken"]}}"""; + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("register")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(errorBody, System.Text.Encoding.UTF8, "application/json") + }); + + // Act + var result = await _provider.RegisterAsync("test@test.com", "password"); + + // Assert + using (new AssertionScope()) + { + result.Succeeded.Should().BeFalse(); + result.ErrorList.Should().Contain("Email already taken"); + } + } + + [Test] + public async Task RegisterAsync_WhenExceptionThrown_ReturnsFailedResult() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Connection failed")); + + // Act + var result = await _provider.RegisterAsync("test@test.com", "password"); + + // Assert + result.Succeeded.Should().BeFalse(); + } + + #endregion + + #region LoginAsync + + [Test] + public async Task LoginAsync_WhenSuccessful_ReturnsSucceededResult() + { + // Arrange + // Login returns 200 - subsequent GetAuthenticationState call returns 401 (not our concern here) + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("login")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // GetAuthenticationStateAsync will be called after login - return 401 to keep test simple + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var result = await _provider.LoginAsync("test@test.com", "password"); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Test] + public async Task LoginAsync_WhenFailed_ReturnsFailedResult() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("login")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var result = await _provider.LoginAsync("test@test.com", "wrongpassword"); + + // Assert + using (new AssertionScope()) + { + result.Succeeded.Should().BeFalse(); + result.ErrorList.Should().Contain("Invalid email and/or password."); + } + } + + [Test] + public async Task LoginAsync_WhenExceptionThrown_ReturnsFailedResult() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + // Act + var result = await _provider.LoginAsync("test@test.com", "password"); + + // Assert + result.Succeeded.Should().BeFalse(); + } + + #endregion + + #region LogoutAsync + + [Test] + public async Task LogoutAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("logout")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // GetAuthenticationStateAsync will also be called + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var result = await _provider.LogoutAsync(); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task LogoutAsync_WhenFailed_ReturnsFalse() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri!.AbsolutePath.Contains("logout")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var result = await _provider.LogoutAsync(); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region GetAuthenticationStateAsync + + [Test] + public async Task GetAuthenticationStateAsync_WhenNotAuthenticated_ReturnsAnonymousUser() + { + // Arrange + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var state = await _provider.GetAuthenticationStateAsync(); + + // Assert + using (new AssertionScope()) + { + state.Should().NotBeNull(); + state.User.Identity?.IsAuthenticated.Should().BeFalse(); + } + } + + [Test] + public async Task GetAuthenticationStateAsync_WhenAuthenticated_ReturnsAuthenticatedUser() + { + // Arrange + var userInfo = new UserInfo + { + Email = "test@test.com", + IsEmailConfirmed = true + }; + var userInfoJson = JsonSerializer.Serialize(userInfo, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + // manage/info returns user info + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get && + req.RequestUri!.AbsolutePath.Contains("manage/info")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(userInfoJson, System.Text.Encoding.UTF8, "application/json") + }); + + // roles returns empty array + _mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get && + req.RequestUri!.AbsolutePath.Contains("roles")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json") + }); + + // Act + var state = await _provider.GetAuthenticationStateAsync(); + + // Assert + using (new AssertionScope()) + { + state.Should().NotBeNull(); + state.User.Identity?.IsAuthenticated.Should().BeTrue(); + state.User.Identity?.Name.Should().Be("test@test.com"); + } + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/Identity/CookieHandlerTests.cs b/WardrobeManager.Presentation.Tests/Identity/CookieHandlerTests.cs new file mode 100644 index 0000000..b8f7948 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Identity/CookieHandlerTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Execution; +using Moq; +using Moq.Protected; +using WardrobeManager.Presentation.Services.Interfaces; + +namespace WardrobeManager.Presentation.Tests.Identity; + +/// +/// Tests for CustomHttpMessageHandler (which replaced the old CookieHandler). +/// CustomHttpMessageHandler adds X-Requested-With header, includes browser credentials, +/// and reports HTTP errors through INotificationService. +/// +public class CookieHandlerTests +{ + private Mock _mockNotificationService; + private Mock _mockInnerHandler; + private CustomHttpMessageHandler _handler; + private HttpClient _httpClient; + + [SetUp] + public void Setup() + { + _mockNotificationService = new Mock(); + _mockInnerHandler = new Mock(); + + _handler = new CustomHttpMessageHandler(_mockNotificationService.Object) + { + InnerHandler = _mockInnerHandler.Object + }; + + _httpClient = new HttpClient(_handler) + { + BaseAddress = new Uri("https://example.com") + }; + } + + [TearDown] + public void TearDown() + { + _httpClient.Dispose(); + _handler.Dispose(); + } + + [Test] + public async Task SendAsync_WhenCalled_AddsXRequestedWithHeader() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + _mockInnerHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + await _httpClient.GetAsync("/test"); + + // Assert + using (new AssertionScope()) + { + capturedRequest.Should().NotBeNull(); + capturedRequest!.Headers.Should().ContainKey("X-Requested-With"); + capturedRequest.Headers.GetValues("X-Requested-With").Should().Contain("XMLHttpRequest"); + } + } + + [Test] + public async Task SendAsync_WhenCalled_ForwardsRequestToInnerHandler() + { + // Arrange + _mockInnerHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + await _httpClient.GetAsync("/test"); + + // Assert + _mockInnerHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + [Test] + public async Task SendAsync_WhenResponseIsNotSuccess_NotifiesNotificationService() + { + // Arrange + _mockInnerHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + // Act + await _httpClient.GetAsync("/test"); + + // Assert + _mockNotificationService.Verify( + s => s.AddNotification(It.Is(m => m.Contains("500")), It.IsAny()), + Times.Once); + } + + [Test] + public async Task SendAsync_WhenSuccessfulResponse_DoesNotCallNotificationService() + { + // Arrange + _mockInnerHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + await _httpClient.GetAsync("/test"); + + // Assert + _mockNotificationService.Verify( + s => s.AddNotification(It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/WardrobeManager.Presentation.Tests/Identity/UserInfoTests.cs b/WardrobeManager.Presentation.Tests/Identity/UserInfoTests.cs new file mode 100644 index 0000000..dd44e12 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Identity/UserInfoTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Presentation.Identity.Models; + +namespace WardrobeManager.Presentation.Tests.Identity; + +public class UserInfoTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void UserInfo_WhenCreated_HasDefaultValues() + { + // Arrange + // Act + var userInfo = new UserInfo(); + + // Assert + using (new AssertionScope()) + { + userInfo.Email.Should().Be(string.Empty); + userInfo.IsEmailConfirmed.Should().BeFalse(); + userInfo.Claims.Should().BeEmpty(); + } + } + + [Test] + public void UserInfo_WhenEmailIsSet_ReturnsSetEmail() + { + // Arrange + const string email = "test@example.com"; + + // Act + var userInfo = new UserInfo { Email = email }; + + // Assert + userInfo.Email.Should().Be(email); + } + + [Test] + public void UserInfo_WhenIsEmailConfirmedIsSet_ReturnsSetValue() + { + // Arrange + // Act + var userInfo = new UserInfo { IsEmailConfirmed = true }; + + // Assert + userInfo.IsEmailConfirmed.Should().BeTrue(); + } + + [Test] + public void UserInfo_WhenClaimsAreAdded_ContainsClaims() + { + // Arrange + var claims = new Dictionary + { + { "role", "Admin" }, + { "sub", "user-id-123" } + }; + + // Act + var userInfo = new UserInfo { Claims = claims }; + + // Assert + using (new AssertionScope()) + { + userInfo.Claims.Should().HaveCount(2); + userInfo.Claims["role"].Should().Be("Admin"); + userInfo.Claims["sub"].Should().Be("user-id-123"); + } + } +} diff --git a/WardrobeManager.Presentation.Tests/Services/IdentityServiceTests.cs b/WardrobeManager.Presentation.Tests/Services/IdentityServiceTests.cs new file mode 100644 index 0000000..1bee35d --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Services/IdentityServiceTests.cs @@ -0,0 +1,278 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Components.Authorization; +using Moq; +using System.Security.Claims; +using WardrobeManager.Presentation.Identity; +using WardrobeManager.Presentation.Identity.Models; +using WardrobeManager.Presentation.Services.Implementation; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Presentation.Tests.Services; + +public class IdentityServiceTests +{ + private Mock _mockAccountManagement; + private Mock _mockNotificationService; + private IdentityService _service; + + [SetUp] + public void Setup() + { + _mockAccountManagement = new Mock(); + _mockNotificationService = new Mock(); + _service = new IdentityService(_mockAccountManagement.Object, _mockNotificationService.Object); + } + + #region SignupAsync + + [Test] + public async Task SignupAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "password" }; + _mockAccountManagement + .Setup(a => a.RegisterAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult { Succeeded = true }); + + // Act + var result = await _service.SignupAsync(credentials); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task SignupAsync_WhenFailed_ReturnsFalse() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "password" }; + _mockAccountManagement + .Setup(a => a.RegisterAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult { Succeeded = false, ErrorList = ["Email already taken"] }); + + // Act + var result = await _service.SignupAsync(credentials); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public async Task SignupAsync_WhenFailed_AddsErrorNotifications() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "password" }; + _mockAccountManagement + .Setup(a => a.RegisterAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult + { + Succeeded = false, + ErrorList = ["Email already taken", "Password too short"] + }); + + // Act + await _service.SignupAsync(credentials); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification(It.IsAny(), NotificationType.Error), Times.Exactly(2)); + } + + #endregion + + #region LoginAsync + + [Test] + public async Task LoginAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "password" }; + _mockAccountManagement + .Setup(a => a.LoginAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult { Succeeded = true }); + + // Act + var result = await _service.LoginAsync(credentials); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task LoginAsync_WhenFailed_ReturnsFalse() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "password" }; + _mockAccountManagement + .Setup(a => a.LoginAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult { Succeeded = false, ErrorList = ["Invalid email and/or password."] }); + + // Act + var result = await _service.LoginAsync(credentials); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public async Task LoginAsync_WhenFailed_AddsErrorNotification() + { + // Arrange + var credentials = new AuthenticationCredentialsModel { Email = "test@test.com", Password = "bad" }; + _mockAccountManagement + .Setup(a => a.LoginAsync(credentials.Email, credentials.Password)) + .ReturnsAsync(new FormResult { Succeeded = false, ErrorList = ["Invalid email and/or password."] }); + + // Act + await _service.LoginAsync(credentials); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification("Invalid email and/or password.", NotificationType.Error), Times.Once); + } + + #endregion + + #region LogoutAsync + + [Test] + public async Task LogoutAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _mockAccountManagement.Setup(a => a.LogoutAsync()).ReturnsAsync(true); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(new ClaimsPrincipal())); + + // Act + var result = await _service.LogoutAsync(); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task LogoutAsync_WhenFailed_ReturnsFalse() + { + // Arrange + _mockAccountManagement.Setup(a => a.LogoutAsync()).ReturnsAsync(false); + + // Act + var result = await _service.LogoutAsync(); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public async Task LogoutAsync_WhenFailed_AddsErrorNotification() + { + // Arrange + _mockAccountManagement.Setup(a => a.LogoutAsync()).ReturnsAsync(false); + + // Act + await _service.LogoutAsync(); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification(It.IsAny(), NotificationType.Error), Times.Once); + } + + [Test] + public async Task LogoutAsync_WhenSuccessful_AddsSuccessNotification() + { + // Arrange + _mockAccountManagement.Setup(a => a.LogoutAsync()).ReturnsAsync(true); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(new ClaimsPrincipal())); + + // Act + await _service.LogoutAsync(); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification(It.IsAny(), NotificationType.Success), Times.Once); + } + + #endregion + + #region IsAuthenticated + + [Test] + public async Task IsAuthenticated_WhenAuthenticated_ReturnsTrue() + { + // Arrange + var claims = new[] { new Claim(ClaimTypes.Name, "test@test.com") }; + var identity = new ClaimsIdentity(claims, "TestScheme"); + var principal = new ClaimsPrincipal(identity); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(principal)); + + // Act + var result = await _service.IsAuthenticated(); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public async Task IsAuthenticated_WhenNotAuthenticated_ReturnsFalse() + { + // Arrange - anonymous principal (no authentication type) + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(principal)); + + // Act + var result = await _service.IsAuthenticated(); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region GetUserInformation + + [Test] + public async Task GetUserInformation_WhenCalled_ReturnsPrincipalFromAuthState() + { + // Arrange + var claims = new[] { new Claim(ClaimTypes.Name, "test@test.com") }; + var identity = new ClaimsIdentity(claims, "TestScheme"); + var principal = new ClaimsPrincipal(identity); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(principal)); + + // Act + var result = await _service.GetUserInformation(); + + // Assert + result.Should().BeSameAs(principal); + } + + [Test] + public async Task GetUserInformation_WhenNotAuthenticated_ReturnsAnonymousPrincipal() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + _mockAccountManagement + .Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(principal)); + + // Act + var result = await _service.GetUserInformation(); + + // Assert + result.Identity?.IsAuthenticated.Should().BeFalse(); + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/Services/NotificationServiceTests.cs b/WardrobeManager.Presentation.Tests/Services/NotificationServiceTests.cs new file mode 100644 index 0000000..11c270f --- /dev/null +++ b/WardrobeManager.Presentation.Tests/Services/NotificationServiceTests.cs @@ -0,0 +1,187 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Presentation.Services.Implementation; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Presentation.Tests.Services; + +public class NotificationServiceTests +{ + private NotificationService _service; + private int _onChangeCallCount; + + [SetUp] + public void Setup() + { + _service = new NotificationService(); + _onChangeCallCount = 0; + _service.OnChange += () => _onChangeCallCount++; + } + + #region AddNotification (string only) + + [Test] + public void AddNotification_StringOnly_AddsNotificationWithInfoType() + { + // Arrange + const string message = "Test message"; + + // Act + _service.AddNotification(message); + + // Assert + using (new AssertionScope()) + { + _service.Notifications.Should().HaveCount(1); + _service.Notifications[0].Message.Should().Be(message); + _service.Notifications[0].Type.Should().Be(NotificationType.Info); + } + } + + #endregion + + #region AddNotification (string + type) + + [Test] + public void AddNotification_WithErrorType_SetsCorrectType() + { + // Arrange + const string message = "Error occurred"; + + // Act + _service.AddNotification(message, NotificationType.Error); + + // Assert + using (new AssertionScope()) + { + _service.Notifications.Should().HaveCount(1); + _service.Notifications[0].Message.Should().Be(message); + _service.Notifications[0].Type.Should().Be(NotificationType.Error); + } + } + + [Test] + public void AddNotification_WithSuccessType_SetsCorrectType() + { + // Arrange + + // Act + _service.AddNotification("Success!", NotificationType.Success); + + // Assert + _service.Notifications[0].Type.Should().Be(NotificationType.Success); + } + + [Test] + public void AddNotification_MultipleNotifications_AllAppearInList() + { + // Arrange + + // Act + _service.AddNotification("First", NotificationType.Info); + _service.AddNotification("Second", NotificationType.Warning); + _service.AddNotification("Third", NotificationType.Error); + + // Assert + _service.Notifications.Should().HaveCount(3); + } + + #endregion + + #region RemoveNotification + + [Test] + public void RemoveNotification_WhenNotificationExists_RemovesItFromList() + { + // Arrange + _service.AddNotification("To be removed", NotificationType.Info); + var notificationToRemove = _service.Notifications[0]; + + // Act + _service.RemoveNotification(notificationToRemove); + + // Assert + _service.Notifications.Should().BeEmpty(); + } + + [Test] + public void RemoveNotification_WhenOneOfMany_RemovesOnlyTargetNotification() + { + // Arrange + _service.AddNotification("Keep this", NotificationType.Info); + _service.AddNotification("Remove this", NotificationType.Error); + var toRemove = _service.Notifications[1]; + + // Act + _service.RemoveNotification(toRemove); + + // Assert + using (new AssertionScope()) + { + _service.Notifications.Should().HaveCount(1); + _service.Notifications[0].Message.Should().Be("Keep this"); + } + } + + #endregion + + #region OnChange event + + [Test] + public void AddNotification_WhenCalled_FiresOnChangeEvent() + { + // Arrange + + // Act + _service.AddNotification("Event test"); + + // Assert + _onChangeCallCount.Should().Be(1); + } + + [Test] + public void RemoveNotification_WhenCalled_FiresOnChangeEvent() + { + // Arrange + _service.AddNotification("Test"); + var notification = _service.Notifications[0]; + var countBeforeRemoval = _onChangeCallCount; + + // Act + _service.RemoveNotification(notification); + + // Assert + _onChangeCallCount.Should().Be(countBeforeRemoval + 1); + } + + #endregion + + #region Notifications property + + [Test] + public void Notifications_WhenEmpty_ReturnsEmptyList() + { + // Act + var result = _service.Notifications; + + // Assert + result.Should().BeEmpty(); + } + + [Test] + public void Notifications_ReturnsACopy_NotTheOriginalList() + { + // Arrange + _service.AddNotification("Test"); + + // Act + var list1 = _service.Notifications; + var list2 = _service.Notifications; + + // Assert + list1.Should().NotBeSameAs(list2); // each call returns a new list copy + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/AddClothingItemViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/AddClothingItemViewModelTests.cs new file mode 100644 index 0000000..ac8f055 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/AddClothingItemViewModelTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Moq; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Presentation.ViewModels; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.StaticResources; +using Blazing.Mvvm.Components; +using Microsoft.Extensions.Configuration; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class AddClothingItemViewModelTests +{ + private Mock _mockApiService; + private Mock _mockNotificationService; + private Mock _mockMiscMethods; + private Mock _mockConfiguration; + private AddClothingItemViewModel _viewModel; + + [SetUp] + public void Setup() + { + _mockApiService = new Mock(); + _mockNotificationService = new Mock(); + _mockMiscMethods = new Mock(); + _mockConfiguration = new Mock(); + + _mockMiscMethods + .Setup(m => m.ConvertEnumToCollection()) + .Returns(new List { ClothingCategory.TShirt, ClothingCategory.Jeans }); + _mockMiscMethods + .Setup(m => m.ConvertEnumToCollection()) + .Returns(new List { ClothingSize.Small, ClothingSize.Medium }); + + _viewModel = new AddClothingItemViewModel( + _mockApiService.Object, + _mockNotificationService.Object, + _mockMiscMethods.Object, + _mockConfiguration.Object + ); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + #region SubmitAsync + + [Test] + public async Task SubmitAsync_WhenItemIsValid_CallsApiAndNotifiesSuccess() + { + // Arrange + _viewModel.NewClothingItem = new NewClothingItemDTO { Name = "My Jeans", Category = ClothingCategory.Jeans }; + _mockApiService.Setup(s => s.AddNewClothingItemAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + await _viewModel.SubmitAsync(); + + // Assert + using (new AssertionScope()) + { + _mockApiService.Verify(s => s.AddNewClothingItemAsync(It.IsAny()), Times.Once); + _mockNotificationService.Verify( + s => s.AddNotification(It.Is(m => m.Contains("My Jeans")), NotificationType.Success), + Times.Once); + } + } + + [Test] + public async Task SubmitAsync_WhenItemIsValid_ResetsNewClothingItem() + { + // Arrange + _viewModel.NewClothingItem = new NewClothingItemDTO { Name = "My Jeans", Category = ClothingCategory.Jeans }; + _mockApiService.Setup(s => s.AddNewClothingItemAsync(It.IsAny())).Returns(Task.CompletedTask); + + // Act + await _viewModel.SubmitAsync(); + + // Assert - NewClothingItem should be reset to default + _viewModel.NewClothingItem.Name.Should().Be(string.Empty); + } + + [Test] + public async Task SubmitAsync_WhenItemNameIsEmpty_DoesNotCallApi() + { + // Arrange + _viewModel.NewClothingItem = new NewClothingItemDTO { Name = "", Category = ClothingCategory.Jeans }; + + // Act + await _viewModel.SubmitAsync(); + + // Assert + using (new AssertionScope()) + { + _mockApiService.Verify(s => s.AddNewClothingItemAsync(It.IsAny()), Times.Never); + _mockNotificationService.Verify( + s => s.AddNotification(It.IsAny(), NotificationType.Error), + Times.Once); + } + } + + #endregion + + #region GetNameWithSpacesAndEmoji + + [Test] + public void GetNameWithSpacesAndEmoji_WhenCalled_DelegatesToMiscMethods() + { + // Arrange + _mockMiscMethods + .Setup(m => m.GetNameWithSpacesFromEnum(ClothingCategory.TShirt)) + .Returns("πŸ‘• T Shirt"); + + // Act + var result = _viewModel.GetNameWithSpacesAndEmoji(ClothingCategory.TShirt); + + // Assert + result.Should().Be("πŸ‘• T Shirt"); + } + + #endregion + + #region ClothingCategories / ClothingSizes + + [Test] + public void ClothingCategories_WhenInstantiated_IsPopulated() + { + _viewModel.ClothingCategories.Should().NotBeNullOrEmpty(); + } + + [Test] + public void ClothingSizes_WhenInstantiated_IsPopulated() + { + _viewModel.ClothingSizes.Should().NotBeNullOrEmpty(); + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/DashboardViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/DashboardViewModelTests.cs new file mode 100644 index 0000000..5d773d0 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/DashboardViewModelTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using WardrobeManager.Presentation.ViewModels; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class DashboardViewModelTests +{ + private DashboardViewModel _viewModel; + + [SetUp] + public void Setup() + { + _viewModel = new DashboardViewModel(); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + [Test] + public void DashboardViewModel_WhenInstantiated_IsNotNull() + { + // Assert + _viewModel.Should().NotBeNull(); + } +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/HomeViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/HomeViewModelTests.cs new file mode 100644 index 0000000..57b4046 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/HomeViewModelTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using WardrobeManager.Presentation.ViewModels; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class HomeViewModelTests +{ + private HomeViewModel _viewModel; + + [SetUp] + public void Setup() + { + _viewModel = new HomeViewModel(); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + [Test] + public void HomeViewModel_WhenInstantiated_IsNotNull() + { + // Assert + _viewModel.Should().NotBeNull(); + } +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/LoginViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/LoginViewModelTests.cs new file mode 100644 index 0000000..8779bb3 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/LoginViewModelTests.cs @@ -0,0 +1,167 @@ +using Blazing.Mvvm.Components; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Components.Web; +using Moq; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Presentation.ViewModels; +using WardrobeManager.Presentation.Identity; +using WardrobeManager.Presentation.Identity.Models; +using WardrobeManager.Presentation.Tests.Helpers; +using WardrobeManager.Shared.Models; +using Microsoft.AspNetCore.Components.Authorization; +using System.Security.Claims; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class LoginViewModelTests +{ + private Mock _mockNavManager; + private Mock _mockIdentityService; + private LoginViewModel _viewModel; + + [SetUp] + public void Setup() + { + _mockNavManager = new Mock(); + _mockIdentityService = new Mock(); + + _viewModel = new LoginViewModel( + _mockNavManager.Object, + _mockIdentityService.Object + ); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + #region SetEmail / SetPassword + + [Test] + public void SetEmail_WhenCalled_SetsEmailOnCredentialsModel() + { + // Arrange + const string email = "test@test.com"; + + // Act + _viewModel.SetEmail(email); + + // Assert + _viewModel.AuthenticationCredentialsModel.Email.Should().Be(email); + } + + [Test] + public void SetPassword_WhenCalled_SetsPasswordOnCredentialsModel() + { + // Arrange + const string password = "securepassword"; + + // Act + _viewModel.SetPassword(password); + + // Assert + _viewModel.AuthenticationCredentialsModel.Password.Should().Be(password); + } + + #endregion + + #region LoginAsync + + [Test] + public async Task LoginAsync_WhenLoginSucceeds_NavigatesToDashboard() + { + // Arrange + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(true); + + // Act + await _viewModel.LoginAsync(); + + // Assert + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Once); + } + + [Test] + public async Task LoginAsync_WhenLoginFails_DoesNotNavigate() + { + // Arrange + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(false); + + // Act + await _viewModel.LoginAsync(); + + // Assert + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Never); + } + + #endregion + + #region DetectEnterPressed + + [Test] + public async Task DetectEnterPressed_WhenEnterKey_CallsLoginAsync() + { + // Arrange + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(true); + var eventArgs = new KeyboardEventArgs { Key = "Enter" }; + + // Act + await _viewModel.DetectEnterPressed(eventArgs); + + // Assert + _mockIdentityService.Verify(s => s.LoginAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task DetectEnterPressed_WhenOtherKey_DoesNotCallLoginAsync() + { + // Arrange + var eventArgs = new KeyboardEventArgs { Key = "a" }; + + // Act + await _viewModel.DetectEnterPressed(eventArgs); + + // Assert + _mockIdentityService.Verify(s => s.LoginAsync(It.IsAny()), Times.Never); + } + + #endregion + + #region OnInitializedAsync + + [Test] + public async Task OnInitializedAsync_WhenAlreadyAuthenticated_NavigatesToDashboard() + { + // Arrange + _mockIdentityService.Setup(s => s.IsAuthenticated()).ReturnsAsync(true); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Once); + } + + [Test] + public async Task OnInitializedAsync_WhenNotAuthenticated_DoesNotNavigate() + { + // Arrange + _mockIdentityService.Setup(s => s.IsAuthenticated()).ReturnsAsync(false); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Never); + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/NavBarViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/NavBarViewModelTests.cs new file mode 100644 index 0000000..957ef5e --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/NavBarViewModelTests.cs @@ -0,0 +1,169 @@ +using Blazing.Mvvm.Components; +using FluentAssertions; +using FluentAssertions.Execution; +using Moq; +using System.Security.Claims; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Presentation.ViewModels; +using WardrobeManager.Presentation.Tests.Helpers; +using Microsoft.AspNetCore.Components.Authorization; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class NavBarViewModelTests +{ + private Mock _mockNavManager; + private Mock _mockIdentityService; + private Mock _mockApiService; + private NavBarViewModel _viewModel; + + [SetUp] + public void Setup() + { + _mockNavManager = new Mock(); + _mockIdentityService = new Mock(); + _mockApiService = new Mock(); + + _viewModel = new NavBarViewModel( + _mockNavManager.Object, + _mockIdentityService.Object, + _mockApiService.Object + ); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + #region ToggleUserPopover + + [Test] + public void ToggleUserPopover_WhenCalledOnce_ShowsPopover() + { + // Arrange - popover initially hidden + + // Act + _viewModel.ToggleUserPopover(); + + // Assert + _viewModel.ShowUserPopover.Should().BeTrue(); + } + + [Test] + public void ToggleUserPopover_WhenCalledTwice_HidesPopover() + { + // Arrange + + // Act + _viewModel.ToggleUserPopover(); + _viewModel.ToggleUserPopover(); + + // Assert + _viewModel.ShowUserPopover.Should().BeFalse(); + } + + #endregion + + #region OnInitializedAsync + + [Test] + public async Task OnInitializedAsync_WhenApiIsUp_SetsCanConnectToBackendTrue() + { + // Arrange + _mockApiService.Setup(s => s.CheckApiConnection()).ReturnsAsync(true); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "test@test.com") }, "TestScheme")); + _mockIdentityService.Setup(s => s.GetUserInformation()).ReturnsAsync(principal); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _viewModel.CanConnectToBackend.Should().BeTrue(); + } + + [Test] + public async Task OnInitializedAsync_WhenApiIsDown_SetsCanConnectToBackendFalse() + { + // Arrange + _mockApiService.Setup(s => s.CheckApiConnection()).ReturnsAsync(false); + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + _mockIdentityService.Setup(s => s.GetUserInformation()).ReturnsAsync(principal); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _viewModel.CanConnectToBackend.Should().BeFalse(); + } + + [Test] + public async Task OnInitializedAsync_WhenUserHasName_SetsUsersName() + { + // Arrange + _mockApiService.Setup(s => s.CheckApiConnection()).ReturnsAsync(true); + var claims = new[] { new Claim(ClaimTypes.Name, "John Doe") }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestScheme")); + _mockIdentityService.Setup(s => s.GetUserInformation()).ReturnsAsync(principal); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _viewModel.UsersName.Should().Be("John Doe"); + } + + [Test] + public async Task OnInitializedAsync_WhenUserHasNoName_SetsDefaultName() + { + // Arrange + _mockApiService.Setup(s => s.CheckApiConnection()).ReturnsAsync(true); + var principal = new ClaimsPrincipal(new ClaimsIdentity()); // anonymous + _mockIdentityService.Setup(s => s.GetUserInformation()).ReturnsAsync(principal); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + _viewModel.UsersName.Should().Be("Logged In User"); + } + + #endregion + + #region LogoutAsync + + [Test] + public async Task LogoutAsync_WhenCalled_CallsIdentityServiceLogout() + { + // Arrange + _mockIdentityService.Setup(s => s.LogoutAsync()).ReturnsAsync(true); + + // Act + await _viewModel.LogoutAsync(); + + // Assert + _mockIdentityService.Verify(s => s.LogoutAsync(), Times.Once); + } + + [Test] + public async Task LogoutAsync_WhenCalled_HidesPopoverAndNavigatesHome() + { + // Arrange + _mockIdentityService.Setup(s => s.LogoutAsync()).ReturnsAsync(true); + _viewModel.ToggleUserPopover(); // Show the popover first + + // Act + await _viewModel.LogoutAsync(); + + // Assert + using (new AssertionScope()) + { + _viewModel.ShowUserPopover.Should().BeFalse(); + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Once); + } + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/SignupViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/SignupViewModelTests.cs new file mode 100644 index 0000000..722d1b7 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/SignupViewModelTests.cs @@ -0,0 +1,182 @@ +using Blazing.Mvvm.Components; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.AspNetCore.Components.Web; +using Moq; +using WardrobeManager.Presentation.Identity; +using WardrobeManager.Presentation.Identity.Models; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Presentation.ViewModels; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class SignupViewModelTests +{ + private Mock _mockNotificationService; + private Mock _mockNavManager; + private Mock _mockIdentityService; + private SignupViewModel _viewModel; + + [SetUp] + public void Setup() + { + _mockNotificationService = new Mock(); + _mockNavManager = new Mock(); + _mockIdentityService = new Mock(); + + _viewModel = new SignupViewModel( + _mockNotificationService.Object, + _mockNavManager.Object, + _mockIdentityService.Object + ); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + #region SetEmail / SetPassword + + [Test] + public void SetEmail_WhenCalled_SetsEmailOnCredentialsModel() + { + // Arrange + const string email = "signup@test.com"; + + // Act + _viewModel.SetEmail(email); + + // Assert + _viewModel.AuthenticationCredentialsModel.Email.Should().Be(email); + } + + [Test] + public void SetPassword_WhenCalled_SetsPasswordOnCredentialsModel() + { + // Arrange + const string password = "mypassword"; + + // Act + _viewModel.SetPassword(password); + + // Assert + _viewModel.AuthenticationCredentialsModel.Password.Should().Be(password); + } + + #endregion + + #region SignupAsync + + [Test] + public async Task SignupAsync_WhenBothSucceed_NavigatesToDashboard() + { + // Arrange + _mockIdentityService + .Setup(s => s.SignupAsync(It.IsAny())) + .ReturnsAsync(true); + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(true); + + // Act + await _viewModel.SignupAsync(); + + // Assert + _mockNavManager.Verify(n => n.NavigateTo(false, false), Times.Once); + } + + [Test] + public async Task SignupAsync_WhenSignupFails_DoesNotAttemptLogin() + { + // Arrange + _mockIdentityService + .Setup(s => s.SignupAsync(It.IsAny())) + .ReturnsAsync(false); + + // Act + await _viewModel.SignupAsync(); + + // Assert + _mockIdentityService.Verify(s => s.LoginAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task SignupAsync_WhenSignupSucceedsButLoginFails_AddsErrorNotification() + { + // Arrange + _mockIdentityService + .Setup(s => s.SignupAsync(It.IsAny())) + .ReturnsAsync(true); + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(false); + + // Act + await _viewModel.SignupAsync(); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification(It.IsAny(), NotificationType.Error), Times.Once); + } + + [Test] + public async Task SignupAsync_WhenBothSucceed_AddsSuccessNotification() + { + // Arrange + _mockIdentityService + .Setup(s => s.SignupAsync(It.IsAny())) + .ReturnsAsync(true); + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(true); + + // Act + await _viewModel.SignupAsync(); + + // Assert + _mockNotificationService.Verify(n => + n.AddNotification(It.IsAny(), NotificationType.Success), Times.Once); + } + + #endregion + + #region DetectEnterPressed + + [Test] + public async Task DetectEnterPressed_WhenEnterKey_CallsSignupAsync() + { + // Arrange + _mockIdentityService + .Setup(s => s.SignupAsync(It.IsAny())) + .ReturnsAsync(true); + _mockIdentityService + .Setup(s => s.LoginAsync(It.IsAny())) + .ReturnsAsync(true); + var eventArgs = new KeyboardEventArgs { Key = "Enter" }; + + // Act + await _viewModel.DetectEnterPressed(eventArgs); + + // Assert + _mockIdentityService.Verify(s => s.SignupAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task DetectEnterPressed_WhenOtherKey_DoesNotCallSignupAsync() + { + // Arrange + var eventArgs = new KeyboardEventArgs { Key = "Tab" }; + + // Act + await _viewModel.DetectEnterPressed(eventArgs); + + // Assert + _mockIdentityService.Verify(s => s.SignupAsync(It.IsAny()), Times.Never); + } + + #endregion +} diff --git a/WardrobeManager.Presentation.Tests/ViewModels/WardrobeViewModelTests.cs b/WardrobeManager.Presentation.Tests/ViewModels/WardrobeViewModelTests.cs new file mode 100644 index 0000000..fadc699 --- /dev/null +++ b/WardrobeManager.Presentation.Tests/ViewModels/WardrobeViewModelTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using Moq; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Presentation.ViewModels; +using WardrobeManager.Presentation.Tests.Helpers; +using WardrobeManager.Shared.DTOs; +using Blazing.Mvvm.Components; + +namespace WardrobeManager.Presentation.Tests.ViewModels; + +public class WardrobeViewModelTests +{ + private Mock _mockApiService; + private Mock _mockNotificationService; + private WardrobeViewModel _viewModel; + + [SetUp] + public void Setup() + { + _mockApiService = new Mock(); + _mockNotificationService = new Mock(); + + _viewModel = new WardrobeViewModel( + _mockApiService.Object, + _mockNotificationService.Object + ); + } + + [TearDown] + public void TearDown() + { + _viewModel.Dispose(); + } + + #region FetchItemAndUpdate / OnInitializedAsync + + [Test] + public async Task OnInitializedAsync_WhenCalled_FetchesAndSetsClothingItems() + { + // Arrange + var items = new List + { + new ClothingItemDTO { Id = 1, Name = "T-Shirt" }, + new ClothingItemDTO { Id = 2, Name = "Jeans" } + }; + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + + // Act + await _viewModel.OnInitializedAsync(); + + // Assert + using (new AssertionScope()) + { + _viewModel.ClothingItems.Should().HaveCount(2); + _viewModel.ActionDialogStates.Should().HaveCount(2); + _viewModel.ActionDialogStates.Keys.Should().Contain(1); + _viewModel.ActionDialogStates.Keys.Should().Contain(2); + } + } + + [Test] + public async Task FetchItemAndUpdate_WhenCalled_ResetsDialogStates() + { + // Arrange - first fetch sets up states + var items = new List { new ClothingItemDTO { Id = 1, Name = "T-Shirt" } }; + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + await _viewModel.FetchItemAndUpdate(); + + // Manually set a dialog state + _viewModel.ActionDialogStates[1].ShowDelete = true; + + // Act - second fetch should reset + await _viewModel.FetchItemAndUpdate(); + + // Assert - state was reset + _viewModel.ActionDialogStates[1].ShowDelete.Should().BeFalse(); + } + + #endregion + + #region RemoveItem + + [Test] + public async Task RemoveItem_WhenCalled_DeletesItemAndRefreshes() + { + // Arrange + var itemId = 42; + var items = new List { new ClothingItemDTO { Id = 99, Name = "Remaining" } }; + _mockApiService.Setup(s => s.DeleteClothingItemAsync(itemId)).Returns(Task.CompletedTask); + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + + // Act + await _viewModel.RemoveItem(itemId); + + // Assert + _mockApiService.Verify(s => s.DeleteClothingItemAsync(itemId), Times.Once); + _mockApiService.Verify(s => s.GetAllClothingItemsAsync(), Times.Once); + _viewModel.ClothingItems.Should().HaveCount(1); + } + + #endregion + + #region UpdateActionDialogState + + [Test] + public async Task UpdateActionDialogState_Delete_SetsShowDeleteFlag() + { + // Arrange + var items = new List { new ClothingItemDTO { Id = 1, Name = "T-Shirt" } }; + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + await _viewModel.FetchItemAndUpdate(); + + // Act + _viewModel.UpdateActionDialogState(1, ActionType.Delete, true); + + // Assert + _viewModel.ActionDialogStates[1].ShowDelete.Should().BeTrue(); + } + + [Test] + public async Task UpdateActionDialogState_Edit_SetsShowEditFlag() + { + // Arrange + var items = new List { new ClothingItemDTO { Id = 1, Name = "T-Shirt" } }; + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + await _viewModel.FetchItemAndUpdate(); + + // Act + _viewModel.UpdateActionDialogState(1, ActionType.Edit, true); + + // Assert + _viewModel.ActionDialogStates[1].ShowEdit.Should().BeTrue(); + } + + [Test] + public void UpdateActionDialogState_WhenItemDoesNotExist_NotifiesError() + { + // Arrange - ActionDialogStates is empty + + // Act + _viewModel.UpdateActionDialogState(999, ActionType.Delete, true); + + // Assert + _mockNotificationService.Verify( + s => s.AddNotification(It.IsAny(), WardrobeManager.Shared.Enums.NotificationType.Error), + Times.Once); + } + + #endregion + + #region GetActionStateSafely + + [Test] + public async Task GetActionStateSafely_WhenDeleteIsSet_ReturnsTrue() + { + // Arrange + var items = new List { new ClothingItemDTO { Id = 1, Name = "T-Shirt" } }; + _mockApiService.Setup(s => s.GetAllClothingItemsAsync()).ReturnsAsync(items); + await _viewModel.FetchItemAndUpdate(); + _viewModel.UpdateActionDialogState(1, ActionType.Delete, true); + + // Act + var result = _viewModel.GetActionStateSafely(1, ActionType.Delete); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public void GetActionStateSafely_WhenItemDoesNotExist_ReturnsFalse() + { + // Arrange - no items loaded + + // Act + var result = _viewModel.GetActionStateSafely(999, ActionType.Delete); + + // Assert + result.Should().BeFalse(); + } + + #endregion +} diff --git a/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs b/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs index dd73d4d..a5a0855 100644 --- a/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs @@ -56,7 +56,7 @@ public void UpdateActionDialogState(int itemId, ActionType actionType, bool valu actionDialog.ShowDelete = value; break; case ActionType.Edit: - actionDialog.ShowDelete = value; + actionDialog.ShowEdit = value; break; default: notificationService.AddNotification("Action type not recognized!", NotificationType.Warning); diff --git a/WardrobeManager.Shared.Tests/DTOs/LogDTOTests.cs b/WardrobeManager.Shared.Tests/DTOs/LogDTOTests.cs new file mode 100644 index 0000000..2fc7b63 --- /dev/null +++ b/WardrobeManager.Shared.Tests/DTOs/LogDTOTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Shared.Tests.DTOs; + +public class LogDTOTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void LogDTO_InitializeWithProperties_HasPropertiesSet() + { + // Arrange + const string title = "System Error"; + const string description = "Database connection failed"; + const LogType type = LogType.Error; + const LogOrigin origin = LogOrigin.Backend; + var created = DateTime.UtcNow; + + // Act + var dto = new LogDTO + { + Title = title, + Description = description, + Type = type, + Origin = origin, + Created = created + }; + + // Assert + using (new AssertionScope()) + { + dto.Title.Should().Be(title); + dto.Description.Should().Be(description); + dto.Type.Should().Be(type); + dto.Origin.Should().Be(origin); + dto.Created.Should().Be(created); + } + } + + [Test] + public void LogDTO_WhenTypeIsInfo_HasInfoType() + { + // Arrange + // Act + var dto = new LogDTO + { + Title = "Info event", + Description = "Something informational", + Type = LogType.Info, + Origin = LogOrigin.Frontend + }; + + // Assert + dto.Type.Should().Be(LogType.Info); + } +} diff --git a/WardrobeManager.Shared.Tests/Models/EditedUserDTOTests.cs b/WardrobeManager.Shared.Tests/Models/EditedUserDTOTests.cs new file mode 100644 index 0000000..5f7247e --- /dev/null +++ b/WardrobeManager.Shared.Tests/Models/EditedUserDTOTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.DTOs; + +namespace WardrobeManager.Shared.Tests.Models; + +public class EditedUserDTOTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void EditedUserDTO_InitializeWithProperties_HasPropertiesSet() + { + // Arrange + const string name = "John Doe"; + const string profilePic = "base64profilepic"; + + // Act + var dto = new EditedUserDTO(name, profilePic); + + // Assert + using (new AssertionScope()) + { + dto.Name.Should().Be(name); + dto.ProfilePictureBase64.Should().Be(profilePic); + } + } + + [Test] + public void EditedUserDTO_WhenPropertiesAreUpdated_ReflectsChanges() + { + // Arrange + var dto = new EditedUserDTO("Original Name", "original-pic") + { + // Act + Name = "Updated Name", + ProfilePictureBase64 = "updated-pic" + }; + + // Assert + using (new AssertionScope()) + { + dto.Name.Should().Be("Updated Name"); + dto.ProfilePictureBase64.Should().Be("updated-pic"); + } + } + + [Test] + public void EditedUserDTO_WhenNameIsEmpty_AcceptsEmptyString() + { + // Arrange + // Act + var dto = new EditedUserDTO(string.Empty, string.Empty); + + // Assert + using (new AssertionScope()) + { + dto.Name.Should().Be(string.Empty); + dto.ProfilePictureBase64.Should().Be(string.Empty); + } + } +} diff --git a/WardrobeManager.Shared.Tests/Models/FilterModelTests.cs b/WardrobeManager.Shared.Tests/Models/FilterModelTests.cs new file mode 100644 index 0000000..1906e60 --- /dev/null +++ b/WardrobeManager.Shared.Tests/Models/FilterModelTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Shared.Tests.Models; + +public class FilterModelTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void FilterModel_WhenCreated_HasCorrectDefaults() + { + // Arrange + // Act + var model = new FilterModel(); + + // Assert + using (new AssertionScope()) + { + model.SearchQuery.Should().Be(string.Empty); + model.SortBy.Should().Be(SortByCategories.None); + model.IsAscending.Should().BeTrue(); + model.HasImage.Should().BeFalse(); + model.Favourited.Should().BeFalse(); + model.RecentlyAdded.Should().BeFalse(); + model.Category.Should().Be(ClothingCategory.None); + model.Season.Should().Be(Season.None); + model.DateAddedFrom.Should().BeNull(); + model.DateAddedTo.Should().BeNull(); + } + } + + [Test] + public void FilterModel_WhenPropertiesAreSet_ReflectsChanges() + { + // Arrange + var model = new FilterModel + { + // Act + SearchQuery = "blue shirt", + Favourited = true, + Category = ClothingCategory.TShirt, + Season = Season.Summer + }; + + // Assert + using (new AssertionScope()) + { + model.SearchQuery.Should().Be("blue shirt"); + model.Favourited.Should().BeTrue(); + model.Category.Should().Be(ClothingCategory.TShirt); + model.Season.Should().Be(Season.Summer); + } + } +} diff --git a/WardrobeManager.Shared.Tests/Models/NewOrEditedClothingItemDTOTests.cs b/WardrobeManager.Shared.Tests/Models/NewOrEditedClothingItemDTOTests.cs new file mode 100644 index 0000000..9edc978 --- /dev/null +++ b/WardrobeManager.Shared.Tests/Models/NewOrEditedClothingItemDTOTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Shared.Tests.Models; + +/// +/// Tests for NewClothingItemDTO (formerly NewOrEditedClothingItemDTO). +/// The old NewOrEditedClothingItemDTO was split into NewClothingItemDTO (add) and EditedUserDTO (edit user). +/// +public class NewOrEditedClothingItemDTOTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void NewClothingItemDTO_DefaultConstructor_HasExpectedDefaults() + { + // Act + var dto = new NewClothingItemDTO(); + + // Assert + using (new AssertionScope()) + { + dto.Should().NotBeNull(); + dto.Name.Should().Be(string.Empty); + dto.Category.Should().Be(ClothingCategory.None); + dto.Size.Should().Be(ClothingSize.NotSpecified); + dto.ImageBase64.Should().BeNull(); + } + } + + [Test] + public void NewClothingItemDTO_WhenPropertiesAreSet_ReflectsValues() + { + // Arrange + var dto = new NewClothingItemDTO + { + // Act + Name = "My T-Shirt", + Category = ClothingCategory.TShirt, + Size = ClothingSize.Medium, + ImageBase64 = "base64data" + }; + + // Assert + using (new AssertionScope()) + { + dto.Name.Should().Be("My T-Shirt"); + dto.Category.Should().Be(ClothingCategory.TShirt); + dto.Size.Should().Be(ClothingSize.Medium); + dto.ImageBase64.Should().Be("base64data"); + } + } + + [Test] + public void NewClothingItemDTO_ImageBase64_CanBeSetToNull() + { + // Arrange + var dto = new NewClothingItemDTO { ImageBase64 = "some-base64" }; + + // Act + dto.ImageBase64 = null; + + // Assert + dto.ImageBase64.Should().BeNull(); + } +} diff --git a/WardrobeManager.Shared.Tests/Models/ResultTests.cs b/WardrobeManager.Shared.Tests/Models/ResultTests.cs new file mode 100644 index 0000000..d4ac1e8 --- /dev/null +++ b/WardrobeManager.Shared.Tests/Models/ResultTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Shared.Tests.Models; + +public class ResultTests +{ + [Test] + public void Result_WhenCreatedWithSuccessTrue_HasCorrectValues() + { + // Arrange & Act + var result = new Result("hello", true); + + // Assert + using (new AssertionScope()) + { + result.Data.Should().Be("hello"); + result.Success.Should().BeTrue(); + result.Message.Should().Be(string.Empty); + } + } + + [Test] + public void Result_WhenCreatedWithSuccessFalseAndMessage_HasCorrectValues() + { + // Arrange & Act + var result = new Result(0, false, "Something went wrong"); + + // Assert + using (new AssertionScope()) + { + result.Data.Should().Be(0); + result.Success.Should().BeFalse(); + result.Message.Should().Be("Something went wrong"); + } + } + + [Test] + public void Result_WhenPropertiesAreMutated_ReflectsChanges() + { + // Arrange + var result = new Result(false, false, "Initial") + { + // Act + Success = true, + Message = "Updated", + Data = true + }; + + // Assert + using (new AssertionScope()) + { + result.Data.Should().BeTrue(); + result.Success.Should().BeTrue(); + result.Message.Should().Be("Updated"); + } + } +} diff --git a/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs b/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs new file mode 100644 index 0000000..4d128d0 --- /dev/null +++ b/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs @@ -0,0 +1,293 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.StaticResources; + +namespace WardrobeManager.Shared.Tests.StaticResources; + +public class MiscMethodsTests +{ + private MiscMethods _miscMethods; + + [SetUp] + public void Setup() + { + _miscMethods = new MiscMethods(); + } + + [Test] + public void GenerateRandomId_DefaultScenario_CorrectFormat() + { + // Arrange + // Act + var randomId = _miscMethods.GenerateRandomId(); + // Assert + randomId.Length.Should().Be(10); + randomId.Should().StartWith("id"); + } + + [Test] + public void GenerateRandomId_CalledTwice_ProducesUniqueIds() + { + // Arrange + // Act + var id1 = _miscMethods.GenerateRandomId(); + var id2 = _miscMethods.GenerateRandomId(); + // Assert + id1.Should().NotBe(id2); + } + + #region GetEmoji(ClothingCategory) + + [Test] + public void GetEmoji_TShirt_ReturnsShirtEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.TShirt); + emoji.Should().Be("πŸ‘•"); + } + + [Test] + public void GetEmoji_DressShirt_ReturnsTieEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.DressShirt); + emoji.Should().Be("πŸ‘”"); + } + + [Test] + public void GetEmoji_Jeans_ReturnsTrousersEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.Jeans); + emoji.Should().Be("πŸ‘–"); + } + + [Test] + public void GetEmoji_Sweater_ReturnsCoatEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.Sweater); + emoji.Should().Be("πŸ§₯"); + } + + [Test] + public void GetEmoji_Shorts_ReturnsShortsEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.Shorts); + emoji.Should().Be("🩳"); + } + + [Test] + public void GetEmoji_Sweatpants_ReturnsTrousersEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.Sweatpants); + emoji.Should().Be("πŸ‘–"); + } + + [Test] + public void GetEmoji_DressPants_ReturnsTrousersEmoji() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.DressPants); + emoji.Should().Be("πŸ‘–"); + } + + [Test] + public void GetEmoji_NoneCategory_ReturnsEmptyString() + { + var emoji = _miscMethods.GetEmoji(ClothingCategory.None); + emoji.Should().Be(string.Empty); + } + + #endregion + + #region GetEmoji(Season) + + [Test] + public void GetEmoji_Fall_ReturnsFallEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.Fall); + emoji.Should().Be("πŸ‚"); + } + + [Test] + public void GetEmoji_Winter_ReturnsSnowflakeEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.Winter); + emoji.Should().Be("❄️"); + } + + [Test] + public void GetEmoji_Summer_ReturnsSunEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.Summer); + emoji.Should().Be("β˜€οΈ"); + } + + [Test] + public void GetEmoji_Spring_ReturnsBlossomEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.Spring); + emoji.Should().Be("🌸"); + } + + [Test] + public void GetEmoji_FallAndWinter_ReturnsCombinedEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.FallAndWinter); + emoji.Should().Be("πŸ‚β„οΈ"); + } + + [Test] + public void GetEmoji_SpringAndSummer_ReturnsCombinedEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.SpringAndSummer); + emoji.Should().Be("πŸŒΈβ˜€οΈ"); + } + + [Test] + public void GetEmoji_SummerAndFall_ReturnsCombinedEmoji() + { + var emoji = _miscMethods.GetEmoji(Season.SummerAndFall); + emoji.Should().Be("β˜€οΈπŸ‚"); + } + + #endregion + + #region GetNameWithSpacesFromEnum + + [Test] + public void GetNameWithSpacesFromEnum_ClothingCategory_ReturnsWithEmojiAndSpaces() + { + // GetNameWithSpacesFromEnum delegates to GetNameWithSpacesAndEmoji for ClothingCategory + var result = _miscMethods.GetNameWithSpacesFromEnum(ClothingCategory.DressShirt); + using (new AssertionScope()) + { + result.Should().Contain("Dress"); + result.Should().Contain("Shirt"); + result.Should().Contain("πŸ‘”"); + } + } + + [Test] + public void GetNameWithSpacesFromEnum_Season_ReturnsWithEmojiAndSpaces() + { + // GetNameWithSpacesFromEnum delegates to GetNameWithSpacesAndEmoji for Season + var result = _miscMethods.GetNameWithSpacesFromEnum(Season.FallAndWinter); + using (new AssertionScope()) + { + result.Should().Contain("Fall"); + result.Should().Contain("Winter"); + result.Should().Contain("πŸ‚"); + } + } + + [Test] + public void GetNameWithSpacesFromEnum_OtherEnum_ReturnsWithSpaces() + { + // For non-ClothingCategory, non-Season enums it falls through to GetNameWithSpaces + var result = _miscMethods.GetNameWithSpacesFromEnum(WearLocation.HomeAndOutside); + using (new AssertionScope()) + { + result.Should().Contain("Home"); + result.Should().Contain("Outside"); + } + } + + #endregion + + #region IsValidBase64 + + [Test] + public void IsValidBase64_WhenValidBase64_ReturnsTrue() + { + var validBase64 = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }); + var result = _miscMethods.IsValidBase64(validBase64); + result.Should().BeTrue(); + } + + [Test] + public void IsValidBase64_WhenInvalidBase64_ReturnsFalse() + { + const string invalidBase64 = "not-valid-base64!!!"; + var result = _miscMethods.IsValidBase64(invalidBase64); + result.Should().BeFalse(); + } + + [Test] + public void IsValidBase64_WhenEmptyString_ReturnsFalse() + { + var result = _miscMethods.IsValidBase64(string.Empty); + result.Should().BeFalse(); + } + + #endregion + + #region GetNameWithSpacesAndEmoji(ClothingCategory) + + [Test] + public void GetNameWithSpacesAndEmoji_TShirt_ReturnsShirtWithEmoji() + { + // regex [A-Z][a-z]+ extracts "Shirt" from "TShirt" + var result = _miscMethods.GetNameWithSpacesAndEmoji(ClothingCategory.TShirt); + using (new AssertionScope()) + { + result.Should().Contain("Shirt"); + result.Should().Contain("πŸ‘•"); + } + } + + [Test] + public void GetNameWithSpacesAndEmoji_DressShirt_ReturnsDressShirtWithEmoji() + { + var result = _miscMethods.GetNameWithSpacesAndEmoji(ClothingCategory.DressShirt); + using (new AssertionScope()) + { + result.Should().Contain("Dress"); + result.Should().Contain("Shirt"); + result.Should().Contain("πŸ‘”"); + } + } + + #endregion + + #region GetNameWithSpacesAndEmoji(Season) + + [Test] + public void GetNameWithSpacesAndEmoji_Fall_ReturnsFallWithEmoji() + { + var result = _miscMethods.GetNameWithSpacesAndEmoji(Season.Fall); + using (new AssertionScope()) + { + result.Should().Contain("Fall"); + result.Should().Contain("πŸ‚"); + } + } + + [Test] + public void GetNameWithSpacesAndEmoji_Summer_ReturnsSummerWithEmoji() + { + var result = _miscMethods.GetNameWithSpacesAndEmoji(Season.Summer); + using (new AssertionScope()) + { + result.Should().Contain("Summer"); + result.Should().Contain("β˜€οΈ"); + } + } + + #endregion + + #region ConvertEnumToCollection + + [Test] + public void ConvertEnumToCollection_ClothingCategory_ReturnsAllValues() + { + var result = _miscMethods.ConvertEnumToCollection(); + result.Count.Should().Be(Enum.GetValues().Length); + } + + [Test] + public void ConvertEnumToCollection_Season_ReturnsAllValues() + { + var result = _miscMethods.ConvertEnumToCollection(); + result.Count.Should().Be(Enum.GetValues().Length); + } + + #endregion +} diff --git a/WardrobeManager.Shared.Tests/StaticResources/ProjectConstantsTests.cs b/WardrobeManager.Shared.Tests/StaticResources/ProjectConstantsTests.cs new file mode 100644 index 0000000..2f8fa6a --- /dev/null +++ b/WardrobeManager.Shared.Tests/StaticResources/ProjectConstantsTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.StaticResources; + +namespace WardrobeManager.Shared.Tests.StaticResources; + +public class ProjectConstantsTests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void ProjectName_HasExpectedValue() + { + // Arrange + // Act + // Assert + ProjectConstants.ProjectName.Should().Be("Wardrobe Manager"); + } + + [Test] + public void ProjectVersion_FollowsSemVer() + { + // Arrange + // Act + var version = ProjectConstants.ProjectVersion; + + // Assert + // SemVer format: MAJOR.MINOR.PATCH + var parts = version.Split('.'); + using (new AssertionScope()) + { + parts.Should().HaveCount(3); + parts[0].Should().MatchRegex(@"^\d+$"); + parts[1].Should().MatchRegex(@"^\d+$"); + parts[2].Should().MatchRegex(@"^\d+$"); + } + } + + [Test] + public void ProjectGitRepo_ContainsProjectName() + { + // Arrange + var expectedRepoName = ProjectConstants.ProjectName.Replace(" ", ""); + + // Act + var repoUrl = ProjectConstants.ProjectGitRepo; + + // Assert + repoUrl.Should().Contain(expectedRepoName); + } + + [Test] + public void ProjectGitRepo_IsValidUrl() + { + // Arrange + // Act + var repoUrl = ProjectConstants.ProjectGitRepo; + + // Assert + repoUrl.Should().StartWith("https://github.com/"); + } + + [Test] + public void DefaultItemImage_IsRelativePath() + { + // Arrange + // Act + // Assert + ProjectConstants.DefaultItemImage.Should().StartWith("/"); + } + + [Test] + public void HomeBackgroundImage_IsRelativePath() + { + // Arrange + // Act + // Assert + ProjectConstants.HomeBackgroundImage.Should().StartWith("/"); + } +} diff --git a/WardrobeManager.Shared.Tests/StaticResources/StaticValidatorsTests.cs b/WardrobeManager.Shared.Tests/StaticResources/StaticValidatorsTests.cs new file mode 100644 index 0000000..db3242e --- /dev/null +++ b/WardrobeManager.Shared.Tests/StaticResources/StaticValidatorsTests.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.StaticResources; + +namespace WardrobeManager.Shared.Tests.StaticResources; + +public class StaticValidatorsTests +{ + #region NewClothingItemDTO + + [Test] + public void Validate_NewClothingItemDTO_WithValidName_ReturnsSuccess() + { + // Arrange + var dto = new NewClothingItemDTO { Name = "My Jeans", Category = ClothingCategory.Jeans }; + + // Act + var result = StaticValidators.Validate(dto); + + // Assert + using (new AssertionScope()) + { + result.Success.Should().BeTrue(); + result.Message.Should().Be(string.Empty); + } + } + + [Test] + public void Validate_NewClothingItemDTO_WithEmptyName_ReturnsFailure() + { + // Arrange + var dto = new NewClothingItemDTO { Name = "", Category = ClothingCategory.Jeans }; + + // Act + var result = StaticValidators.Validate(dto); + + // Assert + using (new AssertionScope()) + { + result.Success.Should().BeFalse(); + result.Message.Should().NotBeNullOrEmpty(); + } + } + + [Test] + public void Validate_NewClothingItemDTO_WithTooLongName_ReturnsFailure() + { + // Arrange + var dto = new NewClothingItemDTO { Name = new string('a', 51), Category = ClothingCategory.Jeans }; + + // Act + var result = StaticValidators.Validate(dto); + + // Assert + result.Success.Should().BeFalse(); + } + + [Test] + public void Validate_NewClothingItemDTO_WithNameAtMaxLength_ReturnsSuccess() + { + // Arrange + var dto = new NewClothingItemDTO { Name = new string('a', 50), Category = ClothingCategory.Jeans }; + + // Act + var result = StaticValidators.Validate(dto); + + // Assert + result.Success.Should().BeTrue(); + } + + #endregion + + #region Null input + + [Test] + public void Validate_WhenInputIsNull_ReturnsFailure() + { + // Act + var result = StaticValidators.Validate(null!); + + // Assert + using (new AssertionScope()) + { + result.Success.Should().BeFalse(); + result.Message.Should().Contain("null"); + } + } + + #endregion + + #region Type without registered validator + + [Test] + public void Validate_TypeWithNoRegisteredValidator_ReturnsSuccessByDefault() + { + // Arrange - EditedUserDTO has no registered validator + var dto = new EditedUserDTO("Name", "pic"); + + // Act + var result = StaticValidators.Validate(dto); + + // Assert + result.Success.Should().BeTrue(); + } + + #endregion +} diff --git a/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj b/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj index 686c750..1e7b41d 100644 --- a/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj +++ b/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj @@ -1,4 +1,4 @@ -ο»Ώ + net10.0