feat: Triển khai các API quản lý cửa hàng và người bán trong MerchantService, đồng thời bổ sung các bài kiểm thử đơn vị và chức năng toàn diện cho các dịch vụ Storage, Membership và IAM.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 18:23:40 +07:00
parent 580e074145
commit 3cbf56ec36
37 changed files with 6012 additions and 11 deletions

View File

@@ -0,0 +1,164 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.GroupAggregate;
namespace IamService.UnitTests.Domain.Groups;
/// <summary>
/// EN: Unit tests for GroupMember entity.
/// VI: Kiểm thử đơn vị cho entity GroupMember.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentNullException>();
}
#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
}

View File

@@ -0,0 +1,163 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.GroupAggregate;
namespace IamService.UnitTests.Domain.Groups;
/// <summary>
/// EN: Unit tests for GroupPermission entity.
/// VI: Kiểm thử đơn vị cho entity GroupPermission.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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
}

View File

@@ -0,0 +1,144 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.GroupAggregate;
namespace IamService.UnitTests.Domain.Groups;
/// <summary>
/// EN: Unit tests for GroupRole enumeration (SmartEnum pattern).
/// VI: Kiểm thử đơn vị cho GroupRole enumeration (SmartEnum pattern).
/// </summary>
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
}

View File

@@ -0,0 +1,501 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.GroupAggregate;
using IamService.Domain.Events;
namespace IamService.UnitTests.Domain.Groups;
/// <summary>
/// EN: Comprehensive unit tests for Group aggregate root.
/// VI: Kiểm thử đơn vị toàn diện cho Group aggregate root.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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<GroupCreatedEvent>();
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<ArgumentException>()
.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<InvalidOperationException>()
.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<ArgumentException>()
.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<MemberAddedToGroupEvent>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<MemberRemovedFromGroupEvent>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<ArgumentException>()
.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<InvalidOperationException>()
.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
}

View File

@@ -0,0 +1,202 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.OrganizationAggregate;
namespace IamService.UnitTests.Domain.Organizations;
/// <summary>
/// EN: Unit tests for OrganizationSettings value object.
/// VI: Kiểm thử đơn vị cho OrganizationSettings value object.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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
}

View File

@@ -0,0 +1,118 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.OrganizationAggregate;
namespace IamService.UnitTests.Domain.Organizations;
/// <summary>
/// EN: Unit tests for OrganizationStatus enumeration (SmartEnum pattern).
/// VI: Kiểm thử đơn vị cho OrganizationStatus enumeration (SmartEnum pattern).
/// </summary>
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
}

View File

@@ -0,0 +1,438 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.OrganizationAggregate;
using IamService.Domain.Events;
namespace IamService.UnitTests.Domain.Organizations;
/// <summary>
/// EN: Comprehensive unit tests for Organization aggregate root.
/// VI: Kiểm thử đơn vị toàn diện cho Organization aggregate root.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>()
.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<OrganizationCreatedEvent>();
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<OrganizationUpdatedEvent>();
}
[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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>()
.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<InvalidOperationException>()
.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<ArgumentNullException>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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
}

View File

@@ -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;
/// <summary>
/// EN: Functional tests for LevelsController.
/// VI: Functional tests cho LevelsController.
/// </summary>
public class LevelsControllerTests : IClassFixture<CustomWebApplicationFactory>
{
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<List<LevelDefinitionDto>>();
// 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<List<LevelDefinitionDto>>();
// 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<List<LevelDefinitionDto>>();
// 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<List<LevelDefinitionDto>>();
// 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<List<LevelDefinitionDto>>();
// Assert
levels.Should().NotBeNull();
var expectedThresholds = new Dictionary<int, int>
{
{ 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<List<LevelDefinitionDto>>();
// Assert
levels.Should().NotBeNull();
var ids = levels!.Select(l => l.Id).ToList();
ids.Should().OnlyHaveUniqueItems();
}
}

View File

@@ -8,42 +8,85 @@ using Xunit;
namespace MembershipService.FunctionalTests.Controllers;
/// <summary>
/// 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.
/// </summary>
public class MembersControllerTests : IClassFixture<CustomWebApplicationFactory>
{
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<CustomWebApplicationFactory>
};
// 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<CreateMemberResult>();
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<MemberDto>();
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<MemberDto>();
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
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
@@ -38,6 +45,86 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
});
// EN: Add mock authentication
// VI: Thêm mock authentication
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
});
}
/// <summary>
/// EN: Create client with authenticated user.
/// VI: Tạo client với user đã xác thực.
/// </summary>
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;
}
/// <summary>
/// EN: Seed test data into database.
/// VI: Seed dữ liệu test vào database.
/// </summary>
public async Task SeedLevelDefinitionsAsync()
{
using var scope = Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<MembershipServiceContext>();
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();
}
}
}
/// <summary>
/// EN: Test authentication handler for functional tests.
/// VI: Test authentication handler cho functional tests.
/// </summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> 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));
}
}

View File

@@ -0,0 +1,187 @@
using FluentAssertions;
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
using Xunit;
namespace MembershipService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for ExperienceTransaction entity.
/// VI: Unit tests cho ExperienceTransaction entity.
/// </summary>
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<ArgumentException>().WithMessage("*positive*");
}
[Fact]
public void Create_WithZeroPoints_ShouldThrow()
{
// Act & Assert
var act = () => new ExperienceTransaction(Guid.NewGuid(), 0, ExperienceSource.Purchase, 1);
act.Should().Throw<ArgumentException>().WithMessage("*positive*");
}
[Fact]
public void Create_WithNullSource_ShouldThrow()
{
// Act & Assert
var act = () => new ExperienceTransaction(Guid.NewGuid(), 50, null!, 1);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Create_WithEmptyMemberId_ShouldThrow()
{
// Act & Assert
var act = () => new ExperienceTransaction(Guid.Empty, 50, ExperienceSource.Purchase, 1);
act.Should().Throw<ArgumentException>().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<ExperienceSource>(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);
}
}

View File

@@ -0,0 +1,175 @@
using FluentAssertions;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using Xunit;
namespace MembershipService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for LevelDefinition aggregate.
/// VI: Unit tests cho LevelDefinition aggregate.
/// </summary>
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<ArgumentException>().WithMessage("*greater than 0*");
}
[Fact]
public void Create_WithNegativeRequiredExp_ShouldThrow()
{
// Act & Assert
var act = () => new LevelDefinition(1, "Invalid", -100, "Invalid level");
act.Should().Throw<ArgumentException>().WithMessage("*non-negative*");
}
[Fact]
public void Create_WithEmptyName_ShouldThrow()
{
// Act & Assert
var act = () => new LevelDefinition(1, "", 0, "Invalid level");
act.Should().Throw<ArgumentException>().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<ArgumentNullException>();
}
[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<LevelDefinition>
{
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<IReadOnlyCollection<LevelBenefit>>();
}
}

View File

@@ -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;
/// <summary>
/// EN: Unit tests for AddExperienceCommandHandler.
/// VI: Unit tests cho AddExperienceCommandHandler.
/// </summary>
public class AddExperienceCommandHandlerTests
{
private readonly IMemberRepository _memberRepository;
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
private readonly IExperienceTransactionRepository _experienceTransactionRepository;
private readonly ILogger<AddExperienceCommandHandler> _logger;
private readonly AddExperienceCommandHandler _handler;
public AddExperienceCommandHandlerTests()
{
_memberRepository = Substitute.For<IMemberRepository>();
_levelDefinitionRepository = Substitute.For<ILevelDefinitionRepository>();
_experienceTransactionRepository = Substitute.For<IExperienceTransactionRepository>();
_logger = Substitute.For<ILogger<AddExperienceCommandHandler>>();
_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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(levelRules);
_memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.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<CancellationToken>());
await _levelDefinitionRepository.Received(1).GetAllActiveAsync();
_experienceTransactionRepository.Received(1).Add(Arg.Any<ExperienceTransaction>());
_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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(levelRules);
_memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns((Member?)null);
// Act & Assert
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
_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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(new List<LevelDefinition>());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(levelRules);
_memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.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<Guid>(), Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(levelRules);
_memberRepository.UnitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.Returns(true);
ExperienceTransaction? capturedTransaction = null;
_experienceTransactionRepository.Add(Arg.Do<ExperienceTransaction>(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<ExperienceTransaction>());
}
private static IReadOnlyList<LevelDefinition> CreateDefaultLevelRules()
{
return new List<LevelDefinition>
{
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")
};
}
}

View File

@@ -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;
/// <summary>
/// EN: Unit tests for GetMemberProgressQueryHandler.
/// VI: Unit tests cho GetMemberProgressQueryHandler.
/// </summary>
public class GetMemberProgressQueryHandlerTests
{
private readonly IMemberRepository _memberRepository;
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
private readonly GetMemberProgressQueryHandler _handler;
public GetMemberProgressQueryHandlerTests()
{
_memberRepository = Substitute.For<IMemberRepository>();
_levelDefinitionRepository = Substitute.For<ILevelDefinitionRepository>();
_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<CancellationToken>())
.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<CancellationToken>())
.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<CancellationToken>())
.Returns(member);
_levelDefinitionRepository.GetAllActiveAsync()
.Returns(new List<LevelDefinition>());
// 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<CancellationToken>())
.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<CancellationToken>())
.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<LevelDefinition>
{
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<CancellationToken>())
.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<LevelDefinition> CreateDefaultLevelRules()
{
return new List<LevelDefinition>
{
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")
};
}
}

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<!-- EN: Test framework / VI: Test framework -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -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;
/// <summary>
/// EN: Command to register a new merchant (shop owner).
/// VI: Command để đăng ký merchant mới (chủ shop).
/// </summary>
public record RegisterMerchantCommand : IRequest<RegisterMerchantResult>
{
/// <summary>
/// EN: Business/Company name.
/// VI: Tên doanh nghiệp/công ty.
/// </summary>
public string BusinessName { get; init; } = null!;
/// <summary>
/// EN: Merchant type (Individual or Company).
/// VI: Loại merchant (Individual hoặc Company).
/// </summary>
public string Type { get; init; } = "Individual";
/// <summary>
/// EN: Tax identification number.
/// VI: Mã số thuế.
/// </summary>
public string? TaxId { get; init; }
/// <summary>
/// EN: Business license number.
/// VI: Số giấy phép kinh doanh.
/// </summary>
public string? BusinessLicenseNumber { get; init; }
}
/// <summary>
/// EN: Result of merchant registration.
/// VI: Kết quả đăng ký merchant.
/// </summary>
public record RegisterMerchantResult(
Guid MerchantId,
string BusinessName,
string Status
);

View File

@@ -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;
/// <summary>
/// EN: Handler for registering a new merchant.
/// VI: Handler để đăng ký merchant mới.
/// </summary>
public class RegisterMerchantCommandHandler : IRequestHandler<RegisterMerchantCommand, RegisterMerchantResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<RegisterMerchantCommandHandler> _logger;
public RegisterMerchantCommandHandler(
IMerchantRepository merchantRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<RegisterMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<RegisterMerchantResult> 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
);
}
}

View File

@@ -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;
/// <summary>
/// EN: Command to update merchant information.
/// VI: Command để cập nhật thông tin merchant.
/// </summary>
public record UpdateMerchantCommand : IRequest<bool>
{
/// <summary>
/// EN: Business/Company name.
/// VI: Tên doanh nghiệp/công ty.
/// </summary>
public string? BusinessName { get; init; }
/// <summary>
/// EN: Tax identification number.
/// VI: Mã số thuế.
/// </summary>
public string? TaxId { get; init; }
/// <summary>
/// EN: Business license number.
/// VI: Số giấy phép kinh doanh.
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// EN: Company registration number.
/// VI: Số đăng ký công ty.
/// </summary>
public string? CompanyRegistrationNumber { get; init; }
/// <summary>
/// EN: Bank code for settlement.
/// VI: Mã ngân hàng để thanh toán.
/// </summary>
public string? BankCode { get; init; }
/// <summary>
/// EN: Bank name.
/// VI: Tên ngân hàng.
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// EN: Bank account number.
/// VI: Số tài khoản ngân hàng.
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// EN: Bank account holder name.
/// VI: Tên chủ tài khoản.
/// </summary>
public string? BankAccountHolderName { get; init; }
}

View File

@@ -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;
/// <summary>
/// EN: Handler for updating merchant information.
/// VI: Handler để cập nhật thông tin merchant.
/// </summary>
public class UpdateMerchantCommandHandler : IRequestHandler<UpdateMerchantCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<UpdateMerchantCommandHandler> _logger;
public UpdateMerchantCommandHandler(
IMerchantRepository merchantRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<UpdateMerchantCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -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;
/// <summary>
/// EN: Command to add a physical branch to a shop.
/// VI: Command để thêm chi nhánh vật lý vào shop.
/// </summary>
public record AddShopBranchCommand : IRequest<AddShopBranchResult>
{
/// <summary>
/// EN: Shop ID.
/// VI: ID shop.
/// </summary>
public Guid ShopId { get; init; }
/// <summary>
/// EN: Branch name.
/// VI: Tên chi nhánh.
/// </summary>
public string Name { get; init; } = null!;
/// <summary>
/// EN: Branch code (e.g., "HN01").
/// VI: Mã chi nhánh (ví dụ: "HN01").
/// </summary>
public string? Code { get; init; }
/// <summary>
/// EN: Street address.
/// VI: Địa chỉ đường phố.
/// </summary>
public string Street { get; init; } = null!;
/// <summary>
/// EN: Ward/Commune.
/// VI: Phường/Xã.
/// </summary>
public string? Ward { get; init; }
/// <summary>
/// EN: District.
/// VI: Quận/Huyện.
/// </summary>
public string District { get; init; } = null!;
/// <summary>
/// EN: City.
/// VI: Thành phố.
/// </summary>
public string City { get; init; } = null!;
/// <summary>
/// EN: Province/State.
/// VI: Tỉnh/Thành.
/// </summary>
public string? Province { get; init; }
/// <summary>
/// EN: Latitude coordinate.
/// VI: Tọa độ vĩ độ.
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// EN: Longitude coordinate.
/// VI: Tọa độ kinh độ.
/// </summary>
public double? Longitude { get; init; }
/// <summary>
/// EN: Branch phone number.
/// VI: Số điện thoại chi nhánh.
/// </summary>
public string? Phone { get; init; }
}
/// <summary>
/// EN: Result of adding a branch.
/// VI: Kết quả thêm chi nhánh.
/// </summary>
public record AddShopBranchResult(
Guid BranchId,
string Name,
string Address
);

View File

@@ -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;
/// <summary>
/// EN: Handler for adding a branch to a shop.
/// VI: Handler để thêm chi nhánh vào shop.
/// </summary>
public class AddShopBranchCommandHandler : IRequestHandler<AddShopBranchCommand, AddShopBranchResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<AddShopBranchCommandHandler> _logger;
public AddShopBranchCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<AddShopBranchCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<AddShopBranchResult> 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
);
}
}

View File

@@ -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;
/// <summary>
/// EN: Command to create a new shop.
/// VI: Command để tạo shop mới.
/// </summary>
public record CreateShopCommand : IRequest<CreateShopResult>
{
/// <summary>
/// EN: Shop name.
/// VI: Tên shop.
/// </summary>
public string Name { get; init; } = null!;
/// <summary>
/// EN: URL-friendly slug.
/// VI: Slug thân thiện URL.
/// </summary>
public string Slug { get; init; } = null!;
/// <summary>
/// EN: Shop type (OnlineOnly, PhysicalOnly, Hybrid).
/// VI: Loại shop (OnlineOnly, PhysicalOnly, Hybrid).
/// </summary>
public string Type { get; init; } = "Hybrid";
/// <summary>
/// EN: Business category.
/// VI: Ngành nghề kinh doanh.
/// </summary>
public string Category { get; init; } = "Other";
/// <summary>
/// EN: Shop description.
/// VI: Mô tả shop.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// EN: Phone number.
/// VI: Số điện thoại.
/// </summary>
public string? Phone { get; init; }
/// <summary>
/// EN: Email address.
/// VI: Địa chỉ email.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// EN: Website URL.
/// VI: URL website.
/// </summary>
public string? Website { get; init; }
}
/// <summary>
/// EN: Result of shop creation.
/// VI: Kết quả tạo shop.
/// </summary>
public record CreateShopResult(
Guid ShopId,
string Name,
string Slug,
string Status
);

View File

@@ -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;
/// <summary>
/// EN: Handler for creating a new shop.
/// VI: Handler để tạo shop mới.
/// </summary>
public class CreateShopCommandHandler : IRequestHandler<CreateShopCommand, CreateShopResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<CreateShopCommandHandler> _logger;
public CreateShopCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<CreateShopCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<CreateShopResult> 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
);
}
}

View File

@@ -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;
/// <summary>
/// EN: Handler for publishing a shop.
/// VI: Handler để công khai shop.
/// </summary>
public class PublishShopCommandHandler : IRequestHandler<PublishShopCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<PublishShopCommandHandler> _logger;
public PublishShopCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<PublishShopCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> 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);
}
}
/// <summary>
/// EN: Handler for setting a shop as inactive.
/// VI: Handler để đặt shop không hoạt động.
/// </summary>
public class SetShopInactiveCommandHandler : IRequestHandler<SetShopInactiveCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<SetShopInactiveCommandHandler> _logger;
public SetShopInactiveCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<SetShopInactiveCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for closing a shop permanently.
/// VI: Handler để đóng cửa shop vĩnh viễn.
/// </summary>
public class CloseShopCommandHandler : IRequestHandler<CloseShopCommand, bool>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<CloseShopCommandHandler> _logger;
public CloseShopCommandHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<CloseShopCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,24 @@
// EN: Command to publish a shop.
// VI: Command để công khai shop.
using MediatR;
namespace MerchantService.API.Application.Commands.Shops;
/// <summary>
/// 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).
/// </summary>
public record PublishShopCommand(Guid ShopId) : IRequest<bool>;
/// <summary>
/// EN: Command to set a shop as inactive.
/// VI: Command để đặt shop thành không hoạt động.
/// </summary>
public record SetShopInactiveCommand(Guid ShopId) : IRequest<bool>;
/// <summary>
/// EN: Command to close a shop permanently.
/// VI: Command để đóng cửa shop vĩnh viễn.
/// </summary>
public record CloseShopCommand(Guid ShopId) : IRequest<bool>;

View File

@@ -0,0 +1,64 @@
// EN: Query to get merchant profile.
// VI: Query để lấy profile merchant.
using MediatR;
namespace MerchantService.API.Application.Queries.Merchants;
/// <summary>
/// EN: Query to get the current merchant's profile.
/// VI: Query để lấy profile của merchant hiện tại.
/// </summary>
public record GetMerchantProfileQuery : IRequest<MerchantProfileDto?>;
/// <summary>
/// EN: Query to get a merchant by ID (admin).
/// VI: Query để lấy merchant theo ID (admin).
/// </summary>
public record GetMerchantByIdQuery(Guid MerchantId) : IRequest<MerchantProfileDto?>;
/// <summary>
/// EN: Merchant profile DTO.
/// VI: DTO profile merchant.
/// </summary>
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; }
}

View File

@@ -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;
/// <summary>
/// EN: Handler for getting current merchant's profile.
/// VI: Handler để lấy profile của merchant hiện tại.
/// </summary>
public class GetMerchantProfileQueryHandler : IRequestHandler<GetMerchantProfileQuery, MerchantProfileDto?>
{
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<MerchantProfileDto?> 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<SettlementCycle>(merchant.SettlementConfig.SettlementCycleId).Name,
AutoSettlement = merchant.SettlementConfig.AutoSettlement,
IsComplete = merchant.SettlementConfig.IsComplete
},
VerifiedAt = merchant.VerifiedAt,
CreatedAt = merchant.CreatedAt,
UpdatedAt = merchant.UpdatedAt,
ShopCount = shopCount
};
}
}
/// <summary>
/// EN: Handler for getting merchant by ID.
/// VI: Handler để lấy merchant theo ID.
/// </summary>
public class GetMerchantByIdQueryHandler : IRequestHandler<GetMerchantByIdQuery, MerchantProfileDto?>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
public GetMerchantByIdQueryHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
}
public async Task<MerchantProfileDto?> 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
};
}
}

View File

@@ -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;
/// <summary>
/// EN: Query to get current merchant's shops.
/// VI: Query để lấy danh sách shops của merchant hiện tại.
/// </summary>
public record GetMyShopsQuery : IRequest<IReadOnlyList<ShopDto>>;
/// <summary>
/// EN: Query to get a shop by ID.
/// VI: Query để lấy shop theo ID.
/// </summary>
public record GetShopByIdQuery(Guid ShopId) : IRequest<ShopDetailDto?>;
/// <summary>
/// EN: Query to get a shop by slug.
/// VI: Query để lấy shop theo slug.
/// </summary>
public record GetShopBySlugQuery(string Slug) : IRequest<ShopDetailDto?>;
/// <summary>
/// EN: Shop list DTO.
/// VI: DTO danh sách shop.
/// </summary>
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; }
}
/// <summary>
/// EN: Shop detail DTO.
/// VI: DTO chi tiết shop.
/// </summary>
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<ShopBranchDto> 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<string> 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; }
}

View File

@@ -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;
/// <summary>
/// EN: Handler for getting current merchant's shops.
/// VI: Handler để lấy danh sách shops của merchant hiện tại.
/// </summary>
public class GetMyShopsQueryHandler : IRequestHandler<GetMyShopsQuery, IReadOnlyList<ShopDto>>
{
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<IReadOnlyList<ShopDto>> 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();
}
}
/// <summary>
/// EN: Handler for getting shop by ID.
/// VI: Handler để lấy shop theo ID.
/// </summary>
public class GetShopByIdQueryHandler : IRequestHandler<GetShopByIdQuery, ShopDetailDto?>
{
private readonly IShopRepository _shopRepository;
public GetShopByIdQueryHandler(IShopRepository shopRepository)
{
_shopRepository = shopRepository;
}
public async Task<ShopDetailDto?> 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
};
}
}
/// <summary>
/// EN: Handler for getting shop by slug.
/// VI: Handler để lấy shop theo slug.
/// </summary>
public class GetShopBySlugQueryHandler : IRequestHandler<GetShopBySlugQuery, ShopDetailDto?>
{
private readonly IShopRepository _shopRepository;
public GetShopBySlugQueryHandler(IShopRepository shopRepository)
{
_shopRepository = shopRepository;
}
public async Task<ShopDetailDto?> 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;
}
}

View File

@@ -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;
/// <summary>
/// EN: Validator for RegisterMerchantCommand.
/// VI: Validator cho RegisterMerchantCommand.
/// </summary>
public class RegisterMerchantCommandValidator : AbstractValidator<RegisterMerchantCommand>
{
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);
}
}
/// <summary>
/// EN: Validator for UpdateMerchantCommand.
/// VI: Validator cho UpdateMerchantCommand.
/// </summary>
public class UpdateMerchantCommandValidator : AbstractValidator<UpdateMerchantCommand>
{
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));
}
}

View File

@@ -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;
/// <summary>
/// EN: Validator for CreateShopCommand.
/// VI: Validator cho CreateShopCommand.
/// </summary>
public class CreateShopCommandValidator : AbstractValidator<CreateShopCommand>
{
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);
}
}
/// <summary>
/// EN: Validator for AddShopBranchCommand.
/// VI: Validator cho AddShopBranchCommand.
/// </summary>
public class AddShopBranchCommandValidator : AbstractValidator<AddShopBranchCommand>
{
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));
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for merchant management.
/// VI: Controller để quản lý merchant.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class MerchantsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<MerchantsController> _logger;
public MerchantsController(IMediator mediator, ILogger<MerchantsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get current merchant's profile.
/// VI: Lấy profile của merchant hiện tại.
/// </summary>
[HttpGet("profile")]
[ProducesResponseType(typeof(MerchantProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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);
}
/// <summary>
/// EN: Register as a new merchant.
/// VI: Đăng ký làm merchant mới.
/// </summary>
[HttpPost("register")]
[ProducesResponseType(typeof(RegisterMerchantResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// EN: Update merchant information.
/// VI: Cập nhật thông tin merchant.
/// </summary>
[HttpPut("profile")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateMerchantCommand command)
{
await _mediator.Send(command);
return Ok(new { message = "Profile updated successfully" });
}
/// <summary>
/// EN: Submit for verification.
/// VI: Nộp hồ sơ xác minh.
/// </summary>
[HttpPost("verification/submit")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SubmitVerification()
{
await _mediator.Send(new SubmitMerchantVerificationCommand());
return Ok(new { message = "Verification submitted successfully. Please wait for admin approval." });
}
}
/// <summary>
/// EN: Command to submit merchant for verification.
/// VI: Command để nộp merchant xác minh.
/// </summary>
public record SubmitMerchantVerificationCommand : IRequest<bool>;
/// <summary>
/// EN: Handler for SubmitMerchantVerificationCommand.
/// VI: Handler cho SubmitMerchantVerificationCommand.
/// </summary>
public class SubmitMerchantVerificationCommandHandler : IRequestHandler<SubmitMerchantVerificationCommand, bool>
{
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<bool> 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;
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for shop management.
/// VI: Controller để quản lý shop.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ShopsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ShopsController> _logger;
public ShopsController(IMediator mediator, ILogger<ShopsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get current merchant's shops.
/// VI: Lấy danh sách shops của merchant hiện tại.
/// </summary>
[HttpGet("my-shops")]
[ProducesResponseType(typeof(IReadOnlyList<ShopDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMyShops()
{
var result = await _mediator.Send(new GetMyShopsQuery());
return Ok(result);
}
/// <summary>
/// EN: Get shop by ID.
/// VI: Lấy shop theo ID.
/// </summary>
[HttpGet("{shopId:guid}")]
[AllowAnonymous]
[ProducesResponseType(typeof(ShopDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid shopId)
{
var result = await _mediator.Send(new GetShopByIdQuery(shopId));
if (result == null)
{
return NotFound(new { message = "Shop not found" });
}
return Ok(result);
}
/// <summary>
/// EN: Get shop by slug.
/// VI: Lấy shop theo slug.
/// </summary>
[HttpGet("slug/{slug}")]
[AllowAnonymous]
[ProducesResponseType(typeof(ShopDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetBySlug(string slug)
{
var result = await _mediator.Send(new GetShopBySlugQuery(slug));
if (result == null)
{
return NotFound(new { message = "Shop not found" });
}
return Ok(result);
}
/// <summary>
/// EN: Create a new shop.
/// VI: Tạo shop mới.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CreateShopResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// EN: Publish a shop (make visible to customers).
/// VI: Công khai shop (hiển thị với khách hàng).
/// </summary>
[HttpPost("{shopId:guid}/publish")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Publish(Guid shopId)
{
await _mediator.Send(new PublishShopCommand(shopId));
return Ok(new { message = "Shop published successfully" });
}
/// <summary>
/// EN: Set shop as inactive.
/// VI: Đặt shop thành không hoạt động.
/// </summary>
[HttpPost("{shopId:guid}/deactivate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Deactivate(Guid shopId)
{
await _mediator.Send(new SetShopInactiveCommand(shopId));
return Ok(new { message = "Shop deactivated" });
}
/// <summary>
/// EN: Close shop permanently.
/// VI: Đóng cửa shop vĩnh viễn.
/// </summary>
[HttpPost("{shopId:guid}/close")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Close(Guid shopId)
{
await _mediator.Send(new CloseShopCommand(shopId));
return Ok(new { message = "Shop closed permanently" });
}
/// <summary>
/// EN: Add a branch to a shop.
/// VI: Thêm chi nhánh vào shop.
/// </summary>
[HttpPost("{shopId:guid}/branches")]
[ProducesResponseType(typeof(AddShopBranchResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// EN: Request model for adding a branch.
/// VI: Model request để thêm chi nhánh.
/// </summary>
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; }
}

View File

@@ -0,0 +1,390 @@
using FluentAssertions;
using StorageService.Domain.AggregatesModel.FileShareAggregate;
namespace StorageService.UnitTests.Domain;
/// <summary>
/// EN: Tests for FileShare aggregate root.
/// VI: Kiểm thử cho aggregate root FileShare.
/// </summary>
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
}

View File

@@ -0,0 +1,352 @@
using FluentAssertions;
using StorageService.Domain.AggregatesModel.FolderAggregate;
namespace StorageService.UnitTests.Domain;
/// <summary>
/// EN: Tests for Folder aggregate root.
/// VI: Kiểm thử cho aggregate root Folder.
/// </summary>
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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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
}

View File

@@ -0,0 +1,385 @@
using FluentAssertions;
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.UnitTests.Domain;
/// <summary>
/// EN: Tests for StorageFile aggregate root.
/// VI: Kiểm thử cho aggregate root StorageFile.
/// </summary>
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<FileUploadedDomainEvent>().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<ArgumentNullException>()
.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<ArgumentNullException>()
.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<ArgumentNullException>()
.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<ArgumentNullException>()
.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<InvalidOperationException>()
.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<FileDeletedDomainEvent>().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<ArgumentException>()
.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<ArgumentException>();
}
#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<InvalidOperationException>()
.WithMessage("*deleted*");
}
#endregion
#region Helper Methods
private static StorageFile CreateValidStorageFile()
{
return new StorageFile(
ValidFileName,
ValidBucketName,
ValidObjectKey,
ValidContentType,
ValidFileSize,
ValidUserId,
StorageProvider.MinIO,
FileAccessLevel.Private);
}
#endregion
}

View File

@@ -18,6 +18,7 @@
<!-- EN: Assertions and mocking / VI: Assertions và mocking -->
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<!-- EN: Coverage / VI: Coverage -->