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 @@
+