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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>();
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user