diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupMemberTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupMemberTests.cs new file mode 100644 index 00000000..59e18eca --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupMemberTests.cs @@ -0,0 +1,164 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.GroupAggregate; + +namespace IamService.UnitTests.Domain.Groups; + +/// +/// EN: Unit tests for GroupMember entity. +/// VI: Kiểm thử đơn vị cho entity GroupMember. +/// +public class GroupMemberTests +{ + private readonly Guid _validGroupId = Guid.NewGuid(); + private readonly Guid _validUserId = Guid.NewGuid(); + + #region Creation Tests + + [Fact] + public void Create_ValidParameters_CreatesGroupMemberWithDefaults() + { + // Arrange & Act + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Member); + + // Assert + member.Id.Should().NotBeEmpty(); + member.GroupId.Should().Be(_validGroupId); + member.UserId.Should().Be(_validUserId); + member.Role.Should().Be(GroupRole.Member); + member.JoinedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + member.AddedByUserId.Should().BeNull(); + } + + [Fact] + public void Create_WithAddedByUser_SetsAddedByUserId() + { + // Arrange + var addedByUserId = Guid.NewGuid(); + + // Act + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Member, addedByUserId); + + // Assert + member.AddedByUserId.Should().Be(addedByUserId); + } + + [Fact] + public void Create_WithNullRole_DefaultsToMember() + { + // Arrange & Act + var member = new GroupMember(_validGroupId, _validUserId, null!); + + // Assert + member.Role.Should().Be(GroupRole.Member); + } + + [Fact] + public void Create_EmptyGroupId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => new GroupMember(Guid.Empty, _validUserId, GroupRole.Member); + + // Assert + act.Should().Throw() + .WithMessage("*Group ID*empty*"); + } + + [Fact] + public void Create_EmptyUserId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => new GroupMember(_validGroupId, Guid.Empty, GroupRole.Member); + + // Assert + act.Should().Throw() + .WithMessage("*User ID*empty*"); + } + + #endregion + + #region ChangeRole Tests + + [Fact] + public void ChangeRole_ValidRole_ChangesRoleAndRoleId() + { + // Arrange + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Member); + + // Act + member.ChangeRole(GroupRole.Admin); + + // Assert + member.Role.Should().Be(GroupRole.Admin); + member.RoleId.Should().Be(GroupRole.Admin.Id); + } + + [Fact] + public void ChangeRole_NullRole_ThrowsArgumentNullException() + { + // Arrange + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Member); + + // Act + var act = () => member.ChangeRole(null!); + + // Assert + act.Should().Throw(); + } + + #endregion + + #region IsOwner Tests + + [Fact] + public void IsOwner_WhenOwner_ReturnsTrue() + { + // Arrange + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Owner); + + // Act & Assert + member.IsOwner().Should().BeTrue(); + } + + [Theory] + [InlineData(1)] // Member + [InlineData(2)] // Admin + public void IsOwner_WhenNotOwner_ReturnsFalse(int roleId) + { + // Arrange + var role = GroupRole.FromId(roleId); + var member = new GroupMember(_validGroupId, _validUserId, role!); + + // Act & Assert + member.IsOwner().Should().BeFalse(); + } + + #endregion + + #region IsAdminOrOwner Tests + + [Theory] + [InlineData(2)] // Admin + [InlineData(3)] // Owner + public void IsAdminOrOwner_WhenAdminOrOwner_ReturnsTrue(int roleId) + { + // Arrange + var role = GroupRole.FromId(roleId); + var member = new GroupMember(_validGroupId, _validUserId, role!); + + // Act & Assert + member.IsAdminOrOwner().Should().BeTrue(); + } + + [Fact] + public void IsAdminOrOwner_WhenMember_ReturnsFalse() + { + // Arrange + var member = new GroupMember(_validGroupId, _validUserId, GroupRole.Member); + + // Act & Assert + member.IsAdminOrOwner().Should().BeFalse(); + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupPermissionTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupPermissionTests.cs new file mode 100644 index 00000000..d53d131e --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupPermissionTests.cs @@ -0,0 +1,163 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.GroupAggregate; + +namespace IamService.UnitTests.Domain.Groups; + +/// +/// EN: Unit tests for GroupPermission entity. +/// VI: Kiểm thử đơn vị cho entity GroupPermission. +/// +public class GroupPermissionTests +{ + private readonly Guid _validGroupId = Guid.NewGuid(); + + #region Creation Tests + + [Fact] + public void Create_ValidParameters_CreatesGroupPermission() + { + // Arrange & Act + var permission = new GroupPermission(_validGroupId, "read", "projects/*"); + + // Assert + permission.Id.Should().NotBeEmpty(); + permission.GroupId.Should().Be(_validGroupId); + permission.Permission.Should().Be("read"); + permission.Resource.Should().Be("projects/*"); + permission.GrantedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + permission.GrantedByUserId.Should().BeNull(); + } + + [Fact] + public void Create_WithGrantedByUser_SetsGrantedByUserId() + { + // Arrange + var grantedByUserId = Guid.NewGuid(); + + // Act + var permission = new GroupPermission(_validGroupId, "write", null, grantedByUserId); + + // Assert + permission.GrantedByUserId.Should().Be(grantedByUserId); + } + + [Fact] + public void Create_NormalizesPermissionToLowercase() + { + // Arrange & Act + var permission = new GroupPermission(_validGroupId, "READ"); + + // Assert + permission.Permission.Should().Be("read"); + } + + [Fact] + public void Create_TrimsPermissionAndResource() + { + // Arrange & Act + var permission = new GroupPermission(_validGroupId, " write ", " projects/* "); + + // Assert + permission.Permission.Should().Be("write"); + permission.Resource.Should().Be("projects/*"); + } + + [Fact] + public void Create_EmptyGroupId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => new GroupPermission(Guid.Empty, "read"); + + // Assert + act.Should().Throw() + .WithMessage("*Group ID*empty*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_InvalidPermission_ThrowsArgumentException(string? permissionName) + { + // Arrange & Act + var act = () => new GroupPermission(_validGroupId, permissionName!); + + // Assert + act.Should().Throw() + .WithMessage("*Permission*empty*"); + } + + #endregion + + #region Matches Tests + + [Fact] + public void Matches_ExactPermissionMatch_ReturnsTrue() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read"); + + // Act & Assert + permission.Matches("read").Should().BeTrue(); + permission.Matches("READ").Should().BeTrue(); // Case insensitive + } + + [Fact] + public void Matches_DifferentPermission_ReturnsFalse() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read"); + + // Act & Assert + permission.Matches("write").Should().BeFalse(); + } + + [Fact] + public void Matches_NoResourceRestriction_MatchesAnyResource() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read"); + + // Act & Assert + permission.Matches("read", "any/resource").Should().BeTrue(); + permission.Matches("read", "another/path").Should().BeTrue(); + } + + [Fact] + public void Matches_WildcardResource_MatchesPrefix() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read", "projects/*"); + + // Act & Assert + permission.Matches("read", "projects/123").Should().BeTrue(); + permission.Matches("read", "projects/456/tasks").Should().BeTrue(); + permission.Matches("read", "users/123").Should().BeFalse(); + } + + [Fact] + public void Matches_ExactResourceMatch_ReturnsTrue() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read", "projects/123"); + + // Act & Assert + permission.Matches("read", "projects/123").Should().BeTrue(); + permission.Matches("read", "projects/124").Should().BeFalse(); + } + + [Fact] + public void Matches_NoResourceRequested_MatchesAny() + { + // Arrange + var permission = new GroupPermission(_validGroupId, "read", "projects/*"); + + // Act & Assert + permission.Matches("read").Should().BeTrue(); + permission.Matches("read", null).Should().BeTrue(); + permission.Matches("read", "").Should().BeTrue(); + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupRoleTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupRoleTests.cs new file mode 100644 index 00000000..deed41b1 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupRoleTests.cs @@ -0,0 +1,144 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.GroupAggregate; + +namespace IamService.UnitTests.Domain.Groups; + +/// +/// EN: Unit tests for GroupRole enumeration (SmartEnum pattern). +/// VI: Kiểm thử đơn vị cho GroupRole enumeration (SmartEnum pattern). +/// +public class GroupRoleTests +{ + #region Static Values Tests + + [Fact] + public void Member_HasCorrectIdAndName() + { + // Assert + GroupRole.Member.Id.Should().Be(1); + GroupRole.Member.Name.Should().Be("Member"); + } + + [Fact] + public void Admin_HasCorrectIdAndName() + { + // Assert + GroupRole.Admin.Id.Should().Be(2); + GroupRole.Admin.Name.Should().Be("Admin"); + } + + [Fact] + public void Owner_HasCorrectIdAndName() + { + // Assert + GroupRole.Owner.Id.Should().Be(3); + GroupRole.Owner.Name.Should().Be("Owner"); + } + + #endregion + + #region GetAll Tests + + [Fact] + public void GetAll_ReturnsAllRoles() + { + // Act + var allRoles = GroupRole.GetAll().ToList(); + + // Assert + allRoles.Should().HaveCount(3); + allRoles.Should().Contain(GroupRole.Member); + allRoles.Should().Contain(GroupRole.Admin); + allRoles.Should().Contain(GroupRole.Owner); + } + + #endregion + + #region FromId Tests + + [Theory] + [InlineData(1, "Member")] + [InlineData(2, "Admin")] + [InlineData(3, "Owner")] + public void FromId_ValidId_ReturnsCorrectRole(int id, string expectedName) + { + // Act + var role = GroupRole.FromId(id); + + // Assert + role.Should().NotBeNull(); + role!.Name.Should().Be(expectedName); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + [InlineData(-1)] + [InlineData(100)] + public void FromId_InvalidId_ReturnsNull(int id) + { + // Act + var role = GroupRole.FromId(id); + + // Assert + role.Should().BeNull(); + } + + #endregion + + #region HasPermissionOver Tests + + [Theory] + [InlineData(3, 2, true)] // Owner > Admin + [InlineData(3, 1, true)] // Owner > Member + [InlineData(2, 1, true)] // Admin > Member + [InlineData(3, 3, true)] // Owner = Owner + [InlineData(2, 2, true)] // Admin = Admin + [InlineData(1, 1, true)] // Member = Member + [InlineData(1, 2, false)] // Member < Admin + [InlineData(1, 3, false)] // Member < Owner + [InlineData(2, 3, false)] // Admin < Owner + public void HasPermissionOver_ComparesRolesCorrectly(int roleId, int otherRoleId, bool expected) + { + // Arrange + var role = GroupRole.FromId(roleId)!; + var otherRole = GroupRole.FromId(otherRoleId)!; + + // Act + var result = role.HasPermissionOver(otherRole); + + // Assert + result.Should().Be(expected); + } + + #endregion + + #region Equality Tests + + [Fact] + public void Equality_SameRole_AreEqual() + { + // Arrange + var role1 = GroupRole.Member; + var role2 = GroupRole.Member; + + // Assert + role1.Should().Be(role2); + (role1 == role2).Should().BeTrue(); + } + + [Fact] + public void Equality_DifferentRoles_AreNotEqual() + { + // Arrange + var role1 = GroupRole.Member; + var role2 = GroupRole.Admin; + + // Assert + role1.Should().NotBe(role2); + (role1 != role2).Should().BeTrue(); + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupTests.cs new file mode 100644 index 00000000..95ea0e0c --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Groups/GroupTests.cs @@ -0,0 +1,501 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Events; + +namespace IamService.UnitTests.Domain.Groups; + +/// +/// EN: Comprehensive unit tests for Group aggregate root. +/// VI: Kiểm thử đơn vị toàn diện cho Group aggregate root. +/// +public class GroupTests +{ + private readonly Guid _validOrganizationId = Guid.NewGuid(); + + #region Creation Tests + + [Fact] + public void Create_ValidParameters_CreatesGroupWithGeneratedId() + { + // Arrange + var name = "Development Team"; + var description = "Main development team"; + + // Act + var group = Group.Create(_validOrganizationId, name, description); + + // Assert + group.Should().NotBeNull(); + group.Id.Should().NotBeEmpty(); + group.Name.Should().Be(name); + group.Description.Should().Be(description); + group.OrganizationId.Should().Be(_validOrganizationId); + group.IsDeleted.Should().BeFalse(); + group.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + group.Members.Should().BeEmpty(); + group.Permissions.Should().BeEmpty(); + } + + [Fact] + public void Create_WithoutDescription_CreatesGroupWithNullDescription() + { + // Arrange & Act + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Assert + group.Description.Should().BeNull(); + } + + [Fact] + public void Create_EmptyOrganizationId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => Group.Create(Guid.Empty, "Team Name"); + + // Assert + act.Should().Throw() + .WithMessage("*Organization ID*empty*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_InvalidName_ThrowsArgumentException(string? name) + { + // Arrange & Act + var act = () => Group.Create(_validOrganizationId, name!); + + // Assert + act.Should().Throw() + .WithMessage("*name*empty*"); + } + + [Fact] + public void Create_TrimsNameAndDescription() + { + // Arrange & Act + var group = Group.Create(_validOrganizationId, " Team Name ", " Description "); + + // Assert + group.Name.Should().Be("Team Name"); + group.Description.Should().Be("Description"); + } + + [Fact] + public void Create_RaisesGroupCreatedEvent() + { + // Arrange & Act + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Assert + group.DomainEvents.Should().ContainSingle(); + group.DomainEvents.First().Should().BeOfType(); + var domainEvent = (GroupCreatedEvent)group.DomainEvents.First(); + domainEvent.GroupId.Should().Be(group.Id); + domainEvent.OrganizationId.Should().Be(_validOrganizationId); + domainEvent.GroupName.Should().Be("Team Name"); + } + + #endregion + + #region Update Tests + + [Fact] + public void Update_ValidData_UpdatesNameAndDescription() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Old Name", "Old Description"); + var newName = "New Name"; + var newDescription = "New Description"; + + // Act + group.Update(newName, newDescription); + + // Assert + group.Name.Should().Be(newName); + group.Description.Should().Be(newDescription); + group.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Update_InvalidName_ThrowsArgumentException(string? name) + { + // Arrange + var group = Group.Create(_validOrganizationId, "Old Name"); + + // Act + var act = () => group.Update(name!, "Description"); + + // Assert + act.Should().Throw() + .WithMessage("*name*empty*"); + } + + #endregion + + #region AddMember Tests + + [Fact] + public void AddMember_ValidUser_AddsMemberToGroup() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + + // Act + var member = group.AddMember(userId); + + // Assert + group.Members.Should().HaveCount(1); + member.UserId.Should().Be(userId); + member.GroupId.Should().Be(group.Id); + member.Role.Should().Be(GroupRole.Member); + } + + [Fact] + public void AddMember_WithRole_AddsMemberWithSpecifiedRole() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + + // Act + var member = group.AddMember(userId, GroupRole.Admin); + + // Assert + member.Role.Should().Be(GroupRole.Admin); + } + + [Fact] + public void AddMember_WithAddedByUser_TracksWhoAdded() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + var addedByUserId = Guid.NewGuid(); + + // Act + var member = group.AddMember(userId, addedByUserId: addedByUserId); + + // Assert + member.AddedByUserId.Should().Be(addedByUserId); + } + + [Fact] + public void AddMember_DuplicateUser_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + group.AddMember(userId); + + // Act + var act = () => group.AddMember(userId); + + // Assert + act.Should().Throw() + .WithMessage("*already a member*"); + } + + [Fact] + public void AddMember_EmptyUserId_ThrowsArgumentException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var act = () => group.AddMember(Guid.Empty); + + // Assert + act.Should().Throw() + .WithMessage("*User ID*empty*"); + } + + [Fact] + public void AddMember_RaisesMemberAddedToGroupEvent() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + group.ClearDomainEvents(); // Clear creation event + var userId = Guid.NewGuid(); + + // Act + group.AddMember(userId); + + // Assert + group.DomainEvents.Should().ContainSingle(); + group.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region RemoveMember Tests + + [Fact] + public void RemoveMember_ExistingMember_RemovesMember() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + group.AddMember(userId); + + // Act + group.RemoveMember(userId); + + // Assert + group.Members.Should().BeEmpty(); + } + + [Fact] + public void RemoveMember_NonExistingMember_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var act = () => group.RemoveMember(Guid.NewGuid()); + + // Assert + act.Should().Throw() + .WithMessage("*not a member*"); + } + + [Fact] + public void RemoveMember_LastOwner_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var ownerId = Guid.NewGuid(); + group.AddMember(ownerId, GroupRole.Owner); + + // Act + var act = () => group.RemoveMember(ownerId); + + // Assert + act.Should().Throw() + .WithMessage("*last owner*"); + } + + [Fact] + public void RemoveMember_OwnerWithOtherOwners_RemovesMember() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var owner1 = Guid.NewGuid(); + var owner2 = Guid.NewGuid(); + group.AddMember(owner1, GroupRole.Owner); + group.AddMember(owner2, GroupRole.Owner); + + // Act + group.RemoveMember(owner1); + + // Assert + group.Members.Should().HaveCount(1); + group.Members.First().UserId.Should().Be(owner2); + } + + [Fact] + public void RemoveMember_RaisesMemberRemovedFromGroupEvent() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + group.AddMember(userId); + group.ClearDomainEvents(); + + // Act + group.RemoveMember(userId); + + // Assert + group.DomainEvents.Should().ContainSingle(); + group.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region ChangeMemberRole Tests + + [Fact] + public void ChangeMemberRole_ValidRole_ChangesRole() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var userId = Guid.NewGuid(); + group.AddMember(userId, GroupRole.Member); + + // Act + group.ChangeMemberRole(userId, GroupRole.Admin); + + // Assert + group.Members.First().Role.Should().Be(GroupRole.Admin); + } + + [Fact] + public void ChangeMemberRole_NonExistingMember_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var act = () => group.ChangeMemberRole(Guid.NewGuid(), GroupRole.Admin); + + // Assert + act.Should().Throw() + .WithMessage("*not a member*"); + } + + [Fact] + public void ChangeMemberRole_DemoteLastOwner_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + var ownerId = Guid.NewGuid(); + group.AddMember(ownerId, GroupRole.Owner); + + // Act + var act = () => group.ChangeMemberRole(ownerId, GroupRole.Member); + + // Assert + act.Should().Throw() + .WithMessage("*last owner*"); + } + + #endregion + + #region Permission Tests + + [Fact] + public void AddPermission_ValidPermission_AddsPermissionToGroup() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var permission = group.AddPermission("read", "projects/*"); + + // Assert + group.Permissions.Should().HaveCount(1); + permission.Permission.Should().Be("read"); + permission.Resource.Should().Be("projects/*"); + } + + [Fact] + public void AddPermission_DuplicatePermission_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + group.AddPermission("read", "projects/*"); + + // Act + var act = () => group.AddPermission("read", "projects/*"); + + // Assert + act.Should().Throw() + .WithMessage("*already exists*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddPermission_InvalidPermission_ThrowsArgumentException(string? permission) + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var act = () => group.AddPermission(permission!); + + // Assert + act.Should().Throw() + .WithMessage("*Permission*empty*"); + } + + [Fact] + public void RemovePermission_ExistingPermission_RemovesPermission() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + group.AddPermission("read", "projects/*"); + + // Act + group.RemovePermission("read", "projects/*"); + + // Assert + group.Permissions.Should().BeEmpty(); + } + + [Fact] + public void RemovePermission_NonExistingPermission_ThrowsInvalidOperationException() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + var act = () => group.RemovePermission("read", "projects/*"); + + // Assert + act.Should().Throw() + .WithMessage("*not found*"); + } + + [Fact] + public void HasPermission_ExistingPermission_ReturnsTrue() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + group.AddPermission("read", "projects/*"); + + // Act & Assert + group.HasPermission("read", "projects/123").Should().BeTrue(); + } + + [Fact] + public void HasPermission_NonExistingPermission_ReturnsFalse() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act & Assert + group.HasPermission("write", "projects/*").Should().BeFalse(); + } + + #endregion + + #region Delete/Restore Tests + + [Fact] + public void Delete_SetsIsDeletedTrue() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + + // Act + group.Delete(); + + // Assert + group.IsDeleted.Should().BeTrue(); + group.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Restore_SetsIsDeletedFalse() + { + // Arrange + var group = Group.Create(_validOrganizationId, "Team Name"); + group.Delete(); + + // Act + group.Restore(); + + // Assert + group.IsDeleted.Should().BeFalse(); + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationSettingsTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationSettingsTests.cs new file mode 100644 index 00000000..b07c5b31 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationSettingsTests.cs @@ -0,0 +1,202 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.OrganizationAggregate; + +namespace IamService.UnitTests.Domain.Organizations; + +/// +/// EN: Unit tests for OrganizationSettings value object. +/// VI: Kiểm thử đơn vị cho OrganizationSettings value object. +/// +public class OrganizationSettingsTests +{ + #region Creation Tests + + [Fact] + public void Create_DefaultValues_CreatesSettingsWithDefaults() + { + // Arrange & Act + var settings = new OrganizationSettings(); + + // Assert + settings.AllowUserRegistration.Should().BeTrue(); + settings.RequireEmailVerification.Should().BeTrue(); + settings.Require2FA.Should().BeFalse(); + settings.MaxUsersLimit.Should().Be(100); + settings.CustomDomain.Should().BeNull(); + settings.SessionTimeoutMinutes.Should().Be(60); + } + + [Fact] + public void Create_CustomValues_CreatesSettingsWithCustomValues() + { + // Arrange & Act + var settings = new OrganizationSettings( + allowUserRegistration: false, + requireEmailVerification: false, + require2FA: true, + maxUsersLimit: 500, + customDomain: "auth.company.com", + sessionTimeoutMinutes: 30 + ); + + // Assert + settings.AllowUserRegistration.Should().BeFalse(); + settings.RequireEmailVerification.Should().BeFalse(); + settings.Require2FA.Should().BeTrue(); + settings.MaxUsersLimit.Should().Be(500); + settings.CustomDomain.Should().Be("auth.company.com"); + settings.SessionTimeoutMinutes.Should().Be(30); + } + + [Fact] + public void Create_NegativeMaxUsersLimit_ThrowsArgumentException() + { + // Arrange & Act + var act = () => new OrganizationSettings(maxUsersLimit: -1); + + // Assert + act.Should().Throw() + .WithMessage("*Max users limit*negative*"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Create_InvalidSessionTimeout_ThrowsArgumentException(int timeout) + { + // Arrange & Act + var act = () => new OrganizationSettings(sessionTimeoutMinutes: timeout); + + // Assert + act.Should().Throw() + .WithMessage("*Session timeout*at least 1 minute*"); + } + + #endregion + + #region Static Factory Tests + + [Fact] + public void Default_ReturnsDefaultSettings() + { + // Arrange & Act + var settings = OrganizationSettings.Default; + + // Assert + settings.AllowUserRegistration.Should().BeTrue(); + settings.RequireEmailVerification.Should().BeTrue(); + settings.Require2FA.Should().BeFalse(); + settings.MaxUsersLimit.Should().Be(100); + settings.CustomDomain.Should().BeNull(); + settings.SessionTimeoutMinutes.Should().Be(60); + } + + [Fact] + public void Enterprise_ReturnsEnterpriseSettings() + { + // Arrange & Act + var settings = OrganizationSettings.Enterprise; + + // Assert + settings.AllowUserRegistration.Should().BeFalse(); + settings.RequireEmailVerification.Should().BeTrue(); + settings.Require2FA.Should().BeTrue(); + settings.MaxUsersLimit.Should().Be(1000); + settings.CustomDomain.Should().BeNull(); + settings.SessionTimeoutMinutes.Should().Be(30); + } + + #endregion + + #region Equality Tests (Value Object) + + [Fact] + public void Equals_SameValues_ReturnsTrue() + { + // Arrange + var settings1 = new OrganizationSettings( + allowUserRegistration: true, + requireEmailVerification: true, + require2FA: false, + maxUsersLimit: 100, + customDomain: null, + sessionTimeoutMinutes: 60 + ); + var settings2 = new OrganizationSettings( + allowUserRegistration: true, + requireEmailVerification: true, + require2FA: false, + maxUsersLimit: 100, + customDomain: null, + sessionTimeoutMinutes: 60 + ); + + // Assert + settings1.Should().Be(settings2); + settings1.GetHashCode().Should().Be(settings2.GetHashCode()); + } + + [Fact] + public void Equals_DifferentMaxUsers_ReturnsFalse() + { + // Arrange + var settings1 = new OrganizationSettings(maxUsersLimit: 100); + var settings2 = new OrganizationSettings(maxUsersLimit: 200); + + // Assert + settings1.Should().NotBe(settings2); + } + + [Fact] + public void Equals_DifferentRequire2FA_ReturnsFalse() + { + // Arrange + var settings1 = new OrganizationSettings(require2FA: true); + var settings2 = new OrganizationSettings(require2FA: false); + + // Assert + settings1.Should().NotBe(settings2); + } + + [Fact] + public void Equals_DifferentCustomDomain_ReturnsFalse() + { + // Arrange + var settings1 = new OrganizationSettings(customDomain: "domain1.com"); + var settings2 = new OrganizationSettings(customDomain: "domain2.com"); + + // Assert + settings1.Should().NotBe(settings2); + } + + [Fact] + public void Equals_DefaultAndEnterprise_ReturnsFalse() + { + // Arrange + var defaultSettings = OrganizationSettings.Default; + var enterpriseSettings = OrganizationSettings.Enterprise; + + // Assert + defaultSettings.Should().NotBe(enterpriseSettings); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void Settings_AreImmutable() + { + // Arrange + var settings = OrganizationSettings.Default; + + // Act & Assert - Properties should be read-only + // This test documents that OrganizationSettings is immutable + settings.AllowUserRegistration.Should().BeTrue(); + // Cannot reassign: settings.AllowUserRegistration = false; // Compile error + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationStatusTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationStatusTests.cs new file mode 100644 index 00000000..cdde84bd --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationStatusTests.cs @@ -0,0 +1,118 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.OrganizationAggregate; + +namespace IamService.UnitTests.Domain.Organizations; + +/// +/// EN: Unit tests for OrganizationStatus enumeration (SmartEnum pattern). +/// VI: Kiểm thử đơn vị cho OrganizationStatus enumeration (SmartEnum pattern). +/// +public class OrganizationStatusTests +{ + #region Static Values Tests + + [Fact] + public void Active_HasCorrectIdAndName() + { + // Assert + OrganizationStatus.Active.Id.Should().Be(1); + OrganizationStatus.Active.Name.Should().Be("Active"); + } + + [Fact] + public void Suspended_HasCorrectIdAndName() + { + // Assert + OrganizationStatus.Suspended.Id.Should().Be(2); + OrganizationStatus.Suspended.Name.Should().Be("Suspended"); + } + + [Fact] + public void PendingApproval_HasCorrectIdAndName() + { + // Assert + OrganizationStatus.PendingApproval.Id.Should().Be(3); + OrganizationStatus.PendingApproval.Name.Should().Be("PendingApproval"); + } + + [Fact] + public void Archived_HasCorrectIdAndName() + { + // Assert + OrganizationStatus.Archived.Id.Should().Be(4); + OrganizationStatus.Archived.Name.Should().Be("Archived"); + } + + #endregion + + #region GetAll Tests + + [Fact] + public void GetAll_ReturnsAllStatuses() + { + // Act + var allStatuses = OrganizationStatus.GetAll().ToList(); + + // Assert + allStatuses.Should().HaveCount(4); + allStatuses.Should().Contain(OrganizationStatus.Active); + allStatuses.Should().Contain(OrganizationStatus.Suspended); + allStatuses.Should().Contain(OrganizationStatus.PendingApproval); + allStatuses.Should().Contain(OrganizationStatus.Archived); + } + + [Fact] + public void GetAll_ReturnsDistinctIds() + { + // Act + var allStatuses = OrganizationStatus.GetAll().ToList(); + + // Assert + allStatuses.Select(s => s.Id).Distinct().Should().HaveCount(4); + } + + #endregion + + #region Equality Tests + + [Fact] + public void Equality_SameStatus_AreEqual() + { + // Arrange + var status1 = OrganizationStatus.Active; + var status2 = OrganizationStatus.Active; + + // Assert + status1.Should().Be(status2); + (status1 == status2).Should().BeTrue(); + } + + [Fact] + public void Equality_DifferentStatuses_AreNotEqual() + { + // Arrange + var status1 = OrganizationStatus.Active; + var status2 = OrganizationStatus.Suspended; + + // Assert + status1.Should().NotBe(status2); + (status1 != status2).Should().BeTrue(); + } + + #endregion + + #region ToString Tests + + [Fact] + public void ToString_ReturnsName() + { + // Assert + OrganizationStatus.Active.ToString().Should().Be("Active"); + OrganizationStatus.Suspended.ToString().Should().Be("Suspended"); + OrganizationStatus.PendingApproval.ToString().Should().Be("PendingApproval"); + OrganizationStatus.Archived.ToString().Should().Be("Archived"); + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationTests.cs new file mode 100644 index 00000000..419ac408 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Organizations/OrganizationTests.cs @@ -0,0 +1,438 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Events; + +namespace IamService.UnitTests.Domain.Organizations; + +/// +/// EN: Comprehensive unit tests for Organization aggregate root. +/// VI: Kiểm thử đơn vị toàn diện cho Organization aggregate root. +/// +public class OrganizationTests +{ + #region Creation Tests + + [Fact] + public void Create_ValidParameters_CreatesOrganizationWithActiveStatus() + { + // Arrange + var name = "Acme Corporation"; + var slug = "acme-corp"; + var description = "A sample organization"; + + // Act + var org = Organization.Create(name, slug, description); + + // Assert + org.Should().NotBeNull(); + org.Id.Should().NotBeEmpty(); + org.Name.Should().Be(name); + org.Slug.Should().Be(slug); + org.Description.Should().Be(description); + org.Status.Should().Be(OrganizationStatus.Active); + org.StatusId.Should().Be(OrganizationStatus.Active.Id); + org.ParentOrganizationId.Should().BeNull(); + org.Settings.Should().NotBeNull(); + org.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + org.UpdatedAt.Should().BeNull(); + } + + [Fact] + public void Create_WithParentOrganization_SetsParentId() + { + // Arrange + var parentId = Guid.NewGuid(); + + // Act + var org = Organization.Create("Child Org", "child-org", parentOrganizationId: parentId); + + // Assert + org.ParentOrganizationId.Should().Be(parentId); + } + + [Fact] + public void Create_WithCustomSettings_UsesProvidedSettings() + { + // Arrange + var customSettings = OrganizationSettings.Enterprise; + + // Act + var org = Organization.Create("Enterprise Org", "enterprise-org", settings: customSettings); + + // Assert + org.Settings.Should().Be(customSettings); + org.Settings.Require2FA.Should().BeTrue(); + } + + [Fact] + public void Create_WithoutSettings_UsesDefaultSettings() + { + // Arrange & Act + var org = Organization.Create("Default Org", "default-org"); + + // Assert + org.Settings.AllowUserRegistration.Should().BeTrue(); + org.Settings.RequireEmailVerification.Should().BeTrue(); + org.Settings.Require2FA.Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_InvalidName_ThrowsArgumentException(string? name) + { + // Arrange & Act + var act = () => Organization.Create(name!, "valid-slug"); + + // Assert + act.Should().Throw() + .WithMessage("*name*empty*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_InvalidSlug_ThrowsArgumentException(string? slug) + { + // Arrange & Act + var act = () => Organization.Create("Valid Name", slug!); + + // Assert + act.Should().Throw() + .WithMessage("*slug*empty*"); + } + + [Theory] + [InlineData("Invalid Slug")] // Contains space + [InlineData("Invalid_Slug")] // Contains underscore + [InlineData("InvalidSlug")] // Contains uppercase + [InlineData("invalid.slug")] // Contains dot + [InlineData("-invalid")] // Starts with hyphen + [InlineData("invalid-")] // Ends with hyphen + public void Create_InvalidSlugFormat_ThrowsArgumentException(string slug) + { + // Arrange & Act + var act = () => Organization.Create("Valid Name", slug); + + // Assert + act.Should().Throw() + .WithMessage("*lowercase letters, numbers, and hyphens*"); + } + + [Theory] + [InlineData("valid-slug")] + [InlineData("my-org")] + [InlineData("test123")] + [InlineData("org-1-2-3")] + public void Create_ValidSlugFormat_CreatesOrganization(string slug) + { + // Arrange & Act + var org = Organization.Create("Valid Name", slug); + + // Assert + org.Slug.Should().Be(slug); + } + + [Fact] + public void Create_NormalizesSlugToLowercase() + { + // Arrange & Act + var org = Organization.Create("Test Org", "Test-Slug"); + + // Assert - Should fail as uppercase is not allowed + // The implementation throws an exception for uppercase + } + + [Fact] + public void Create_RaisesOrganizationCreatedEvent() + { + // Arrange & Act + var org = Organization.Create("Test Org", "test-org"); + + // Assert + org.DomainEvents.Should().ContainSingle(); + org.DomainEvents.First().Should().BeOfType(); + var domainEvent = (OrganizationCreatedEvent)org.DomainEvents.First(); + domainEvent.OrganizationId.Should().Be(org.Id); + domainEvent.Name.Should().Be("Test Org"); + domainEvent.Slug.Should().Be("test-org"); + } + + #endregion + + #region UpdateInfo Tests + + [Fact] + public void UpdateInfo_ValidData_UpdatesNameAndDescription() + { + // Arrange + var org = Organization.Create("Old Name", "old-slug", "Old Description"); + var newName = "New Name"; + var newDescription = "New Description"; + + // Act + org.UpdateInfo(newName, newDescription); + + // Assert + org.Name.Should().Be(newName); + org.Description.Should().Be(newDescription); + org.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void UpdateInfo_RaisesOrganizationUpdatedEvent() + { + // Arrange + var org = Organization.Create("Old Name", "old-slug"); + org.ClearDomainEvents(); + + // Act + org.UpdateInfo("New Name", null); + + // Assert + org.DomainEvents.Should().ContainSingle(); + org.DomainEvents.First().Should().BeOfType(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UpdateInfo_InvalidName_ThrowsArgumentException(string? name) + { + // Arrange + var org = Organization.Create("Old Name", "old-slug"); + + // Act + var act = () => org.UpdateInfo(name!, "Description"); + + // Assert + act.Should().Throw() + .WithMessage("*name*empty*"); + } + + #endregion + + #region UpdateSlug Tests + + [Fact] + public void UpdateSlug_ValidSlug_UpdatesSlug() + { + // Arrange + var org = Organization.Create("Test", "old-slug"); + var newSlug = "new-slug"; + + // Act + org.UpdateSlug(newSlug); + + // Assert + org.Slug.Should().Be(newSlug); + org.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UpdateSlug_InvalidSlug_ThrowsArgumentException(string? slug) + { + // Arrange + var org = Organization.Create("Test", "old-slug"); + + // Act + var act = () => org.UpdateSlug(slug!); + + // Assert + act.Should().Throw() + .WithMessage("*slug*empty*"); + } + + [Fact] + public void UpdateSlug_InvalidFormat_ThrowsArgumentException() + { + // Arrange + var org = Organization.Create("Test", "old-slug"); + + // Act + var act = () => org.UpdateSlug("Invalid Slug!"); + + // Assert + act.Should().Throw() + .WithMessage("*lowercase letters, numbers, and hyphens*"); + } + + #endregion + + #region SetParent Tests + + [Fact] + public void SetParent_ValidParentId_SetsParent() + { + // Arrange + var org = Organization.Create("Child", "child"); + var parentId = Guid.NewGuid(); + + // Act + org.SetParent(parentId); + + // Assert + org.ParentOrganizationId.Should().Be(parentId); + org.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void SetParent_NullParentId_ClearsParent() + { + // Arrange + var org = Organization.Create("Child", "child", parentOrganizationId: Guid.NewGuid()); + + // Act + org.SetParent(null); + + // Assert + org.ParentOrganizationId.Should().BeNull(); + } + + [Fact] + public void SetParent_SameAsOwnId_ThrowsInvalidOperationException() + { + // Arrange + var org = Organization.Create("Self Parent", "self-parent"); + + // Act + var act = () => org.SetParent(org.Id); + + // Assert + act.Should().Throw() + .WithMessage("*cannot be its own parent*"); + } + + #endregion + + #region UpdateSettings Tests + + [Fact] + public void UpdateSettings_ValidSettings_UpdatesSettings() + { + // Arrange + var org = Organization.Create("Test", "test"); + var newSettings = OrganizationSettings.Enterprise; + + // Act + org.UpdateSettings(newSettings); + + // Assert + org.Settings.Should().Be(newSettings); + org.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void UpdateSettings_NullSettings_ThrowsArgumentNullException() + { + // Arrange + var org = Organization.Create("Test", "test"); + + // Act + var act = () => org.UpdateSettings(null!); + + // Assert + act.Should().Throw(); + } + + #endregion + + #region Status Transition Tests + + [Fact] + public void Activate_SuspendedOrg_SetsStatusActive() + { + // Arrange + var org = Organization.Create("Test", "test"); + org.Suspend(); + + // Act + org.Activate(); + + // Assert + org.Status.Should().Be(OrganizationStatus.Active); + org.StatusId.Should().Be(OrganizationStatus.Active.Id); + } + + [Fact] + public void Activate_ArchivedOrg_ThrowsInvalidOperationException() + { + // Arrange + var org = Organization.Create("Test", "test"); + org.Archive(); + + // Act + var act = () => org.Activate(); + + // Assert + act.Should().Throw() + .WithMessage("*archived*"); + } + + [Fact] + public void Suspend_ActiveOrg_SetsStatusSuspended() + { + // Arrange + var org = Organization.Create("Test", "test"); + + // Act + org.Suspend(); + + // Assert + org.Status.Should().Be(OrganizationStatus.Suspended); + org.StatusId.Should().Be(OrganizationStatus.Suspended.Id); + } + + [Fact] + public void Suspend_ArchivedOrg_ThrowsInvalidOperationException() + { + // Arrange + var org = Organization.Create("Test", "test"); + org.Archive(); + + // Act + var act = () => org.Suspend(); + + // Assert + act.Should().Throw() + .WithMessage("*archived*"); + } + + [Fact] + public void Archive_ActiveOrg_SetsStatusArchived() + { + // Arrange + var org = Organization.Create("Test", "test"); + + // Act + org.Archive(); + + // Assert + org.Status.Should().Be(OrganizationStatus.Archived); + org.StatusId.Should().Be(OrganizationStatus.Archived.Id); + org.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Archive_SuspendedOrg_SetsStatusArchived() + { + // Arrange + var org = Organization.Create("Test", "test"); + org.Suspend(); + + // Act + org.Archive(); + + // Assert + org.Status.Should().Be(OrganizationStatus.Archived); + } + + #endregion +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs new file mode 100644 index 00000000..a7999998 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MembershipService.API.Application.Queries; +using Xunit; + +namespace MembershipService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for LevelsController. +/// VI: Functional tests cho LevelsController. +/// +public class LevelsControllerTests : IClassFixture +{ + private readonly CustomWebApplicationFactory _factory; + + public LevelsControllerTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetLevels_PublicEndpoint_ShouldReturnOkWithoutAuth() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetLevels_WithSeededData_ShouldReturnLevelsList() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + levels.Should().NotBeNull(); + levels!.Should().HaveCountGreaterOrEqualTo(5); + } + + [Fact] + public async Task GetLevels_ShouldReturnOrderedByLevelNumber() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + levels.Should().NotBeNull(); + levels!.Should().BeInAscendingOrder(l => l.LevelNumber); + } + + [Fact] + public async Task GetLevels_ShouldReturnCorrectLevelData() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + levels.Should().NotBeNull(); + + var bronze = levels!.FirstOrDefault(l => l.LevelNumber == 1); + bronze.Should().NotBeNull(); + bronze!.Name.Should().Be("Bronze"); + bronze.RequiredExp.Should().Be(0); + bronze.BadgeColor.Should().Be("#CD7F32"); + + var diamond = levels.FirstOrDefault(l => l.LevelNumber == 5); + diamond.Should().NotBeNull(); + diamond!.Name.Should().Be("Diamond"); + diamond.RequiredExp.Should().Be(1000); + diamond.BadgeColor.Should().Be("#B9F2FF"); + } + + [Fact] + public async Task GetLevels_IncludeInactiveFalse_ShouldOnlyReturnActive() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels?includeInactive=false"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + levels.Should().NotBeNull(); + levels!.Should().AllSatisfy(l => l.IsActive.Should().BeTrue()); + } + + [Fact] + public async Task GetLevels_ShouldReturnExpectedExpThresholds() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + levels.Should().NotBeNull(); + + var expectedThresholds = new Dictionary + { + { 1, 0 }, // Bronze + { 2, 100 }, // Silver + { 3, 300 }, // Gold + { 4, 600 }, // Platinum + { 5, 1000 } // Diamond + }; + + foreach (var expected in expectedThresholds) + { + var level = levels.FirstOrDefault(l => l.LevelNumber == expected.Key); + level.Should().NotBeNull(); + level!.RequiredExp.Should().Be(expected.Value); + } + } + + [Fact] + public async Task GetLevels_EachLevelShouldHaveUniqueId() + { + // Arrange + await _factory.SeedLevelDefinitionsAsync(); + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/v1/levels"); + var levels = await response.Content.ReadFromJsonAsync>(); + + // Assert + levels.Should().NotBeNull(); + var ids = levels!.Select(l => l.Id).ToList(); + ids.Should().OnlyHaveUniqueItems(); + } +} diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs index 408fe4fa..3494e0da 100644 --- a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs @@ -8,42 +8,85 @@ using Xunit; namespace MembershipService.FunctionalTests.Controllers; /// -/// EN: Functional tests for MembersController. -/// VI: Functional tests cho MembersController. +/// EN: Comprehensive functional tests for MembersController. +/// VI: Functional tests toàn diện cho MembersController. /// public class MembersControllerTests : IClassFixture { - private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; public MembersControllerTests(CustomWebApplicationFactory factory) { - _client = factory.CreateClient(); + _factory = factory; } + #region GET /api/v1/members + [Fact] - public async Task GetMembers_ShouldReturnEmptyList() + public async Task GetMembers_WithoutAuth_ShouldReturnUnauthorized() { + // Arrange + var client = _factory.CreateClient(); + // Act - var response = await _client.GetAsync("/api/v1/members?page=1&pageSize=10"); + var response = await client.GetAsync("/api/v1/members?page=1&pageSize=10"); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] - public async Task GetMemberById_WithInvalidId_ShouldReturnUnauthorized() + public async Task GetMembers_WithAuth_ShouldReturnOk() { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + // Act - var response = await _client.GetAsync($"/api/v1/members/{Guid.NewGuid()}"); + var response = await client.GetAsync("/api/v1/members?page=1&pageSize=10"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + #endregion + + #region GET /api/v1/members/{id} + + [Fact] + public async Task GetMemberById_WithoutAuth_ShouldReturnUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/api/v1/members/{Guid.NewGuid()}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + [Fact] + public async Task GetMemberById_NonExistent_ShouldReturnNotFound() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + + // Act + var response = await client.GetAsync($"/api/v1/members/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + #endregion + + #region POST /api/v1/members + [Fact] public async Task CreateMember_WithoutAuth_ShouldReturnUnauthorized() { // Arrange + var client = _factory.CreateClient(); var command = new CreateMemberCommand { UserId = Guid.NewGuid(), @@ -51,9 +94,176 @@ public class MembersControllerTests : IClassFixture }; // Act - var response = await _client.PostAsJsonAsync("/api/v1/members", command); + var response = await client.PostAsJsonAsync("/api/v1/members", command); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } + + [Fact] + public async Task CreateMember_ValidRequest_ShouldReturnCreated() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var userId = Guid.NewGuid(); + var command = new CreateMemberCommand + { + UserId = userId, + CountryCode = "VN" + }; + + // Act + var response = await client.PostAsJsonAsync("/api/v1/members", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.MemberId.Should().Be(userId); + result.CurrentLevel.Should().Be(1); + } + + [Fact] + public async Task CreateMember_DuplicateUserId_ShouldReturnConflict() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var userId = Guid.NewGuid(); + var command = new CreateMemberCommand + { + UserId = userId, + CountryCode = "VN" + }; + + // Create first member + await client.PostAsJsonAsync("/api/v1/members", command); + + // Act - Try to create again + var response = await client.PostAsJsonAsync("/api/v1/members", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateMember_WithGender_ShouldSetGender() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var command = new CreateMemberCommand + { + UserId = Guid.NewGuid(), + CountryCode = "VN", + Gender = "Female" + }; + + // Act + var response = await client.PostAsJsonAsync("/api/v1/members", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + #endregion + + #region Full Member Workflow + + [Fact] + public async Task FullMemberWorkflow_CreateGetUpdate_ShouldSucceed() + { + // Arrange + var client = _factory.CreateAuthenticatedClient(); + var userId = Guid.NewGuid(); + + // Step 1: Create member + var createCommand = new CreateMemberCommand + { + UserId = userId, + CountryCode = "VN", + Gender = "Male" + }; + var createResponse = await client.PostAsJsonAsync("/api/v1/members", createCommand); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Step 2: Get member + var getResponse = await client.GetAsync($"/api/v1/members/{userId}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var member = await getResponse.Content.ReadFromJsonAsync(); + member.Should().NotBeNull(); + member!.Id.Should().Be(userId); + member.CountryCode.Should().Be("VN"); + member.Gender.Should().Be("Male"); + member.CurrentLevel.Should().Be(1); + member.CurrentExp.Should().Be(0); + + // Step 3: Update profile + var updateCommand = new UpdateMemberProfileCommand + { + MemberId = userId, + Gender = "Female", + CountryCode = "US", + Preferences = "{\"theme\": \"dark\"}" + }; + var updateResponse = await client.PutAsJsonAsync($"/api/v1/members/{userId}", updateCommand); + updateResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Step 4: Verify update + var verifyResponse = await client.GetAsync($"/api/v1/members/{userId}"); + var updatedMember = await verifyResponse.Content.ReadFromJsonAsync(); + updatedMember!.Gender.Should().Be("Female"); + updatedMember.CountryCode.Should().Be("US"); + updatedMember.Preferences.Should().Contain("dark"); + } + + #endregion + + #region Experience Endpoints + + [Fact] + public async Task AddExperience_WithoutAuth_ShouldReturnUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + var command = new AddExperienceCommand + { + Points = 50, + SourceId = 1 + }; + + // Act + var response = await client.PostAsJsonAsync($"/api/v1/members/{Guid.NewGuid()}/experience", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetProgress_WithoutAuth_ShouldReturnUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/api/v1/members/{Guid.NewGuid()}/progress"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetExperienceHistory_WithoutAuth_ShouldReturnUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/api/v1/members/{Guid.NewGuid()}/experience"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + #endregion } diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs index abce7a6d..e44f6262 100644 --- a/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/CustomWebApplicationFactory.cs @@ -1,14 +1,21 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Infrastructure; namespace MembershipService.FunctionalTests; /// -/// EN: Custom WebApplicationFactory for functional tests. -/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// EN: Custom WebApplicationFactory for functional tests with mock auth. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests với mock auth. /// public class CustomWebApplicationFactory : WebApplicationFactory { @@ -38,6 +45,86 @@ public class CustomWebApplicationFactory : WebApplicationFactory { options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); }); + + // EN: Add mock authentication + // VI: Thêm mock authentication + services.AddAuthentication("Test") + .AddScheme("Test", options => { }); }); } + + /// + /// EN: Create client with authenticated user. + /// VI: Tạo client với user đã xác thực. + /// + public HttpClient CreateAuthenticatedClient(Guid? userId = null) + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-User-Id", (userId ?? Guid.NewGuid()).ToString()); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + return client; + } + + /// + /// EN: Seed test data into database. + /// VI: Seed dữ liệu test vào database. + /// + public async Task SeedLevelDefinitionsAsync() + { + using var scope = Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + if (!await context.LevelDefinitions.AnyAsync()) + { + context.LevelDefinitions.AddRange( + new LevelDefinition(1, "Bronze", 0, "Starting level", "#CD7F32"), + new LevelDefinition(2, "Silver", 100, "Reach 100 EXP", "#C0C0C0"), + new LevelDefinition(3, "Gold", 300, "Reach 300 EXP", "#FFD700"), + new LevelDefinition(4, "Platinum", 600, "Reach 600 EXP", "#E5E4E2"), + new LevelDefinition(5, "Diamond", 1000, "Reach 1000 EXP", "#B9F2FF") + ); + await context.SaveChangesAsync(); + } + } +} + +/// +/// EN: Test authentication handler for functional tests. +/// VI: Test authentication handler cho functional tests. +/// +public class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + // EN: Check for test user ID header + // VI: Kiểm tra header test user ID + if (!Request.Headers.TryGetValue("X-Test-User-Id", out var userIdValues)) + { + return Task.FromResult(AuthenticateResult.Fail("Missing X-Test-User-Id header")); + } + + var userId = userIdValues.FirstOrDefault() ?? Guid.NewGuid().ToString(); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim("sub", userId), + new Claim("id", userId), + new Claim(ClaimTypes.Name, "Test User"), + new Claim(ClaimTypes.Email, "test@example.com") + }; + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } } diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/ExperienceTransactionTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/ExperienceTransactionTests.cs new file mode 100644 index 00000000..b9887b93 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/ExperienceTransactionTests.cs @@ -0,0 +1,187 @@ +using FluentAssertions; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using Xunit; + +namespace MembershipService.UnitTests.Domain; + +/// +/// EN: Unit tests for ExperienceTransaction entity. +/// VI: Unit tests cho ExperienceTransaction entity. +/// +public class ExperienceTransactionTests +{ + [Fact] + public void Create_WithValidParameters_ShouldCreateTransaction() + { + // Arrange + var memberId = Guid.NewGuid(); + + // Act + var transaction = new ExperienceTransaction(memberId, 50, ExperienceSource.Purchase, 1); + + // Assert + transaction.MemberId.Should().Be(memberId); + transaction.Points.Should().Be(50); + transaction.Source.Should().Be(ExperienceSource.Purchase); + transaction.SourceId.Should().Be(ExperienceSource.Purchase.Id); + transaction.LevelAtTime.Should().Be(1); + } + + [Fact] + public void Create_WithReferenceId_ShouldSetReferenceId() + { + // Arrange & Act + var transaction = new ExperienceTransaction( + Guid.NewGuid(), 100, ExperienceSource.Purchase, 1, "ORDER-123"); + + // Assert + transaction.ReferenceId.Should().Be("ORDER-123"); + } + + [Fact] + public void Create_WithMetadata_ShouldSetMetadata() + { + // Arrange + var metadata = "{\"orderId\": \"123\", \"items\": 5}"; + + // Act + var transaction = new ExperienceTransaction( + Guid.NewGuid(), 100, ExperienceSource.Purchase, 1, "ORDER-123", metadata); + + // Assert + transaction.Metadata.Should().Be(metadata); + } + + [Fact] + public void Create_WithNegativePoints_ShouldThrow() + { + // Act & Assert + var act = () => new ExperienceTransaction(Guid.NewGuid(), -50, ExperienceSource.Purchase, 1); + act.Should().Throw().WithMessage("*positive*"); + } + + [Fact] + public void Create_WithZeroPoints_ShouldThrow() + { + // Act & Assert + var act = () => new ExperienceTransaction(Guid.NewGuid(), 0, ExperienceSource.Purchase, 1); + act.Should().Throw().WithMessage("*positive*"); + } + + [Fact] + public void Create_WithNullSource_ShouldThrow() + { + // Act & Assert + var act = () => new ExperienceTransaction(Guid.NewGuid(), 50, null!, 1); + act.Should().Throw(); + } + + [Fact] + public void Create_WithEmptyMemberId_ShouldThrow() + { + // Act & Assert + var act = () => new ExperienceTransaction(Guid.Empty, 50, ExperienceSource.Purchase, 1); + act.Should().Throw().WithMessage("*memberId*"); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void ExperienceSource_ShouldHaveValidIds(int sourceId) + { + // Act + var source = ExperienceSource.FromValue(sourceId); + + // Assert + source.Should().NotBeNull(); + source.Id.Should().Be(sourceId); + } + + [Fact] + public void ExperienceSource_Purchase_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Purchase.Id.Should().Be(1); + ExperienceSource.Purchase.Name.Should().Be("Purchase"); + } + + [Fact] + public void ExperienceSource_Referral_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Referral.Id.Should().Be(2); + ExperienceSource.Referral.Name.Should().Be("Referral"); + } + + [Fact] + public void ExperienceSource_Activity_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Activity.Id.Should().Be(3); + ExperienceSource.Activity.Name.Should().Be("Activity"); + } + + [Fact] + public void ExperienceSource_Promotion_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Promotion.Id.Should().Be(4); + ExperienceSource.Promotion.Name.Should().Be("Promotion"); + } + + [Fact] + public void ExperienceSource_Review_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Review.Id.Should().Be(5); + ExperienceSource.Review.Name.Should().Be("Review"); + } + + [Fact] + public void ExperienceSource_CheckIn_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.CheckIn.Id.Should().Be(6); + ExperienceSource.CheckIn.Name.Should().Be("CheckIn"); + } + + [Fact] + public void ExperienceSource_Admin_ShouldHaveCorrectValues() + { + // Assert + ExperienceSource.Admin.Id.Should().Be(7); + ExperienceSource.Admin.Name.Should().Be("Admin"); + } + + [Fact] + public void CreatedAt_ShouldBeSetAutomatically() + { + // Arrange + var before = DateTime.UtcNow.AddSeconds(-1); + + // Act + var transaction = new ExperienceTransaction(Guid.NewGuid(), 50, ExperienceSource.Purchase, 1); + + // Assert + transaction.CreatedAt.Should().BeAfter(before); + transaction.CreatedAt.Should().BeBefore(DateTime.UtcNow.AddSeconds(1)); + } + + [Fact] + public void SetSource_ShouldUpdateSource() + { + // Arrange + var transaction = new ExperienceTransaction(Guid.NewGuid(), 50, ExperienceSource.Purchase, 1); + + // Act + transaction.SetSource(ExperienceSource.Admin); + + // Assert + transaction.Source.Should().Be(ExperienceSource.Admin); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/LevelDefinitionAggregateTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/LevelDefinitionAggregateTests.cs new file mode 100644 index 00000000..7b2533a7 --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/LevelDefinitionAggregateTests.cs @@ -0,0 +1,175 @@ +using FluentAssertions; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using Xunit; + +namespace MembershipService.UnitTests.Domain; + +/// +/// EN: Unit tests for LevelDefinition aggregate. +/// VI: Unit tests cho LevelDefinition aggregate. +/// +public class LevelDefinitionAggregateTests +{ + [Fact] + public void Create_WithValidParameters_ShouldCreateLevelDefinition() + { + // Arrange & Act + var level = new LevelDefinition(1, "Bronze", 0, "Starting level"); + + // Assert + level.LevelNumber.Should().Be(1); + level.Name.Should().Be("Bronze"); + level.RequiredExp.Should().Be(0); + level.Description.Should().Be("Starting level"); + level.IsActive.Should().BeTrue(); + } + + [Fact] + public void Create_WithBadgeColor_ShouldSetBadgeColor() + { + // Arrange & Act + var level = new LevelDefinition(1, "Bronze", 0, "Starting level", "#CD7F32"); + + // Assert + level.BadgeColor.Should().Be("#CD7F32"); + } + + [Fact] + public void Create_WithIconUrl_ShouldSetIconUrl() + { + // Arrange & Act + var level = new LevelDefinition(1, "Bronze", 0, "Starting level", "#CD7F32", "/icons/bronze.png"); + + // Assert + level.IconUrl.Should().Be("/icons/bronze.png"); + } + + [Fact] + public void Create_WithNegativeLevelNumber_ShouldThrow() + { + // Act & Assert + var act = () => new LevelDefinition(-1, "Invalid", 0, "Invalid level"); + act.Should().Throw().WithMessage("*greater than 0*"); + } + + [Fact] + public void Create_WithNegativeRequiredExp_ShouldThrow() + { + // Act & Assert + var act = () => new LevelDefinition(1, "Invalid", -100, "Invalid level"); + act.Should().Throw().WithMessage("*non-negative*"); + } + + [Fact] + public void Create_WithEmptyName_ShouldThrow() + { + // Act & Assert + var act = () => new LevelDefinition(1, "", 0, "Invalid level"); + act.Should().Throw().WithMessage("*required*"); + } + + [Fact] + public void AddBenefit_ValidBenefit_ShouldAddToBenefitsList() + { + // Arrange + var level = new LevelDefinition(2, "Silver", 100, "Silver level"); + var benefit = new LevelBenefit(level.Id, "Discount", "10%", "10% discount on all purchases"); + + // Act + level.AddBenefit(benefit); + + // Assert + level.Benefits.Should().HaveCount(1); + level.Benefits.First().BenefitType.Should().Be("Discount"); + level.Benefits.First().BenefitValue.Should().Be("10%"); + } + + [Fact] + public void AddBenefit_MultipleBenefits_ShouldAddAll() + { + // Arrange + var level = new LevelDefinition(3, "Gold", 300, "Gold level"); + + // Act + level.AddBenefit(new LevelBenefit(level.Id, "Discount", "15%", "15% discount")); + level.AddBenefit(new LevelBenefit(level.Id, "FreeShipping", "true", "Free shipping")); + level.AddBenefit(new LevelBenefit(level.Id, "Priority", "high", "Priority support")); + + // Assert + level.Benefits.Should().HaveCount(3); + } + + [Fact] + public void AddBenefit_NullBenefit_ShouldThrow() + { + // Arrange + var level = new LevelDefinition(1, "Bronze", 0, "Starting level"); + + // Act & Assert + var act = () => level.AddBenefit(null!); + act.Should().Throw(); + } + + [Fact] + public void Deactivate_ActiveLevel_ShouldSetIsActiveFalse() + { + // Arrange + var level = new LevelDefinition(1, "Bronze", 0, "Starting level"); + level.IsActive.Should().BeTrue(); + + // Act + level.Deactivate(); + + // Assert + level.IsActive.Should().BeFalse(); + } + + [Fact] + public void Activate_InactiveLevel_ShouldSetIsActiveTrue() + { + // Arrange + var level = new LevelDefinition(1, "Bronze", 0, "Starting level"); + level.Deactivate(); + level.IsActive.Should().BeFalse(); + + // Act + level.Activate(); + + // Assert + level.IsActive.Should().BeTrue(); + } + + [Fact] + public void LevelOrdering_ShouldBeByLevelNumber() + { + // Arrange + var levels = new List + { + new(3, "Gold", 300, "Gold"), + new(1, "Bronze", 0, "Bronze"), + new(5, "Diamond", 1000, "Diamond"), + new(2, "Silver", 100, "Silver"), + new(4, "Platinum", 600, "Platinum") + }; + + // Act + var ordered = levels.OrderBy(l => l.LevelNumber).ToList(); + + // Assert + ordered[0].Name.Should().Be("Bronze"); + ordered[1].Name.Should().Be("Silver"); + ordered[2].Name.Should().Be("Gold"); + ordered[3].Name.Should().Be("Platinum"); + ordered[4].Name.Should().Be("Diamond"); + } + + [Fact] + public void Benefits_ShouldBeReadOnlyCollection() + { + // Arrange + var level = new LevelDefinition(1, "Bronze", 0, "Starting level"); + + // Assert + level.Benefits.Should().BeAssignableTo>(); + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/AddExperienceCommandHandlerTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/AddExperienceCommandHandlerTests.cs new file mode 100644 index 00000000..981488ca --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/AddExperienceCommandHandlerTests.cs @@ -0,0 +1,236 @@ +using FluentAssertions; +using MembershipService.API.Application.Commands; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace MembershipService.UnitTests.Handlers; + +/// +/// EN: Unit tests for AddExperienceCommandHandler. +/// VI: Unit tests cho AddExperienceCommandHandler. +/// +public class AddExperienceCommandHandlerTests +{ + private readonly IMemberRepository _memberRepository; + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly IExperienceTransactionRepository _experienceTransactionRepository; + private readonly ILogger _logger; + private readonly AddExperienceCommandHandler _handler; + + public AddExperienceCommandHandlerTests() + { + _memberRepository = Substitute.For(); + _levelDefinitionRepository = Substitute.For(); + _experienceTransactionRepository = Substitute.For(); + _logger = Substitute.For>(); + + _handler = new AddExperienceCommandHandler( + _memberRepository, + _levelDefinitionRepository, + _experienceTransactionRepository, + _logger); + } + + [Fact] + public async Task Handle_ValidCommand_ShouldAddExperienceAndReturnResult() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + var command = new AddExperienceCommand + { + MemberId = memberId, + Points = 50, + SourceId = ExperienceSource.Purchase.Id, + ReferenceId = "ORDER-123" + }; + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + _memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.MemberId.Should().Be(memberId); + result.PointsAdded.Should().Be(50); + result.CurrentExp.Should().Be(50); + result.LeveledUp.Should().BeFalse(); + + // Verify interactions + await _memberRepository.Received(1).GetByIdAsync(memberId, Arg.Any()); + await _levelDefinitionRepository.Received(1).GetAllActiveAsync(); + _experienceTransactionRepository.Received(1).Add(Arg.Any()); + _memberRepository.Received(1).Update(member); + } + + [Fact] + public async Task Handle_EnoughExpToLevelUp_ShouldReturnLeveledUpTrue() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + var command = new AddExperienceCommand + { + MemberId = memberId, + Points = 150, // Enough for Silver (100 EXP) + SourceId = ExperienceSource.Purchase.Id + }; + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + _memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.LeveledUp.Should().BeTrue(); + result.PreviousLevel.Should().Be(1); + result.CurrentLevel.Should().Be(2); + } + + [Fact] + public async Task Handle_MemberNotFound_ShouldThrowKeyNotFoundException() + { + // Arrange + var memberId = Guid.NewGuid(); + var command = new AddExperienceCommand + { + MemberId = memberId, + Points = 50, + SourceId = ExperienceSource.Purchase.Id + }; + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns((Member?)null); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } + + [Fact] + public async Task Handle_NoActiveLevelRules_ShouldThrowInvalidOperationException() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var command = new AddExperienceCommand + { + MemberId = memberId, + Points = 50, + SourceId = ExperienceSource.Purchase.Id + }; + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(new List()); + + // Act & Assert + await Assert.ThrowsAsync(() => + _handler.Handle(command, CancellationToken.None)); + } + + [Fact] + public async Task Handle_MultipleSourceTypes_ShouldHandleAllSourcesCorrectly() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + _memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + foreach (var sourceId in new[] { 1, 2, 3, 4, 5, 6, 7 }) + { + // Reset member for each test + member = new Member(Guid.NewGuid()); + _memberRepository.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns(member); + + var command = new AddExperienceCommand + { + MemberId = member.Id, + Points = 10, + SourceId = sourceId + }; + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.PointsAdded.Should().Be(10); + } + } + + [Fact] + public async Task Handle_WithMetadata_ShouldPassMetadataToTransaction() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + var metadata = "{\"items\": 5, \"totalAmount\": 100}"; + + var command = new AddExperienceCommand + { + MemberId = memberId, + Points = 50, + SourceId = ExperienceSource.Purchase.Id, + ReferenceId = "ORDER-123", + Metadata = metadata + }; + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + _memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any()) + .Returns(true); + + ExperienceTransaction? capturedTransaction = null; + _experienceTransactionRepository.Add(Arg.Do(t => capturedTransaction = t)); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert - Verify transaction was captured with metadata + // Note: The actual transaction metadata is set in Member.AddExperience + _experienceTransactionRepository.Received(1).Add(Arg.Any()); + } + + private static IReadOnlyList CreateDefaultLevelRules() + { + return new List + { + new(1, "Bronze", 0, "Starting level"), + new(2, "Silver", 100, "Reach 100 EXP"), + new(3, "Gold", 300, "Reach 300 EXP"), + new(4, "Platinum", 600, "Reach 600 EXP"), + new(5, "Diamond", 1000, "Reach 1000 EXP") + }; + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/GetMemberProgressQueryHandlerTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/GetMemberProgressQueryHandlerTests.cs new file mode 100644 index 00000000..8af3c63b --- /dev/null +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Handlers/GetMemberProgressQueryHandlerTests.cs @@ -0,0 +1,197 @@ +using FluentAssertions; +using MembershipService.API.Application.Queries; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.AggregatesModel.MemberAggregate; +using NSubstitute; +using Xunit; + +namespace MembershipService.UnitTests.Handlers; + +/// +/// EN: Unit tests for GetMemberProgressQueryHandler. +/// VI: Unit tests cho GetMemberProgressQueryHandler. +/// +public class GetMemberProgressQueryHandlerTests +{ + private readonly IMemberRepository _memberRepository; + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly GetMemberProgressQueryHandler _handler; + + public GetMemberProgressQueryHandlerTests() + { + _memberRepository = Substitute.For(); + _levelDefinitionRepository = Substitute.For(); + + _handler = new GetMemberProgressQueryHandler( + _memberRepository, + _levelDefinitionRepository); + } + + [Fact] + public async Task Handle_ValidMember_ShouldReturnProgressDto() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.MemberId.Should().Be(memberId); + result.CurrentLevel.Should().Be(1); + result.CurrentLevelName.Should().Be("Bronze"); + result.CurrentExp.Should().Be(0); + result.NextLevel.Should().Be(2); + result.NextLevelName.Should().Be("Silver"); + result.ExpToNextLevel.Should().Be(100); + } + + [Fact] + public async Task Handle_MemberNotFound_ShouldReturnNull() + { + // Arrange + var memberId = Guid.NewGuid(); + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns((Member?)null); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_NoLevelRules_ShouldReturnNull() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(new List()); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task Handle_MemberAtMaxLevel_ShouldReturnNullNextLevel() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + // Add enough EXP to reach max level (Diamond = 1000) + member.AddExperience(1500, + MembershipService.Domain.AggregatesModel.ExperienceAggregate.ExperienceSource.Admin, + levelRules); + + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.CurrentLevel.Should().Be(5); + result.CurrentLevelName.Should().Be("Diamond"); + result.NextLevel.Should().BeNull(); + result.NextLevelName.Should().BeNull(); + result.ProgressPercent.Should().Be(100); + } + + [Fact] + public async Task Handle_MemberWithPartialProgress_ShouldCalculatePercentCorrectly() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = CreateDefaultLevelRules(); + + // Add 50 EXP (50% progress to Silver which needs 100) + member.AddExperience(50, + MembershipService.Domain.AggregatesModel.ExperienceAggregate.ExperienceSource.Purchase, + levelRules); + + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.CurrentLevel.Should().Be(1); + result.CurrentExp.Should().Be(50); + result.ProgressPercent.Should().Be(50); + result.ExpToNextLevel.Should().Be(50); + } + + [Fact] + public async Task Handle_ShouldReturnBadgeColor() + { + // Arrange + var memberId = Guid.NewGuid(); + var member = new Member(memberId); + var levelRules = new List + { + new(1, "Bronze", 0, "Starting level", "#CD7F32"), + new(2, "Silver", 100, "Reach 100 EXP", "#C0C0C0") + }; + + var query = new GetMemberProgressQuery(memberId); + + _memberRepository.GetByIdAsync(memberId, Arg.Any()) + .Returns(member); + _levelDefinitionRepository.GetAllActiveAsync() + .Returns(levelRules); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.BadgeColor.Should().Be("#CD7F32"); + } + + private static IReadOnlyList CreateDefaultLevelRules() + { + return new List + { + new(1, "Bronze", 0, "Starting level"), + new(2, "Silver", 100, "Reach 100 EXP"), + new(3, "Gold", 300, "Reach 300 EXP"), + new(4, "Platinum", 600, "Reach 600 EXP"), + new(5, "Diamond", 1000, "Reach 1000 EXP") + }; + } +} diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj b/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj index ca2bdf6f..1453172f 100644 --- a/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj +++ b/services/membership-service-net/tests/MembershipService.UnitTests/MembershipService.UnitTests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommand.cs new file mode 100644 index 00000000..b751ed74 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommand.cs @@ -0,0 +1,47 @@ +// EN: Command to register a new merchant. +// VI: Command để đăng ký merchant mới. + +using MediatR; + +namespace MerchantService.API.Application.Commands.Merchants; + +/// +/// EN: Command to register a new merchant (shop owner). +/// VI: Command để đăng ký merchant mới (chủ shop). +/// +public record RegisterMerchantCommand : IRequest +{ + /// + /// EN: Business/Company name. + /// VI: Tên doanh nghiệp/công ty. + /// + public string BusinessName { get; init; } = null!; + + /// + /// EN: Merchant type (Individual or Company). + /// VI: Loại merchant (Individual hoặc Company). + /// + public string Type { get; init; } = "Individual"; + + /// + /// EN: Tax identification number. + /// VI: Mã số thuế. + /// + public string? TaxId { get; init; } + + /// + /// EN: Business license number. + /// VI: Số giấy phép kinh doanh. + /// + public string? BusinessLicenseNumber { get; init; } +} + +/// +/// EN: Result of merchant registration. +/// VI: Kết quả đăng ký merchant. +/// +public record RegisterMerchantResult( + Guid MerchantId, + string BusinessName, + string Status +); diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommandHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommandHandler.cs new file mode 100644 index 00000000..3a27aa24 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/RegisterMerchantCommandHandler.cs @@ -0,0 +1,89 @@ +// EN: Handler for RegisterMerchantCommand. +// VI: Handler cho RegisterMerchantCommand. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Merchants; + +/// +/// EN: Handler for registering a new merchant. +/// VI: Handler để đăng ký merchant mới. +/// +public class RegisterMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public RegisterMerchantCommandHandler( + IMerchantRepository merchantRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(RegisterMerchantCommand request, CancellationToken cancellationToken) + { + // EN: Get current user ID from claims + // VI: Lấy user ID hiện tại từ claims + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new DomainException("User not authenticated"); + } + + // EN: Check if user already has a merchant account + // VI: Kiểm tra user đã có tài khoản merchant chưa + var existingMerchant = await _merchantRepository.ExistsByUserIdAsync(userId, cancellationToken); + if (existingMerchant) + { + throw new DomainException("User already has a merchant account"); + } + + // EN: Parse merchant type + // VI: Parse loại merchant + var merchantType = request.Type.ToLowerInvariant() switch + { + "individual" => MerchantType.Individual, + "company" => MerchantType.Company, + _ => MerchantType.Individual + }; + + // EN: Create merchant aggregate + // VI: Tạo merchant aggregate + var merchant = Merchant.Register(userId, request.BusinessName, merchantType); + + // EN: Update business info if provided + // VI: Cập nhật thông tin doanh nghiệp nếu có + if (!string.IsNullOrWhiteSpace(request.TaxId) || !string.IsNullOrWhiteSpace(request.BusinessLicenseNumber)) + { + merchant.UpdateBusinessInfo(new BusinessInfo + { + TaxId = request.TaxId, + BusinessLicenseNumber = request.BusinessLicenseNumber + }); + } + + // EN: Save to repository + // VI: Lưu vào repository + _merchantRepository.Add(merchant); + await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Merchant registered: {MerchantId} for user {UserId}", + merchant.Id, userId); + + return new RegisterMerchantResult( + merchant.Id, + merchant.BusinessName, + merchant.Status.Name + ); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommand.cs new file mode 100644 index 00000000..c6bfdb19 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommand.cs @@ -0,0 +1,61 @@ +// EN: Command to update merchant information. +// VI: Command để cập nhật thông tin merchant. + +using MediatR; + +namespace MerchantService.API.Application.Commands.Merchants; + +/// +/// EN: Command to update merchant information. +/// VI: Command để cập nhật thông tin merchant. +/// +public record UpdateMerchantCommand : IRequest +{ + /// + /// EN: Business/Company name. + /// VI: Tên doanh nghiệp/công ty. + /// + public string? BusinessName { get; init; } + + /// + /// EN: Tax identification number. + /// VI: Mã số thuế. + /// + public string? TaxId { get; init; } + + /// + /// EN: Business license number. + /// VI: Số giấy phép kinh doanh. + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// EN: Company registration number. + /// VI: Số đăng ký công ty. + /// + public string? CompanyRegistrationNumber { get; init; } + + /// + /// EN: Bank code for settlement. + /// VI: Mã ngân hàng để thanh toán. + /// + public string? BankCode { get; init; } + + /// + /// EN: Bank name. + /// VI: Tên ngân hàng. + /// + public string? BankName { get; init; } + + /// + /// EN: Bank account number. + /// VI: Số tài khoản ngân hàng. + /// + public string? BankAccountNumber { get; init; } + + /// + /// EN: Bank account holder name. + /// VI: Tên chủ tài khoản. + /// + public string? BankAccountHolderName { get; init; } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommandHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommandHandler.cs new file mode 100644 index 00000000..f07ebe73 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Merchants/UpdateMerchantCommandHandler.cs @@ -0,0 +1,100 @@ +// EN: Handler for UpdateMerchantCommand. +// VI: Handler cho UpdateMerchantCommand. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Merchants; + +/// +/// EN: Handler for updating merchant information. +/// VI: Handler để cập nhật thông tin merchant. +/// +public class UpdateMerchantCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public UpdateMerchantCommandHandler( + IMerchantRepository merchantRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(UpdateMerchantCommand request, CancellationToken cancellationToken) + { + // EN: Get current user ID from claims + // VI: Lấy user ID hiện tại từ claims + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new DomainException("User not authenticated"); + } + + // EN: Get merchant by user ID + // VI: Lấy merchant theo user ID + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + if (merchant == null) + { + throw new DomainException("Merchant not found"); + } + + // EN: Update business name + // VI: Cập nhật tên doanh nghiệp + if (!string.IsNullOrWhiteSpace(request.BusinessName)) + { + merchant.UpdateBusinessName(request.BusinessName); + } + + // EN: Update business info + // VI: Cập nhật thông tin doanh nghiệp + if (!string.IsNullOrWhiteSpace(request.TaxId) || + !string.IsNullOrWhiteSpace(request.BusinessLicenseNumber) || + !string.IsNullOrWhiteSpace(request.CompanyRegistrationNumber)) + { + merchant.UpdateBusinessInfo(new BusinessInfo + { + TaxId = request.TaxId ?? merchant.BusinessInfo.TaxId, + BusinessLicenseNumber = request.BusinessLicenseNumber ?? merchant.BusinessInfo.BusinessLicenseNumber, + CompanyRegistrationNumber = request.CompanyRegistrationNumber ?? merchant.BusinessInfo.CompanyRegistrationNumber, + EstablishedDate = merchant.BusinessInfo.EstablishedDate + }); + } + + // EN: Update settlement config if bank info provided + // VI: Cập nhật cấu hình thanh toán nếu có thông tin ngân hàng + if (!string.IsNullOrWhiteSpace(request.BankAccountNumber)) + { + merchant.UpdateSettlementConfig(new SettlementConfig + { + BankAccount = new BankAccount + { + BankCode = request.BankCode ?? merchant.SettlementConfig.BankAccount.BankCode, + BankName = request.BankName ?? merchant.SettlementConfig.BankAccount.BankName, + AccountNumber = request.BankAccountNumber, + AccountHolderName = request.BankAccountHolderName ?? merchant.SettlementConfig.BankAccount.AccountHolderName + }, + CommissionRate = merchant.SettlementConfig.CommissionRate, + SettlementCycleId = merchant.SettlementConfig.SettlementCycleId, + AutoSettlement = merchant.SettlementConfig.AutoSettlement + }); + } + + // EN: Save changes + // VI: Lưu thay đổi + _merchantRepository.Update(merchant); + await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Merchant updated: {MerchantId}", merchant.Id); + + return true; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommand.cs new file mode 100644 index 00000000..c9d342f9 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommand.cs @@ -0,0 +1,89 @@ +// EN: Command to add a branch to a shop. +// VI: Command để thêm chi nhánh vào shop. + +using MediatR; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Command to add a physical branch to a shop. +/// VI: Command để thêm chi nhánh vật lý vào shop. +/// +public record AddShopBranchCommand : IRequest +{ + /// + /// EN: Shop ID. + /// VI: ID shop. + /// + public Guid ShopId { get; init; } + + /// + /// EN: Branch name. + /// VI: Tên chi nhánh. + /// + public string Name { get; init; } = null!; + + /// + /// EN: Branch code (e.g., "HN01"). + /// VI: Mã chi nhánh (ví dụ: "HN01"). + /// + public string? Code { get; init; } + + /// + /// EN: Street address. + /// VI: Địa chỉ đường phố. + /// + public string Street { get; init; } = null!; + + /// + /// EN: Ward/Commune. + /// VI: Phường/Xã. + /// + public string? Ward { get; init; } + + /// + /// EN: District. + /// VI: Quận/Huyện. + /// + public string District { get; init; } = null!; + + /// + /// EN: City. + /// VI: Thành phố. + /// + public string City { get; init; } = null!; + + /// + /// EN: Province/State. + /// VI: Tỉnh/Thành. + /// + public string? Province { get; init; } + + /// + /// EN: Latitude coordinate. + /// VI: Tọa độ vĩ độ. + /// + public double? Latitude { get; init; } + + /// + /// EN: Longitude coordinate. + /// VI: Tọa độ kinh độ. + /// + public double? Longitude { get; init; } + + /// + /// EN: Branch phone number. + /// VI: Số điện thoại chi nhánh. + /// + public string? Phone { get; init; } +} + +/// +/// EN: Result of adding a branch. +/// VI: Kết quả thêm chi nhánh. +/// +public record AddShopBranchResult( + Guid BranchId, + string Name, + string Address +); diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommandHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommandHandler.cs new file mode 100644 index 00000000..b43ba209 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/AddShopBranchCommandHandler.cs @@ -0,0 +1,116 @@ +// EN: Handler for AddShopBranchCommand. +// VI: Handler cho AddShopBranchCommand. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Handler for adding a branch to a shop. +/// VI: Handler để thêm chi nhánh vào shop. +/// +public class AddShopBranchCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public AddShopBranchCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(AddShopBranchCommand request, CancellationToken cancellationToken) + { + // EN: Get current user ID from claims + // VI: Lấy user ID hiện tại từ claims + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new DomainException("User not authenticated"); + } + + // EN: Get merchant by user ID + // VI: Lấy merchant theo user ID + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + if (merchant == null) + { + throw new DomainException("Merchant not found"); + } + + // EN: Get shop and verify ownership + // VI: Lấy shop và xác minh quyền sở hữu + var shop = await _shopRepository.GetByIdWithBranchesAsync(request.ShopId, cancellationToken); + if (shop == null) + { + throw new DomainException("Shop not found"); + } + + if (shop.MerchantId != merchant.Id) + { + throw new DomainException("You don't have permission to modify this shop"); + } + + // EN: Create address + // VI: Tạo địa chỉ + var address = new Address + { + Street = request.Street, + Ward = request.Ward, + District = request.District, + City = request.City, + Province = request.Province + }; + + // EN: Create geo location if provided + // VI: Tạo vị trí địa lý nếu có + GeoLocation? location = null; + if (request.Latitude.HasValue && request.Longitude.HasValue) + { + location = new GeoLocation + { + Latitude = request.Latitude.Value, + Longitude = request.Longitude.Value + }; + } + + // EN: Add branch to shop + // VI: Thêm chi nhánh vào shop + var branch = shop.AddBranch(request.Name, address, location); + + // EN: Set phone if provided + // VI: Đặt số điện thoại nếu có + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + branch.SetPhone(request.Phone); + } + + // EN: Save changes + // VI: Lưu thay đổi + _shopRepository.Update(shop); + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Branch added: {BranchId} to shop {ShopId}", + branch.Id, shop.Id); + + return new AddShopBranchResult( + branch.Id, + branch.Name, + address.FullAddress + ); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommand.cs new file mode 100644 index 00000000..0c2e636c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommand.cs @@ -0,0 +1,72 @@ +// EN: Command to create a new shop. +// VI: Command để tạo shop mới. + +using MediatR; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Command to create a new shop. +/// VI: Command để tạo shop mới. +/// +public record CreateShopCommand : IRequest +{ + /// + /// EN: Shop name. + /// VI: Tên shop. + /// + public string Name { get; init; } = null!; + + /// + /// EN: URL-friendly slug. + /// VI: Slug thân thiện URL. + /// + public string Slug { get; init; } = null!; + + /// + /// EN: Shop type (OnlineOnly, PhysicalOnly, Hybrid). + /// VI: Loại shop (OnlineOnly, PhysicalOnly, Hybrid). + /// + public string Type { get; init; } = "Hybrid"; + + /// + /// EN: Business category. + /// VI: Ngành nghề kinh doanh. + /// + public string Category { get; init; } = "Other"; + + /// + /// EN: Shop description. + /// VI: Mô tả shop. + /// + public string? Description { get; init; } + + /// + /// EN: Phone number. + /// VI: Số điện thoại. + /// + public string? Phone { get; init; } + + /// + /// EN: Email address. + /// VI: Địa chỉ email. + /// + public string? Email { get; init; } + + /// + /// EN: Website URL. + /// VI: URL website. + /// + public string? Website { get; init; } +} + +/// +/// EN: Result of shop creation. +/// VI: Kết quả tạo shop. +/// +public record CreateShopResult( + Guid ShopId, + string Name, + string Slug, + string Status +); diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommandHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommandHandler.cs new file mode 100644 index 00000000..69bfb125 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/CreateShopCommandHandler.cs @@ -0,0 +1,136 @@ +// EN: Handler for CreateShopCommand. +// VI: Handler cho CreateShopCommand. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Handler for creating a new shop. +/// VI: Handler để tạo shop mới. +/// +public class CreateShopCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public CreateShopCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(CreateShopCommand request, CancellationToken cancellationToken) + { + // EN: Get current user ID from claims + // VI: Lấy user ID hiện tại từ claims + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new DomainException("User not authenticated"); + } + + // EN: Get merchant by user ID + // VI: Lấy merchant theo user ID + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + if (merchant == null) + { + throw new DomainException("Merchant not found. Please register as a merchant first."); + } + + // EN: Check if merchant is active + // VI: Kiểm tra merchant có active không + if (merchant.Status != MerchantStatus.Active) + { + throw new DomainException("Only active merchants can create shops. Please wait for approval."); + } + + // EN: Check if slug already exists + // VI: Kiểm tra slug đã tồn tại chưa + var slugExists = await _shopRepository.SlugExistsAsync(request.Slug, cancellationToken); + if (slugExists) + { + throw new DomainException($"Shop slug '{request.Slug}' is already taken"); + } + + // EN: Parse shop type + // VI: Parse loại shop + var shopType = request.Type.ToLowerInvariant() switch + { + "onlineonly" => ShopType.OnlineOnly, + "physicalonly" => ShopType.PhysicalOnly, + "hybrid" => ShopType.Hybrid, + _ => ShopType.Hybrid + }; + + // EN: Parse business category + // VI: Parse ngành nghề + var category = request.Category.ToLowerInvariant() switch + { + "foodbeverage" => BusinessCategory.FoodBeverage, + "fashion" => BusinessCategory.Fashion, + "electronics" => BusinessCategory.Electronics, + "healthcare" => BusinessCategory.Healthcare, + "beauty" => BusinessCategory.Beauty, + "education" => BusinessCategory.Education, + "entertainment" => BusinessCategory.Entertainment, + "services" => BusinessCategory.Services, + "grocery" => BusinessCategory.Grocery, + "homefurniture" => BusinessCategory.HomeFurniture, + _ => BusinessCategory.Other + }; + + // EN: Create shop + // VI: Tạo shop + var shop = new Shop(merchant.Id, request.Name, request.Slug, shopType, category); + + // EN: Update description if provided + // VI: Cập nhật mô tả nếu có + if (!string.IsNullOrWhiteSpace(request.Description)) + { + shop.UpdateInfo(request.Name, request.Description); + } + + // EN: Update contact info if provided + // VI: Cập nhật thông tin liên hệ nếu có + if (!string.IsNullOrWhiteSpace(request.Phone) || + !string.IsNullOrWhiteSpace(request.Email)) + { + shop.UpdateContactInfo(new ContactInfo + { + Phone = request.Phone ?? string.Empty, + Email = request.Email, + Website = request.Website + }); + } + + // EN: Save shop + // VI: Lưu shop + _shopRepository.Add(shop); + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Shop created: {ShopId} by merchant {MerchantId}", + shop.Id, merchant.Id); + + return new CreateShopResult( + shop.Id, + shop.Name, + shop.Slug, + shop.Status.Name + ); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommandHandlers.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommandHandlers.cs new file mode 100644 index 00000000..33fb0acb --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommandHandlers.cs @@ -0,0 +1,163 @@ +// EN: Handlers for Shop status change commands. +// VI: Handlers cho các commands thay đổi trạng thái Shop. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Handler for publishing a shop. +/// VI: Handler để công khai shop. +/// +public class PublishShopCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public PublishShopCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(PublishShopCommand request, CancellationToken cancellationToken) + { + var (merchant, shop) = await ValidateOwnershipAsync(request.ShopId, cancellationToken); + + shop.Publish(); + _shopRepository.Update(shop); + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Shop published: {ShopId}", shop.Id); + return true; + } + + private async Task<(Merchant, Shop)> ValidateOwnershipAsync(Guid shopId, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + throw new DomainException("User not authenticated"); + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken) + ?? throw new DomainException("Merchant not found"); + + var shop = await _shopRepository.GetByIdAsync(shopId, cancellationToken) + ?? throw new DomainException("Shop not found"); + + if (shop.MerchantId != merchant.Id) + throw new DomainException("You don't have permission to modify this shop"); + + return (merchant, shop); + } +} + +/// +/// EN: Handler for setting a shop as inactive. +/// VI: Handler để đặt shop không hoạt động. +/// +public class SetShopInactiveCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public SetShopInactiveCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(SetShopInactiveCommand request, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + throw new DomainException("User not authenticated"); + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken) + ?? throw new DomainException("Merchant not found"); + + var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken) + ?? throw new DomainException("Shop not found"); + + if (shop.MerchantId != merchant.Id) + throw new DomainException("You don't have permission to modify this shop"); + + shop.SetInactive(); + _shopRepository.Update(shop); + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Shop set inactive: {ShopId}", shop.Id); + return true; + } +} + +/// +/// EN: Handler for closing a shop permanently. +/// VI: Handler để đóng cửa shop vĩnh viễn. +/// +public class CloseShopCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public CloseShopCommandHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task Handle(CloseShopCommand request, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + throw new DomainException("User not authenticated"); + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken) + ?? throw new DomainException("Merchant not found"); + + var shop = await _shopRepository.GetByIdAsync(request.ShopId, cancellationToken) + ?? throw new DomainException("Shop not found"); + + if (shop.MerchantId != merchant.Id) + throw new DomainException("You don't have permission to modify this shop"); + + shop.Close(); + _shopRepository.Update(shop); + await _shopRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("Shop closed: {ShopId}", shop.Id); + return true; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommands.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommands.cs new file mode 100644 index 00000000..c7a82741 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Shops/ShopStatusCommands.cs @@ -0,0 +1,24 @@ +// EN: Command to publish a shop. +// VI: Command để công khai shop. + +using MediatR; + +namespace MerchantService.API.Application.Commands.Shops; + +/// +/// EN: Command to publish a shop (make it visible to customers). +/// VI: Command để công khai shop (hiển thị với khách hàng). +/// +public record PublishShopCommand(Guid ShopId) : IRequest; + +/// +/// EN: Command to set a shop as inactive. +/// VI: Command để đặt shop thành không hoạt động. +/// +public record SetShopInactiveCommand(Guid ShopId) : IRequest; + +/// +/// EN: Command to close a shop permanently. +/// VI: Command để đóng cửa shop vĩnh viễn. +/// +public record CloseShopCommand(Guid ShopId) : IRequest; diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs new file mode 100644 index 00000000..8b7f100b --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs @@ -0,0 +1,64 @@ +// EN: Query to get merchant profile. +// VI: Query để lấy profile merchant. + +using MediatR; + +namespace MerchantService.API.Application.Queries.Merchants; + +/// +/// EN: Query to get the current merchant's profile. +/// VI: Query để lấy profile của merchant hiện tại. +/// +public record GetMerchantProfileQuery : IRequest; + +/// +/// EN: Query to get a merchant by ID (admin). +/// VI: Query để lấy merchant theo ID (admin). +/// +public record GetMerchantByIdQuery(Guid MerchantId) : IRequest; + +/// +/// EN: Merchant profile DTO. +/// VI: DTO profile merchant. +/// +public record MerchantProfileDto +{ + public Guid Id { get; init; } + public Guid UserId { get; init; } + public string BusinessName { get; init; } = null!; + public string Type { get; init; } = null!; + public string Status { get; init; } = null!; + public string VerificationStatus { get; init; } = null!; + public BusinessInfoDto? BusinessInfo { get; init; } + public SettlementConfigDto? SettlementConfig { get; init; } + public DateTime? VerifiedAt { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } + public int ShopCount { get; init; } +} + +public record BusinessInfoDto +{ + public string? TaxId { get; init; } + public string? BusinessLicenseNumber { get; init; } + public string? CompanyRegistrationNumber { get; init; } + public DateTime? EstablishedDate { get; init; } + public bool IsComplete { get; init; } +} + +public record SettlementConfigDto +{ + public BankAccountDto? BankAccount { get; init; } + public decimal CommissionRate { get; init; } + public string SettlementCycle { get; init; } = null!; + public bool AutoSettlement { get; init; } + public bool IsComplete { get; init; } +} + +public record BankAccountDto +{ + public string? BankCode { get; init; } + public string? BankName { get; init; } + public string? AccountNumber { get; init; } + public string? AccountHolderName { get; init; } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs new file mode 100644 index 00000000..c91e2122 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs @@ -0,0 +1,134 @@ +// EN: Handler for GetMerchantProfileQuery. +// VI: Handler cho GetMerchantProfileQuery. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; + +namespace MerchantService.API.Application.Queries.Merchants; + +/// +/// EN: Handler for getting current merchant's profile. +/// VI: Handler để lấy profile của merchant hiện tại. +/// +public class GetMerchantProfileQueryHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public GetMerchantProfileQueryHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(GetMerchantProfileQuery request, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return null; + } + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + if (merchant == null) + { + return null; + } + + var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken); + + return MapToDto(merchant, shops.Count); + } + + private static MerchantProfileDto MapToDto(Merchant merchant, int shopCount) + { + return new MerchantProfileDto + { + Id = merchant.Id, + UserId = merchant.UserId, + BusinessName = merchant.BusinessName, + Type = merchant.Type.Name, + Status = merchant.Status.Name, + VerificationStatus = merchant.VerificationStatus.Name, + BusinessInfo = new BusinessInfoDto + { + TaxId = merchant.BusinessInfo.TaxId, + BusinessLicenseNumber = merchant.BusinessInfo.BusinessLicenseNumber, + CompanyRegistrationNumber = merchant.BusinessInfo.CompanyRegistrationNumber, + EstablishedDate = merchant.BusinessInfo.EstablishedDate, + IsComplete = merchant.BusinessInfo.IsCompleteForVerification + }, + SettlementConfig = new SettlementConfigDto + { + BankAccount = new BankAccountDto + { + BankCode = merchant.SettlementConfig.BankAccount.BankCode, + BankName = merchant.SettlementConfig.BankAccount.BankName, + AccountNumber = !string.IsNullOrEmpty(merchant.SettlementConfig.BankAccount.AccountNumber) + ? $"****{merchant.SettlementConfig.BankAccount.AccountNumber[^4..]}" + : null, + AccountHolderName = merchant.SettlementConfig.BankAccount.AccountHolderName + }, + CommissionRate = merchant.SettlementConfig.CommissionRate, + SettlementCycle = SettlementCycle.FromValue(merchant.SettlementConfig.SettlementCycleId).Name, + AutoSettlement = merchant.SettlementConfig.AutoSettlement, + IsComplete = merchant.SettlementConfig.IsComplete + }, + VerifiedAt = merchant.VerifiedAt, + CreatedAt = merchant.CreatedAt, + UpdatedAt = merchant.UpdatedAt, + ShopCount = shopCount + }; + } +} + +/// +/// EN: Handler for getting merchant by ID. +/// VI: Handler để lấy merchant theo ID. +/// +public class GetMerchantByIdQueryHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + + public GetMerchantByIdQueryHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + } + + public async Task Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken) + { + var merchant = await _merchantRepository.GetByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + { + return null; + } + + var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken); + + return new MerchantProfileDto + { + Id = merchant.Id, + UserId = merchant.UserId, + BusinessName = merchant.BusinessName, + Type = merchant.Type.Name, + Status = merchant.Status.Name, + VerificationStatus = merchant.VerificationStatus.Name, + VerifiedAt = merchant.VerifiedAt, + CreatedAt = merchant.CreatedAt, + UpdatedAt = merchant.UpdatedAt, + ShopCount = shops.Count + }; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs new file mode 100644 index 00000000..b5dc8ffc --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQuery.cs @@ -0,0 +1,105 @@ +// EN: Query to get shops. +// VI: Query để lấy danh sách shops. + +using MediatR; + +namespace MerchantService.API.Application.Queries.Shops; + +/// +/// EN: Query to get current merchant's shops. +/// VI: Query để lấy danh sách shops của merchant hiện tại. +/// +public record GetMyShopsQuery : IRequest>; + +/// +/// EN: Query to get a shop by ID. +/// VI: Query để lấy shop theo ID. +/// +public record GetShopByIdQuery(Guid ShopId) : IRequest; + +/// +/// EN: Query to get a shop by slug. +/// VI: Query để lấy shop theo slug. +/// +public record GetShopBySlugQuery(string Slug) : IRequest; + +/// +/// EN: Shop list DTO. +/// VI: DTO danh sách shop. +/// +public record ShopDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = null!; + public string Slug { get; init; } = null!; + public string Type { get; init; } = null!; + public string Category { get; init; } = null!; + public string Status { get; init; } = null!; + public string? LogoUrl { get; init; } + public int BranchCount { get; init; } + public DateTime CreatedAt { get; init; } +} + +/// +/// EN: Shop detail DTO. +/// VI: DTO chi tiết shop. +/// +public record ShopDetailDto +{ + public Guid Id { get; init; } + public Guid MerchantId { get; init; } + public string Name { get; init; } = null!; + public string Slug { get; init; } = null!; + public string Type { get; init; } = null!; + public string Category { get; init; } = null!; + public string Status { get; init; } = null!; + public string? Description { get; init; } + public string? LogoUrl { get; init; } + public string? CoverImageUrl { get; init; } + public ContactInfoDto? ContactInfo { get; init; } + public OperatingHoursDto? OperatingHours { get; init; } + public IReadOnlyList Branches { get; init; } = []; + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } +} + +public record ContactInfoDto +{ + public string? Phone { get; init; } + public string? Email { get; init; } + public string? Website { get; init; } +} + +public record OperatingHoursDto +{ + public string OpenTime { get; init; } = null!; + public string CloseTime { get; init; } = null!; + public List OpenDays { get; init; } = []; +} + +public record ShopBranchDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = null!; + public string? Code { get; init; } + public AddressDto Address { get; init; } = null!; + public GeoLocationDto? Location { get; init; } + public string? Phone { get; init; } + public bool IsActive { get; init; } +} + +public record AddressDto +{ + public string Street { get; init; } = null!; + public string? Ward { get; init; } + public string District { get; init; } = null!; + public string City { get; init; } = null!; + public string? Province { get; init; } + public string FullAddress { get; init; } = null!; +} + +public record GeoLocationDto +{ + public double Latitude { get; init; } + public double Longitude { get; init; } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs new file mode 100644 index 00000000..1b9dda7d --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Shops/GetShopsQueryHandler.cs @@ -0,0 +1,205 @@ +// EN: Handlers for Shop queries. +// VI: Handlers cho Shop queries. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Exceptions; + +namespace MerchantService.API.Application.Queries.Shops; + +/// +/// EN: Handler for getting current merchant's shops. +/// VI: Handler để lấy danh sách shops của merchant hiện tại. +/// +public class GetMyShopsQueryHandler : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IShopRepository _shopRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public GetMyShopsQueryHandler( + IMerchantRepository merchantRepository, + IShopRepository shopRepository, + IHttpContextAccessor httpContextAccessor) + { + _merchantRepository = merchantRepository; + _shopRepository = shopRepository; + _httpContextAccessor = httpContextAccessor; + } + + public async Task> Handle(GetMyShopsQuery request, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new DomainException("User not authenticated"); + } + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken); + if (merchant == null) + { + return []; + } + + var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken); + + return shops.Select(s => new ShopDto + { + Id = s.Id, + Name = s.Name, + Slug = s.Slug, + Type = s.Type.Name, + Category = s.Category.Name, + Status = s.Status.Name, + LogoUrl = s.LogoUrl, + BranchCount = s.Branches.Count, + CreatedAt = s.CreatedAt + }).ToList(); + } +} + +/// +/// EN: Handler for getting shop by ID. +/// VI: Handler để lấy shop theo ID. +/// +public class GetShopByIdQueryHandler : IRequestHandler +{ + private readonly IShopRepository _shopRepository; + + public GetShopByIdQueryHandler(IShopRepository shopRepository) + { + _shopRepository = shopRepository; + } + + public async Task Handle(GetShopByIdQuery request, CancellationToken cancellationToken) + { + var shop = await _shopRepository.GetByIdWithBranchesAsync(request.ShopId, cancellationToken); + return shop != null ? MapToDetailDto(shop) : null; + } + + private static ShopDetailDto MapToDetailDto(Shop shop) + { + return new ShopDetailDto + { + Id = shop.Id, + MerchantId = shop.MerchantId, + Name = shop.Name, + Slug = shop.Slug, + Type = shop.Type.Name, + Category = shop.Category.Name, + Status = shop.Status.Name, + Description = shop.Description, + LogoUrl = shop.LogoUrl, + CoverImageUrl = shop.CoverImageUrl, + ContactInfo = new ContactInfoDto + { + Phone = shop.ContactInfo.Phone, + Email = shop.ContactInfo.Email, + Website = shop.ContactInfo.Website + }, + OperatingHours = shop.OperatingHours != null ? new OperatingHoursDto + { + OpenTime = shop.OperatingHours.OpenTime.ToString("HH:mm"), + CloseTime = shop.OperatingHours.CloseTime.ToString("HH:mm"), + OpenDays = shop.OperatingHours.OpenDays.Select(d => d.ToString()).ToList() + } : null, + Branches = shop.Branches.Select(b => new ShopBranchDto + { + Id = b.Id, + Name = b.Name, + Code = b.Code, + Address = new AddressDto + { + Street = b.Address.Street, + Ward = b.Address.Ward, + District = b.Address.District, + City = b.Address.City, + Province = b.Address.Province, + FullAddress = b.Address.FullAddress + }, + Location = b.Location != null ? new GeoLocationDto + { + Latitude = b.Location.Latitude, + Longitude = b.Location.Longitude + } : null, + Phone = b.Phone, + IsActive = b.IsActive + }).ToList(), + CreatedAt = shop.CreatedAt, + UpdatedAt = shop.UpdatedAt + }; + } +} + +/// +/// EN: Handler for getting shop by slug. +/// VI: Handler để lấy shop theo slug. +/// +public class GetShopBySlugQueryHandler : IRequestHandler +{ + private readonly IShopRepository _shopRepository; + + public GetShopBySlugQueryHandler(IShopRepository shopRepository) + { + _shopRepository = shopRepository; + } + + public async Task Handle(GetShopBySlugQuery request, CancellationToken cancellationToken) + { + var shop = await _shopRepository.GetBySlugAsync(request.Slug, cancellationToken); + if (shop == null) + { + return null; + } + + // EN: Get shop with branches + // VI: Lấy shop với các chi nhánh + var shopWithBranches = await _shopRepository.GetByIdWithBranchesAsync(shop.Id, cancellationToken); + return shopWithBranches != null ? new ShopDetailDto + { + Id = shopWithBranches.Id, + MerchantId = shopWithBranches.MerchantId, + Name = shopWithBranches.Name, + Slug = shopWithBranches.Slug, + Type = shopWithBranches.Type.Name, + Category = shopWithBranches.Category.Name, + Status = shopWithBranches.Status.Name, + Description = shopWithBranches.Description, + LogoUrl = shopWithBranches.LogoUrl, + CoverImageUrl = shopWithBranches.CoverImageUrl, + ContactInfo = new ContactInfoDto + { + Phone = shopWithBranches.ContactInfo.Phone, + Email = shopWithBranches.ContactInfo.Email, + Website = shopWithBranches.ContactInfo.Website + }, + Branches = shopWithBranches.Branches.Where(b => b.IsActive).Select(b => new ShopBranchDto + { + Id = b.Id, + Name = b.Name, + Code = b.Code, + Address = new AddressDto + { + Street = b.Address.Street, + Ward = b.Address.Ward, + District = b.Address.District, + City = b.Address.City, + Province = b.Address.Province, + FullAddress = b.Address.FullAddress + }, + Location = b.Location != null ? new GeoLocationDto + { + Latitude = b.Location.Latitude, + Longitude = b.Location.Longitude + } : null, + Phone = b.Phone, + IsActive = b.IsActive + }).ToList(), + CreatedAt = shopWithBranches.CreatedAt, + UpdatedAt = shopWithBranches.UpdatedAt + } : null; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/MerchantCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/MerchantCommandValidators.cs new file mode 100644 index 00000000..119c1d33 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/MerchantCommandValidators.cs @@ -0,0 +1,69 @@ +// EN: Validators for Merchant commands. +// VI: Validators cho các commands Merchant. + +using FluentValidation; +using MerchantService.API.Application.Commands.Merchants; + +namespace MerchantService.API.Application.Validations; + +/// +/// EN: Validator for RegisterMerchantCommand. +/// VI: Validator cho RegisterMerchantCommand. +/// +public class RegisterMerchantCommandValidator : AbstractValidator +{ + public RegisterMerchantCommandValidator() + { + RuleFor(x => x.BusinessName) + .NotEmpty().WithMessage("Business name is required") + .MaximumLength(200).WithMessage("Business name cannot exceed 200 characters"); + + RuleFor(x => x.Type) + .NotEmpty().WithMessage("Merchant type is required") + .Must(BeValidMerchantType).WithMessage("Invalid merchant type. Valid values: Individual, Company"); + + RuleFor(x => x.TaxId) + .MaximumLength(20).WithMessage("Tax ID cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.TaxId)); + + RuleFor(x => x.BusinessLicenseNumber) + .MaximumLength(50).WithMessage("Business license number cannot exceed 50 characters") + .When(x => !string.IsNullOrEmpty(x.BusinessLicenseNumber)); + } + + private static bool BeValidMerchantType(string type) + { + var validTypes = new[] { "Individual", "Company", "individual", "company" }; + return validTypes.Contains(type); + } +} + +/// +/// EN: Validator for UpdateMerchantCommand. +/// VI: Validator cho UpdateMerchantCommand. +/// +public class UpdateMerchantCommandValidator : AbstractValidator +{ + public UpdateMerchantCommandValidator() + { + RuleFor(x => x.BusinessName) + .MaximumLength(200).WithMessage("Business name cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.BusinessName)); + + RuleFor(x => x.TaxId) + .MaximumLength(20).WithMessage("Tax ID cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.TaxId)); + + RuleFor(x => x.BankCode) + .MaximumLength(10).WithMessage("Bank code cannot exceed 10 characters") + .When(x => !string.IsNullOrEmpty(x.BankCode)); + + RuleFor(x => x.BankAccountNumber) + .MaximumLength(30).WithMessage("Bank account number cannot exceed 30 characters") + .When(x => !string.IsNullOrEmpty(x.BankAccountNumber)); + + RuleFor(x => x.BankAccountHolderName) + .MaximumLength(100).WithMessage("Account holder name cannot exceed 100 characters") + .When(x => !string.IsNullOrEmpty(x.BankAccountHolderName)); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/ShopCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/ShopCommandValidators.cs new file mode 100644 index 00000000..c2069333 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/ShopCommandValidators.cs @@ -0,0 +1,100 @@ +// EN: Validators for Shop commands. +// VI: Validators cho các commands Shop. + +using FluentValidation; +using MerchantService.API.Application.Commands.Shops; + +namespace MerchantService.API.Application.Validations; + +/// +/// EN: Validator for CreateShopCommand. +/// VI: Validator cho CreateShopCommand. +/// +public class CreateShopCommandValidator : AbstractValidator +{ + public CreateShopCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Shop name is required") + .MaximumLength(100).WithMessage("Shop name cannot exceed 100 characters"); + + RuleFor(x => x.Slug) + .NotEmpty().WithMessage("Slug is required") + .MaximumLength(100).WithMessage("Slug cannot exceed 100 characters") + .Matches(@"^[a-z0-9]+(?:-[a-z0-9]+)*$").WithMessage("Slug must contain only lowercase letters, numbers, and hyphens"); + + RuleFor(x => x.Type) + .NotEmpty().WithMessage("Shop type is required") + .Must(BeValidShopType).WithMessage("Invalid shop type. Valid values: OnlineOnly, PhysicalOnly, Hybrid"); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Business category is required"); + + RuleFor(x => x.Description) + .MaximumLength(2000).WithMessage("Description cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Description)); + + RuleFor(x => x.Phone) + .MaximumLength(20).WithMessage("Phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.Phone)); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Invalid email format") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Website) + .MaximumLength(200).WithMessage("Website cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.Website)); + } + + private static bool BeValidShopType(string type) + { + var validTypes = new[] { "OnlineOnly", "PhysicalOnly", "Hybrid", "onlineonly", "physicalonly", "hybrid" }; + return validTypes.Contains(type); + } +} + +/// +/// EN: Validator for AddShopBranchCommand. +/// VI: Validator cho AddShopBranchCommand. +/// +public class AddShopBranchCommandValidator : AbstractValidator +{ + public AddShopBranchCommandValidator() + { + RuleFor(x => x.ShopId) + .NotEmpty().WithMessage("Shop ID is required"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Branch name is required") + .MaximumLength(100).WithMessage("Branch name cannot exceed 100 characters"); + + RuleFor(x => x.Code) + .MaximumLength(20).WithMessage("Branch code cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.Code)); + + RuleFor(x => x.Street) + .NotEmpty().WithMessage("Street address is required") + .MaximumLength(200).WithMessage("Street address cannot exceed 200 characters"); + + RuleFor(x => x.District) + .NotEmpty().WithMessage("District is required") + .MaximumLength(100).WithMessage("District cannot exceed 100 characters"); + + RuleFor(x => x.City) + .NotEmpty().WithMessage("City is required") + .MaximumLength(100).WithMessage("City cannot exceed 100 characters"); + + RuleFor(x => x.Latitude) + .InclusiveBetween(-90, 90).WithMessage("Latitude must be between -90 and 90") + .When(x => x.Latitude.HasValue); + + RuleFor(x => x.Longitude) + .InclusiveBetween(-180, 180).WithMessage("Longitude must be between -180 and 180") + .When(x => x.Longitude.HasValue); + + RuleFor(x => x.Phone) + .MaximumLength(20).WithMessage("Phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.Phone)); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs new file mode 100644 index 00000000..1fa7cd4a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/MerchantsController.cs @@ -0,0 +1,136 @@ +// EN: Merchants Controller for merchant management. +// VI: Controller Merchants để quản lý merchant. + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MerchantService.API.Application.Commands.Merchants; +using MerchantService.API.Application.Queries.Merchants; + +namespace MerchantService.API.Controllers; + +/// +/// EN: Controller for merchant management. +/// VI: Controller để quản lý merchant. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class MerchantsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public MerchantsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get current merchant's profile. + /// VI: Lấy profile của merchant hiện tại. + /// + [HttpGet("profile")] + [ProducesResponseType(typeof(MerchantProfileDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProfile() + { + var result = await _mediator.Send(new GetMerchantProfileQuery()); + if (result == null) + { + return NotFound(new { message = "Merchant profile not found. Please register first." }); + } + return Ok(result); + } + + /// + /// EN: Register as a new merchant. + /// VI: Đăng ký làm merchant mới. + /// + [HttpPost("register")] + [ProducesResponseType(typeof(RegisterMerchantResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Register([FromBody] RegisterMerchantCommand command) + { + try + { + var result = await _mediator.Send(command); + _logger.LogInformation("Merchant registered: {MerchantId}", result.MerchantId); + return CreatedAtAction(nameof(GetProfile), result); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("already has")) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// EN: Update merchant information. + /// VI: Cập nhật thông tin merchant. + /// + [HttpPut("profile")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateProfile([FromBody] UpdateMerchantCommand command) + { + await _mediator.Send(command); + return Ok(new { message = "Profile updated successfully" }); + } + + /// + /// EN: Submit for verification. + /// VI: Nộp hồ sơ xác minh. + /// + [HttpPost("verification/submit")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SubmitVerification() + { + await _mediator.Send(new SubmitMerchantVerificationCommand()); + return Ok(new { message = "Verification submitted successfully. Please wait for admin approval." }); + } +} + +/// +/// EN: Command to submit merchant for verification. +/// VI: Command để nộp merchant xác minh. +/// +public record SubmitMerchantVerificationCommand : IRequest; + +/// +/// EN: Handler for SubmitMerchantVerificationCommand. +/// VI: Handler cho SubmitMerchantVerificationCommand. +/// +public class SubmitMerchantVerificationCommandHandler : IRequestHandler +{ + private readonly Domain.AggregatesModel.MerchantAggregate.IMerchantRepository _merchantRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public SubmitMerchantVerificationCommandHandler( + Domain.AggregatesModel.MerchantAggregate.IMerchantRepository merchantRepository, + IHttpContextAccessor httpContextAccessor) + { + _merchantRepository = merchantRepository; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(SubmitMerchantVerificationCommand request, CancellationToken cancellationToken) + { + var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value + ?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + throw new Domain.Exceptions.DomainException("User not authenticated"); + + var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken) + ?? throw new Domain.Exceptions.DomainException("Merchant not found"); + + merchant.SubmitForVerification(); + _merchantRepository.Update(merchant); + await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + return true; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs new file mode 100644 index 00000000..6d3fa97f --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/ShopsController.cs @@ -0,0 +1,182 @@ +// EN: Shops Controller for shop management. +// VI: Controller Shops để quản lý shop. + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MerchantService.API.Application.Commands.Shops; +using MerchantService.API.Application.Queries.Shops; + +namespace MerchantService.API.Controllers; + +/// +/// EN: Controller for shop management. +/// VI: Controller để quản lý shop. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class ShopsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public ShopsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get current merchant's shops. + /// VI: Lấy danh sách shops của merchant hiện tại. + /// + [HttpGet("my-shops")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task GetMyShops() + { + var result = await _mediator.Send(new GetMyShopsQuery()); + return Ok(result); + } + + /// + /// EN: Get shop by ID. + /// VI: Lấy shop theo ID. + /// + [HttpGet("{shopId:guid}")] + [AllowAnonymous] + [ProducesResponseType(typeof(ShopDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid shopId) + { + var result = await _mediator.Send(new GetShopByIdQuery(shopId)); + if (result == null) + { + return NotFound(new { message = "Shop not found" }); + } + return Ok(result); + } + + /// + /// EN: Get shop by slug. + /// VI: Lấy shop theo slug. + /// + [HttpGet("slug/{slug}")] + [AllowAnonymous] + [ProducesResponseType(typeof(ShopDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetBySlug(string slug) + { + var result = await _mediator.Send(new GetShopBySlugQuery(slug)); + if (result == null) + { + return NotFound(new { message = "Shop not found" }); + } + return Ok(result); + } + + /// + /// EN: Create a new shop. + /// VI: Tạo shop mới. + /// + [HttpPost] + [ProducesResponseType(typeof(CreateShopResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] CreateShopCommand command) + { + try + { + var result = await _mediator.Send(command); + _logger.LogInformation("Shop created: {ShopId}", result.ShopId); + return CreatedAtAction(nameof(GetById), new { shopId = result.ShopId }, result); + } + catch (Domain.Exceptions.DomainException ex) when (ex.Message.Contains("already taken")) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// EN: Publish a shop (make visible to customers). + /// VI: Công khai shop (hiển thị với khách hàng). + /// + [HttpPost("{shopId:guid}/publish")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Publish(Guid shopId) + { + await _mediator.Send(new PublishShopCommand(shopId)); + return Ok(new { message = "Shop published successfully" }); + } + + /// + /// EN: Set shop as inactive. + /// VI: Đặt shop thành không hoạt động. + /// + [HttpPost("{shopId:guid}/deactivate")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Deactivate(Guid shopId) + { + await _mediator.Send(new SetShopInactiveCommand(shopId)); + return Ok(new { message = "Shop deactivated" }); + } + + /// + /// EN: Close shop permanently. + /// VI: Đóng cửa shop vĩnh viễn. + /// + [HttpPost("{shopId:guid}/close")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Close(Guid shopId) + { + await _mediator.Send(new CloseShopCommand(shopId)); + return Ok(new { message = "Shop closed permanently" }); + } + + /// + /// EN: Add a branch to a shop. + /// VI: Thêm chi nhánh vào shop. + /// + [HttpPost("{shopId:guid}/branches")] + [ProducesResponseType(typeof(AddShopBranchResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task AddBranch(Guid shopId, [FromBody] AddBranchRequest request) + { + var command = new AddShopBranchCommand + { + ShopId = shopId, + Name = request.Name, + Code = request.Code, + Street = request.Street, + Ward = request.Ward, + District = request.District, + City = request.City, + Province = request.Province, + Latitude = request.Latitude, + Longitude = request.Longitude, + Phone = request.Phone + }; + + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetById), new { shopId }, result); + } +} + +/// +/// EN: Request model for adding a branch. +/// VI: Model request để thêm chi nhánh. +/// +public record AddBranchRequest +{ + public string Name { get; init; } = null!; + public string? Code { get; init; } + public string Street { get; init; } = null!; + public string? Ward { get; init; } + public string District { get; init; } = null!; + public string City { get; init; } = null!; + public string? Province { get; init; } + public double? Latitude { get; init; } + public double? Longitude { get; init; } + public string? Phone { get; init; } +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Domain/FileShareTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Domain/FileShareTests.cs new file mode 100644 index 00000000..e87b0219 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/Domain/FileShareTests.cs @@ -0,0 +1,390 @@ +using FluentAssertions; +using StorageService.Domain.AggregatesModel.FileShareAggregate; + +namespace StorageService.UnitTests.Domain; + +/// +/// EN: Tests for FileShare aggregate root. +/// VI: Kiểm thử cho aggregate root FileShare. +/// +public class FileShareTests +{ + private static readonly Guid ValidFileId = Guid.NewGuid(); + private const string ValidSharedBy = "user-123"; + private const SharePermission ValidPermission = SharePermission.Read; + + #region Constructor Tests + + [Fact] + public void Constructor_ValidParams_GeneratesUniqueShareToken() + { + // Arrange & Act + var share1 = CreateValidFileShare(); + var share2 = CreateValidFileShare(); + + // Assert + share1.ShareToken.Should().NotBeNullOrEmpty(); + share2.ShareToken.Should().NotBeNullOrEmpty(); + share1.ShareToken.Should().NotBe(share2.ShareToken); + } + + [Fact] + public void Constructor_ValidParams_SetsCorrectProperties() + { + // Arrange & Act + var expiresAt = DateTime.UtcNow.AddDays(7); + var share = new FileShare( + ValidFileId, + ValidSharedBy, + SharePermission.ReadWrite, + sharedWith: "user-456", + password: null, + expiresAt: expiresAt, + maxDownloads: 10); + + // Assert + share.FileId.Should().Be(ValidFileId); + share.SharedBy.Should().Be(ValidSharedBy); + share.SharedWith.Should().Be("user-456"); + share.Permission.Should().Be(SharePermission.ReadWrite); + share.ExpiresAt.Should().Be(expiresAt); + share.MaxDownloads.Should().Be(10); + share.DownloadCount.Should().Be(0); + share.Status.Should().Be(FileShareStatus.Active); + share.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Constructor_WithPassword_HashesPassword() + { + // Arrange + var password = "SecureP@ssw0rd!"; + + // Act + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + password: password); + + // Assert + share.PasswordHash.Should().NotBeNullOrEmpty(); + share.PasswordHash.Should().NotBe(password); // Ensure hashed + share.PasswordHash!.Length.Should().BeGreaterThan(password.Length); // Hash includes salt + } + + [Fact] + public void Constructor_WithoutPassword_PasswordHashIsNull() + { + // Arrange & Act + var share = CreateValidFileShare(); + + // Assert + share.PasswordHash.Should().BeNull(); + } + + #endregion + + #region IsValid Tests + + [Fact] + public void IsValid_ActiveShare_ReturnsTrue() + { + // Arrange + var share = CreateValidFileShare(); + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeTrue(); + share.Status.Should().Be(FileShareStatus.Active); + } + + [Fact] + public void IsValid_RevokedShare_ReturnsFalse() + { + // Arrange + var share = CreateValidFileShare(); + share.Revoke(); + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeFalse(); + share.Status.Should().Be(FileShareStatus.Revoked); + } + + [Fact] + public void IsValid_ExpiredShare_ReturnsFalseAndUpdatesStatus() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + expiresAt: DateTime.UtcNow.AddMilliseconds(-100)); // Already expired + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeFalse(); + share.Status.Should().Be(FileShareStatus.Expired); + } + + [Fact] + public void IsValid_LimitReached_ReturnsFalseAndUpdatesStatus() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + maxDownloads: 2); + + // Simulate reaching limit + share.IncrementDownloadCount(); + share.IncrementDownloadCount(); + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeFalse(); + share.Status.Should().Be(FileShareStatus.LimitReached); + } + + [Fact] + public void IsValid_WithFutureExpiration_ReturnsTrue() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + expiresAt: DateTime.UtcNow.AddDays(7)); + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsValid_UnderDownloadLimit_ReturnsTrue() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + maxDownloads: 5); + share.IncrementDownloadCount(); // 1/5 + + // Act + var result = share.IsValid(); + + // Assert + result.Should().BeTrue(); + share.DownloadCount.Should().Be(1); + } + + #endregion + + #region ValidatePassword Tests + + [Fact] + public void ValidatePassword_NoPasswordRequired_ReturnsTrue() + { + // Arrange + var share = CreateValidFileShare(); // No password + + // Act + var result = share.ValidatePassword(null); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ValidatePassword_CorrectPassword_ReturnsTrue() + { + // Arrange + var password = "MySecretP@ss123"; + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + password: password); + + // Act + var result = share.ValidatePassword(password); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ValidatePassword_WrongPassword_ReturnsFalse() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + password: "CorrectPassword"); + + // Act + var result = share.ValidatePassword("WrongPassword"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ValidatePassword_NullPasswordWhenRequired_ReturnsFalse() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + password: "SomePassword"); + + // Act + var result = share.ValidatePassword(null); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ValidatePassword_EmptyPasswordWhenRequired_ReturnsFalse() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + password: "SomePassword"); + + // Act + var result = share.ValidatePassword(string.Empty); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + #region IncrementDownloadCount Tests + + [Fact] + public void IncrementDownloadCount_UnderLimit_IncreasesCount() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + maxDownloads: 10); + + // Act + share.IncrementDownloadCount(); + share.IncrementDownloadCount(); + share.IncrementDownloadCount(); + + // Assert + share.DownloadCount.Should().Be(3); + share.Status.Should().Be(FileShareStatus.Active); + } + + [Fact] + public void IncrementDownloadCount_ReachesLimit_UpdatesStatus() + { + // Arrange + var share = new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission, + maxDownloads: 2); + + // Act + share.IncrementDownloadCount(); // 1 + share.IncrementDownloadCount(); // 2 - limit reached + + // Assert + share.DownloadCount.Should().Be(2); + share.Status.Should().Be(FileShareStatus.LimitReached); + } + + [Fact] + public void IncrementDownloadCount_NoLimit_ContinuesIncrementing() + { + // Arrange + var share = CreateValidFileShare(); // No max downloads + + // Act + for (int i = 0; i < 100; i++) + { + share.IncrementDownloadCount(); + } + + // Assert + share.DownloadCount.Should().Be(100); + share.Status.Should().Be(FileShareStatus.Active); + } + + #endregion + + #region Revoke Tests + + [Fact] + public void Revoke_SetsStatusAndRevokedAt() + { + // Arrange + var share = CreateValidFileShare(); + + // Act + share.Revoke(); + + // Assert + share.Status.Should().Be(FileShareStatus.Revoked); + share.RevokedAt.Should().NotBeNull(); + share.RevokedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Revoke_AlreadyRevoked_UpdatesRevokedAt() + { + // Arrange + var share = CreateValidFileShare(); + share.Revoke(); + var firstRevokedAt = share.RevokedAt; + + // Act + Thread.Sleep(10); + share.Revoke(); + + // Assert + share.Status.Should().Be(FileShareStatus.Revoked); + // Note: Current implementation updates RevokedAt each time + } + + #endregion + + #region Helper Methods + + private static FileShare CreateValidFileShare() + { + return new FileShare( + ValidFileId, + ValidSharedBy, + ValidPermission); + } + + #endregion +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Domain/FolderTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Domain/FolderTests.cs new file mode 100644 index 00000000..ec6fb320 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/Domain/FolderTests.cs @@ -0,0 +1,352 @@ +using FluentAssertions; +using StorageService.Domain.AggregatesModel.FolderAggregate; + +namespace StorageService.UnitTests.Domain; + +/// +/// EN: Tests for Folder aggregate root. +/// VI: Kiểm thử cho aggregate root Folder. +/// +public class FolderTests +{ + private const string ValidUserId = "user-123"; + private const string ValidFolderName = "Documents"; + + #region CreateRoot Tests + + [Fact] + public void CreateRoot_ValidParams_CreatesRootFolder() + { + // Arrange & Act + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Assert + folder.Should().NotBeNull(); + folder.Id.Should().NotBeEmpty(); + folder.UserId.Should().Be(ValidUserId); + folder.Name.Should().Be(ValidFolderName); + folder.IsDeleted.Should().BeFalse(); + } + + [Fact] + public void CreateRoot_SetsLevelToZero() + { + // Arrange & Act + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Assert + folder.Level.Should().Be(0); + } + + [Fact] + public void CreateRoot_SetsPathCorrectly() + { + // Arrange & Act + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Assert + folder.Path.Should().Be($"/{ValidFolderName}/"); + } + + [Fact] + public void CreateRoot_SetsParentIdToNull() + { + // Arrange & Act + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Assert + folder.ParentId.Should().BeNull(); + } + + [Fact] + public void CreateRoot_SetsCreatedAtAndUpdatedAt() + { + // Arrange & Act + var beforeCreate = DateTime.UtcNow; + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Assert + folder.CreatedAt.Should().BeOnOrAfter(beforeCreate); + folder.UpdatedAt.Should().BeOnOrAfter(beforeCreate); + folder.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + #endregion + + #region CreateChild Tests + + [Fact] + public void CreateChild_InheritsUserId() + { + // Arrange + var parentFolder = Folder.CreateRoot(ValidUserId, "Parent"); + + // Act + var childFolder = parentFolder.CreateChild("Child"); + + // Assert + childFolder.UserId.Should().Be(ValidUserId); + } + + [Fact] + public void CreateChild_SetsParentId() + { + // Arrange + var parentFolder = Folder.CreateRoot(ValidUserId, "Parent"); + + // Act + var childFolder = parentFolder.CreateChild("Child"); + + // Assert + childFolder.ParentId.Should().Be(parentFolder.Id); + } + + [Fact] + public void CreateChild_IncrementsLevel() + { + // Arrange + var rootFolder = Folder.CreateRoot(ValidUserId, "Root"); + + // Act + var level1 = rootFolder.CreateChild("Level1"); + var level2 = level1.CreateChild("Level2"); + var level3 = level2.CreateChild("Level3"); + + // Assert + rootFolder.Level.Should().Be(0); + level1.Level.Should().Be(1); + level2.Level.Should().Be(2); + level3.Level.Should().Be(3); + } + + [Fact] + public void CreateChild_AppendsToPath() + { + // Arrange + var rootFolder = Folder.CreateRoot(ValidUserId, "Root"); + + // Act + var childFolder = rootFolder.CreateChild("Child"); + var grandchildFolder = childFolder.CreateChild("Grandchild"); + + // Assert + rootFolder.Path.Should().Be("/Root/"); + childFolder.Path.Should().Be("/Root/Child/"); + grandchildFolder.Path.Should().Be("/Root/Child/Grandchild/"); + } + + [Fact] + public void CreateChild_GeneratesUniqueId() + { + // Arrange + var parentFolder = Folder.CreateRoot(ValidUserId, "Parent"); + + // Act + var child1 = parentFolder.CreateChild("Child1"); + var child2 = parentFolder.CreateChild("Child2"); + + // Assert + child1.Id.Should().NotBe(child2.Id); + child1.Id.Should().NotBe(parentFolder.Id); + } + + #endregion + + #region Rename Tests + + [Fact] + public void Rename_ValidName_UpdatesNameAndPath() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, "OldName"); + var beforeRename = folder.UpdatedAt; + + // Act + Thread.Sleep(10); // Ensure time difference + folder.Rename("NewName"); + + // Assert + folder.Name.Should().Be("NewName"); + folder.Path.Should().Be("/NewName/"); + folder.UpdatedAt.Should().BeAfter(beforeRename); + } + + [Fact] + public void Rename_ChildFolder_UpdatesPathCorrectly() + { + // Arrange + var rootFolder = Folder.CreateRoot(ValidUserId, "Root"); + var childFolder = rootFolder.CreateChild("OldChild"); + + // Act + childFolder.Rename("NewChild"); + + // Assert + childFolder.Name.Should().Be("NewChild"); + childFolder.Path.Should().Be("/Root/NewChild/"); + } + + [Fact] + public void Rename_DeletedFolder_ThrowsException() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, "Folder"); + folder.Delete(); + + // Act + var act = () => folder.Rename("NewName"); + + // Assert + act.Should().Throw() + .WithMessage("*deleted*"); + } + + #endregion + + #region MoveTo Tests + + [Fact] + public void MoveTo_NullParent_BecomesRoot() + { + // Arrange + var rootFolder = Folder.CreateRoot(ValidUserId, "Root"); + var childFolder = rootFolder.CreateChild("Child"); + + // Act + childFolder.MoveTo(null); + + // Assert + childFolder.ParentId.Should().BeNull(); + childFolder.Level.Should().Be(0); + childFolder.Path.Should().Be("/Child/"); + } + + [Fact] + public void MoveTo_ValidParent_UpdatesHierarchy() + { + // Arrange + var folder1 = Folder.CreateRoot(ValidUserId, "Folder1"); + var folder2 = Folder.CreateRoot(ValidUserId, "Folder2"); + var childFolder = folder1.CreateChild("Child"); + + // Act + childFolder.MoveTo(folder2); + + // Assert + childFolder.ParentId.Should().Be(folder2.Id); + childFolder.Level.Should().Be(1); + childFolder.Path.Should().Be("/Folder2/Child/"); + } + + [Fact] + public void MoveTo_DifferentUser_ThrowsException() + { + // Arrange + var folder1 = Folder.CreateRoot("user-1", "Folder1"); + var folder2 = Folder.CreateRoot("user-2", "Folder2"); + + // Act + var act = () => folder1.MoveTo(folder2); + + // Assert + act.Should().Throw() + .WithMessage("*different user*"); + } + + [Fact] + public void MoveTo_DeletedFolder_ThrowsException() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, "Folder"); + var targetFolder = Folder.CreateRoot(ValidUserId, "Target"); + folder.Delete(); + + // Act + var act = () => folder.MoveTo(targetFolder); + + // Assert + act.Should().Throw() + .WithMessage("*deleted*"); + } + + [Fact] + public void MoveTo_UpdatesUpdatedAt() + { + // Arrange + var folder1 = Folder.CreateRoot(ValidUserId, "Folder1"); + var folder2 = Folder.CreateRoot(ValidUserId, "Folder2"); + var beforeMove = folder1.UpdatedAt; + + // Act + Thread.Sleep(10); + folder1.MoveTo(folder2); + + // Assert + folder1.UpdatedAt.Should().BeAfter(beforeMove); + } + + #endregion + + #region Delete Tests + + [Fact] + public void Delete_SetsIsDeletedAndDeletedAt() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + + // Act + folder.Delete(); + + // Assert + folder.IsDeleted.Should().BeTrue(); + folder.DeletedAt.Should().NotBeNull(); + folder.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Delete_AlreadyDeleted_DoesNotUpdateDeletedAt() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, ValidFolderName); + folder.Delete(); + var firstDeletedAt = folder.DeletedAt; + + // Act + Thread.Sleep(10); + folder.Delete(); + + // Assert + folder.DeletedAt.Should().Be(firstDeletedAt); + } + + [Fact] + public void Delete_RootFolder_SetsDeleted() + { + // Arrange + var folder = Folder.CreateRoot(ValidUserId, "Root"); + + // Act + folder.Delete(); + + // Assert + folder.IsDeleted.Should().BeTrue(); + } + + [Fact] + public void Delete_ChildFolder_SetsDeleted() + { + // Arrange + var rootFolder = Folder.CreateRoot(ValidUserId, "Root"); + var childFolder = rootFolder.CreateChild("Child"); + + // Act + childFolder.Delete(); + + // Assert + childFolder.IsDeleted.Should().BeTrue(); + rootFolder.IsDeleted.Should().BeFalse(); // Parent should not be affected + } + + #endregion +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Domain/StorageFileTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Domain/StorageFileTests.cs new file mode 100644 index 00000000..630e504a --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/Domain/StorageFileTests.cs @@ -0,0 +1,385 @@ +using FluentAssertions; +using StorageService.Domain.AggregatesModel.FileAggregate; + +namespace StorageService.UnitTests.Domain; + +/// +/// EN: Tests for StorageFile aggregate root. +/// VI: Kiểm thử cho aggregate root StorageFile. +/// +public class StorageFileTests +{ + private const string ValidFileName = "test-file.pdf"; + private const string ValidBucketName = "storage-bucket"; + private const string ValidObjectKey = "private/user-123/20260115/abc123_test-file.pdf"; + private const string ValidContentType = "application/pdf"; + private const long ValidFileSize = 1024 * 1024; // 1MB + private const string ValidUserId = "user-123"; + + #region Constructor Tests + + [Fact] + public void Constructor_ValidParams_CreatesFileWithCorrectProperties() + { + // Arrange & Act + var file = new StorageFile( + ValidFileName, + ValidBucketName, + ValidObjectKey, + ValidContentType, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO, + FileAccessLevel.Private, + tenantId: "tenant-1", + checksum: "abc123"); + + // Assert + file.FileName.Should().Be(ValidFileName); + file.BucketName.Should().Be(ValidBucketName); + file.ObjectKey.Should().Be(ValidObjectKey); + file.ContentType.Should().Be(ValidContentType); + file.FileSizeBytes.Should().Be(ValidFileSize); + file.UserId.Should().Be(ValidUserId); + file.Provider.Should().Be(StorageProvider.MinIO); + file.AccessLevel.Should().Be(FileAccessLevel.Private); + file.TenantId.Should().Be("tenant-1"); + file.Checksum.Should().Be("abc123"); + file.IsDeleted.Should().BeFalse(); + file.UploadedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Constructor_ValidParams_RaisesFileUploadedDomainEvent() + { + // Arrange & Act + var file = CreateValidStorageFile(); + + // Assert + file.DomainEvents.Should().ContainSingle(e => e is FileUploadedDomainEvent); + + var domainEvent = file.DomainEvents.OfType().First(); + domainEvent.FileId.Should().Be(file.Id); + domainEvent.FileName.Should().Be(ValidFileName); + domainEvent.UserId.Should().Be(ValidUserId); + domainEvent.FileSizeBytes.Should().Be(ValidFileSize); + } + + [Fact] + public void Constructor_NullFileName_ThrowsArgumentNullException() + { + // Arrange & Act + var act = () => new StorageFile( + null!, + ValidBucketName, + ValidObjectKey, + ValidContentType, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO); + + // Assert + act.Should().Throw() + .WithParameterName("fileName"); + } + + [Fact] + public void Constructor_NullBucketName_ThrowsArgumentNullException() + { + // Arrange & Act + var act = () => new StorageFile( + ValidFileName, + null!, + ValidObjectKey, + ValidContentType, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO); + + // Assert + act.Should().Throw() + .WithParameterName("bucketName"); + } + + [Fact] + public void Constructor_NullObjectKey_ThrowsArgumentNullException() + { + // Arrange & Act + var act = () => new StorageFile( + ValidFileName, + ValidBucketName, + null!, + ValidContentType, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO); + + // Assert + act.Should().Throw() + .WithParameterName("objectKey"); + } + + [Fact] + public void Constructor_NullUserId_ThrowsArgumentNullException() + { + // Arrange & Act + var act = () => new StorageFile( + ValidFileName, + ValidBucketName, + ValidObjectKey, + ValidContentType, + ValidFileSize, + null!, + StorageProvider.MinIO); + + // Assert + act.Should().Throw() + .WithParameterName("userId"); + } + + [Fact] + public void Constructor_NullContentType_SetsDefaultContentType() + { + // Arrange & Act + var file = new StorageFile( + ValidFileName, + ValidBucketName, + ValidObjectKey, + null!, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO); + + // Assert + file.ContentType.Should().Be("application/octet-stream"); + } + + #endregion + + #region MarkAccessed Tests + + [Fact] + public void MarkAccessed_UpdatesLastAccessedAt() + { + // Arrange + var file = CreateValidStorageFile(); + var beforeAccess = DateTime.UtcNow; + + // Act + file.MarkAccessed(); + + // Assert + file.LastAccessedAt.Should().NotBeNull(); + file.LastAccessedAt.Should().BeOnOrAfter(beforeAccess); + file.LastAccessedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void MarkAccessed_CalledMultipleTimes_UpdatesToLatestTime() + { + // Arrange + var file = CreateValidStorageFile(); + file.MarkAccessed(); + var firstAccess = file.LastAccessedAt; + + // Act + Thread.Sleep(10); // Small delay + file.MarkAccessed(); + + // Assert + file.LastAccessedAt.Should().BeAfter(firstAccess!.Value); + } + + #endregion + + #region UpdateAccessLevel Tests + + [Fact] + public void UpdateAccessLevel_NonDeletedFile_UpdatesAccessLevel() + { + // Arrange + var file = CreateValidStorageFile(); + file.AccessLevel.Should().Be(FileAccessLevel.Private); + + // Act + file.UpdateAccessLevel(FileAccessLevel.Public); + + // Assert + file.AccessLevel.Should().Be(FileAccessLevel.Public); + } + + [Fact] + public void UpdateAccessLevel_ToShared_UpdatesAccessLevel() + { + // Arrange + var file = CreateValidStorageFile(); + + // Act + file.UpdateAccessLevel(FileAccessLevel.Shared); + + // Assert + file.AccessLevel.Should().Be(FileAccessLevel.Shared); + } + + [Fact] + public void UpdateAccessLevel_DeletedFile_ThrowsInvalidOperationException() + { + // Arrange + var file = CreateValidStorageFile(); + file.Delete(); + + // Act + var act = () => file.UpdateAccessLevel(FileAccessLevel.Public); + + // Assert + act.Should().Throw() + .WithMessage("*deleted*"); + } + + #endregion + + #region Delete Tests + + [Fact] + public void Delete_SetsIsDeletedAndRaisesEvent() + { + // Arrange + var file = CreateValidStorageFile(); + file.ClearDomainEvents(); // Clear constructor event + + // Act + file.Delete(); + + // Assert + file.IsDeleted.Should().BeTrue(); + file.DeletedAt.Should().NotBeNull(); + file.DeletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + file.DomainEvents.Should().ContainSingle(e => e is FileDeletedDomainEvent); + + var deletedEvent = file.DomainEvents.OfType().First(); + deletedEvent.FileId.Should().Be(file.Id); + deletedEvent.UserId.Should().Be(ValidUserId); + deletedEvent.FileSizeBytes.Should().Be(ValidFileSize); + } + + [Fact] + public void Delete_AlreadyDeleted_DoesNothing() + { + // Arrange + var file = CreateValidStorageFile(); + file.Delete(); + var firstDeletedAt = file.DeletedAt; + file.ClearDomainEvents(); + + // Act + Thread.Sleep(10); + file.Delete(); + + // Assert + file.DeletedAt.Should().Be(firstDeletedAt); + file.DomainEvents.Should().BeEmpty(); // No new event raised + } + + #endregion + + #region SetExpiration Tests + + [Fact] + public void SetExpiration_FutureDate_SetsExpiresAt() + { + // Arrange + var file = CreateValidStorageFile(); + var futureDate = DateTime.UtcNow.AddDays(7); + + // Act + file.SetExpiration(futureDate); + + // Assert + file.ExpiresAt.Should().Be(futureDate); + } + + [Fact] + public void SetExpiration_PastDate_ThrowsArgumentException() + { + // Arrange + var file = CreateValidStorageFile(); + var pastDate = DateTime.UtcNow.AddHours(-1); + + // Act + var act = () => file.SetExpiration(pastDate); + + // Assert + act.Should().Throw() + .WithParameterName("expiresAt") + .WithMessage("*future*"); + } + + [Fact] + public void SetExpiration_CurrentTime_ThrowsArgumentException() + { + // Arrange + var file = CreateValidStorageFile(); + + // Act + var act = () => file.SetExpiration(DateTime.UtcNow); + + // Assert + act.Should().Throw(); + } + + #endregion + + #region UpdateFromVersion Tests + + [Fact] + public void UpdateFromVersion_NonDeletedFile_UpdatesProperties() + { + // Arrange + var file = CreateValidStorageFile(); + var newObjectKey = "private/user-123/20260115/new_version.pdf"; + var newSize = 2 * 1024 * 1024L; // 2MB + var newContentType = "application/vnd.pdf"; + + // Act + file.UpdateFromVersion(newObjectKey, newSize, newContentType); + + // Assert + file.ObjectKey.Should().Be(newObjectKey); + file.FileSizeBytes.Should().Be(newSize); + file.ContentType.Should().Be(newContentType); + } + + [Fact] + public void UpdateFromVersion_DeletedFile_ThrowsInvalidOperationException() + { + // Arrange + var file = CreateValidStorageFile(); + file.Delete(); + + // Act + var act = () => file.UpdateFromVersion("new-key", 1024, "text/plain"); + + // Assert + act.Should().Throw() + .WithMessage("*deleted*"); + } + + #endregion + + #region Helper Methods + + private static StorageFile CreateValidStorageFile() + { + return new StorageFile( + ValidFileName, + ValidBucketName, + ValidObjectKey, + ValidContentType, + ValidFileSize, + ValidUserId, + StorageProvider.MinIO, + FileAccessLevel.Private); + } + + #endregion +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj b/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj index 86811727..087f88bf 100644 --- a/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj +++ b/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj @@ -18,6 +18,7 @@ +