feat: Bổ sung unit test cho các mô hình miền AccessRequest và AccessRequestStatus trong IAM, đồng thời loại bỏ tệp docker-compose.yml của dịch vụ merchant.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 18:52:44 +07:00
parent 8783588ec4
commit 056d93d338
4 changed files with 598 additions and 42 deletions

View File

@@ -0,0 +1,80 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
namespace IamService.UnitTests.Domain.AccessControl;
/// <summary>
/// EN: Unit tests for AccessRequestStatus enumeration.
/// VI: Unit tests cho AccessRequestStatus enumeration.
/// </summary>
public class AccessRequestStatusTests
{
[Fact]
public void GetAll_Returns6Statuses()
{
// Act
var statuses = AccessRequestStatus.GetAll();
// Assert
statuses.Should().HaveCount(6);
}
[Theory]
[InlineData(1, "Draft")]
[InlineData(2, "Pending")]
[InlineData(3, "Approved")]
[InlineData(4, "Rejected")]
[InlineData(5, "Cancelled")]
[InlineData(6, "Expired")]
public void FromId_ValidId_ReturnsCorrectStatus(int id, string expectedName)
{
// Act
var status = AccessRequestStatus.FromId(id);
// Assert
status.Should().NotBeNull();
status!.Name.Should().Be(expectedName);
}
[Theory]
[InlineData(0)]
[InlineData(7)]
[InlineData(-1)]
public void FromId_InvalidId_ReturnsNull(int id)
{
// Act
var status = AccessRequestStatus.FromId(id);
// Assert
status.Should().BeNull();
}
[Theory]
[InlineData("Approved", true)]
[InlineData("Rejected", true)]
[InlineData("Cancelled", true)]
[InlineData("Expired", true)]
[InlineData("Draft", false)]
[InlineData("Pending", false)]
public void IsTerminal_ReturnsCorrectValue(string statusName, bool expectedTerminal)
{
// Arrange
var status = AccessRequestStatus.GetAll().First(s => s.Name == statusName);
// Assert
status.IsTerminal.Should().Be(expectedTerminal);
}
[Fact]
public void StaticInstances_AreCorrect()
{
// Assert
AccessRequestStatus.Draft.Id.Should().Be(1);
AccessRequestStatus.Pending.Id.Should().Be(2);
AccessRequestStatus.Approved.Id.Should().Be(3);
AccessRequestStatus.Rejected.Id.Should().Be(4);
AccessRequestStatus.Cancelled.Id.Should().Be(5);
AccessRequestStatus.Expired.Id.Should().Be(6);
}
}

View File

@@ -0,0 +1,488 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.AccessRequestAggregate;
using IamService.Domain.Events;
namespace IamService.UnitTests.Domain.AccessControl;
/// <summary>
/// EN: Unit tests for AccessRequest aggregate root.
/// VI: Unit tests cho AccessRequest aggregate root.
/// </summary>
public class AccessRequestTests
{
private readonly Guid _validRequesterId = Guid.NewGuid();
private readonly Guid _validResourceId = Guid.NewGuid();
#region Creation Tests
[Fact]
public void Create_ValidParameters_CreatesAccessRequestInDraftStatus()
{
// Arrange & Act
var request = AccessRequest.Create(
_validRequesterId,
"Project",
_validResourceId,
"read",
"Need access to project files");
// Assert
request.Should().NotBeNull();
request.Id.Should().NotBeEmpty();
request.RequesterId.Should().Be(_validRequesterId);
request.ResourceType.Should().Be("Project");
request.ResourceId.Should().Be(_validResourceId);
request.RequestedPermission.Should().Be("read");
request.Justification.Should().Be("Need access to project files");
request.Status.Should().Be(AccessRequestStatus.Draft);
request.Priority.Should().Be(AccessRequestPriority.Medium);
request.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
request.Approvers.Should().BeEmpty();
}
[Fact]
public void Create_WithCustomPriority_SetsPriority()
{
// Arrange & Act
var request = AccessRequest.Create(
_validRequesterId,
"Project",
_validResourceId,
"admin",
priority: AccessRequestPriority.High);
// Assert
request.Priority.Should().Be(AccessRequestPriority.High);
}
[Fact]
public void Create_EmptyRequesterId_ThrowsArgumentException()
{
// Arrange & Act
var act = () => AccessRequest.Create(Guid.Empty, "Project", _validResourceId, "read");
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*Requester ID*empty*");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Create_InvalidResourceType_ThrowsArgumentException(string? resourceType)
{
// Arrange & Act
var act = () => AccessRequest.Create(_validRequesterId, resourceType!, _validResourceId, "read");
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*Resource type*empty*");
}
[Fact]
public void Create_EmptyResourceId_ThrowsArgumentException()
{
// Arrange & Act
var act = () => AccessRequest.Create(_validRequesterId, "Project", Guid.Empty, "read");
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*Resource ID*empty*");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Create_InvalidPermission_ThrowsArgumentException(string? permission)
{
// Arrange & Act
var act = () => AccessRequest.Create(_validRequesterId, "Project", _validResourceId, permission!);
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*permission*empty*");
}
[Fact]
public void Create_RaisesAccessRequestCreatedEvent()
{
// Arrange & Act
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
// Assert
request.DomainEvents.Should().ContainSingle();
request.DomainEvents.First().Should().BeOfType<AccessRequestCreatedEvent>();
}
#endregion
#region AddApprover Tests
[Fact]
public void AddApprover_InDraftStatus_AddsApprover()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
// Act
var approver = request.AddApprover(approverId);
// Assert
request.Approvers.Should().HaveCount(1);
approver.UserId.Should().Be(approverId);
approver.Order.Should().Be(1);
}
[Fact]
public void AddApprover_MultipleApprovers_SetsCorrectOrder()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approver1 = Guid.NewGuid();
var approver2 = Guid.NewGuid();
// Act
request.AddApprover(approver1);
request.AddApprover(approver2);
// Assert
request.Approvers.Should().HaveCount(2);
request.Approvers.ElementAt(0).Order.Should().Be(1);
request.Approvers.ElementAt(1).Order.Should().Be(2);
}
[Fact]
public void AddApprover_AfterSubmit_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act
var act = () => request.AddApprover(Guid.NewGuid());
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*after request is submitted*");
}
#endregion
#region Submit Tests
[Fact]
public void Submit_WithApprovers_ChangeStatusToPending()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
// Act
request.Submit();
// Assert
request.Status.Should().Be(AccessRequestStatus.Pending);
request.SubmittedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
request.ExpiresAt.Should().NotBeNull();
}
[Fact]
public void Submit_WithoutApprovers_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
// Act
var act = () => request.Submit();
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*At least one approver*");
}
[Fact]
public void Submit_NotInDraft_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act - Try to submit again
var act = () => request.Submit();
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*draft requests*");
}
[Fact]
public void Submit_RaisesAccessRequestSubmittedEvent()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.ClearDomainEvents();
// Act
request.Submit();
// Assert
request.DomainEvents.Should().ContainSingle();
request.DomainEvents.First().Should().BeOfType<AccessRequestSubmittedEvent>();
}
#endregion
#region Approve Tests
[Fact]
public void Approve_SingleApprover_ChangesStatusToApproved()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
request.AddApprover(approverId);
request.Submit();
// Act
request.Approve(approverId, "Looks good");
// Assert
request.Status.Should().Be(AccessRequestStatus.Approved);
request.ResolvedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Approve_AllApprovers_ChangesStatusToApproved()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approver1 = Guid.NewGuid();
var approver2 = Guid.NewGuid();
request.AddApprover(approver1);
request.AddApprover(approver2);
request.Submit();
// Act
request.Approve(approver1);
request.Approve(approver2);
// Assert
request.Status.Should().Be(AccessRequestStatus.Approved);
}
[Fact]
public void Approve_PartialApproval_StatusRemainsPending()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approver1 = Guid.NewGuid();
var approver2 = Guid.NewGuid();
request.AddApprover(approver1);
request.AddApprover(approver2);
request.Submit();
// Act
request.Approve(approver1);
// Assert
request.Status.Should().Be(AccessRequestStatus.Pending);
}
[Fact]
public void Approve_NotPending_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
request.AddApprover(approverId);
// Not submitted yet
// Act
var act = () => request.Approve(approverId);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*pending requests*");
}
[Fact]
public void Approve_NotAnApprover_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act - Random user trying to approve
var act = () => request.Approve(Guid.NewGuid());
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*not a pending approver*");
}
#endregion
#region Reject Tests
[Fact]
public void Reject_PendingRequest_ChangesStatusToRejected()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
request.AddApprover(approverId);
request.Submit();
// Act
request.Reject(approverId, "Not justified");
// Assert
request.Status.Should().Be(AccessRequestStatus.Rejected);
request.ResolvedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Reject_RaisesAccessRequestRejectedEvent()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
request.AddApprover(approverId);
request.Submit();
request.ClearDomainEvents();
// Act
request.Reject(approverId, "Not justified");
// Assert
request.DomainEvents.Should().ContainSingle();
request.DomainEvents.First().Should().BeOfType<AccessRequestRejectedEvent>();
}
#endregion
#region Cancel Tests
[Fact]
public void Cancel_DraftRequest_ChangesStatusToCancelled()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
// Act
request.Cancel();
// Assert
request.Status.Should().Be(AccessRequestStatus.Cancelled);
}
[Fact]
public void Cancel_PendingRequest_ChangesStatusToCancelled()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act
request.Cancel();
// Assert
request.Status.Should().Be(AccessRequestStatus.Cancelled);
}
[Fact]
public void Cancel_ApprovedRequest_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
var approverId = Guid.NewGuid();
request.AddApprover(approverId);
request.Submit();
request.Approve(approverId);
// Act
var act = () => request.Cancel();
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*terminal request*");
}
#endregion
#region Expire Tests
[Fact]
public void Expire_PendingRequest_ChangesStatusToExpired()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act
request.Expire();
// Assert
request.Status.Should().Be(AccessRequestStatus.Expired);
request.ResolvedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public void Expire_NotPending_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
// Act
var act = () => request.Expire();
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*pending requests*");
}
#endregion
#region UpdateJustification Tests
[Fact]
public void UpdateJustification_InDraft_UpdatesJustification()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
// Act
request.UpdateJustification("Updated justification");
// Assert
request.Justification.Should().Be("Updated justification");
}
[Fact]
public void UpdateJustification_AfterSubmit_ThrowsInvalidOperationException()
{
// Arrange
var request = AccessRequest.Create(_validRequesterId, "Project", _validResourceId, "read");
request.AddApprover(Guid.NewGuid());
request.Submit();
// Act
var act = () => request.UpdateJustification("Updated");
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*submitted request*");
}
#endregion
}

View File

@@ -1,30 +0,0 @@
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
merchant-service-net:
build:
context: .
dockerfile: Dockerfile
container_name: merchant-service-net
ports:
- "5005:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=merchant_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
- Jwt__Authority=http://iam-service-net:8080
- Jwt__Audience=goodgo-api
- Jwt__RequireHttpsMetadata=false
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- microservices-network
restart: unless-stopped
networks:
microservices-network:
external: true

View File

@@ -34,7 +34,6 @@ public class SignUploadCommandHandlerTests
public SignUploadCommandHandlerTests()
{
// EN: Setup mocks / VI: Setup mocks
_quotaRepository = Substitute.For<IQuotaRepository>();
_storageProviderFactory = Substitute.For<IStorageProviderFactory>();
_storageProvider = Substitute.For<IStorageProvider>();
@@ -68,6 +67,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(expectedUrl);
@@ -92,6 +93,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -116,7 +119,7 @@ public class SignUploadCommandHandlerTests
TestUserId,
TestFileName,
TestContentType,
MaxFileSize + 1); // Exceeds max
MaxFileSize + 1);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
@@ -136,11 +139,13 @@ public class SignUploadCommandHandlerTests
TestUserId,
TestFileName,
TestContentType,
MaxFileSize); // Exactly at limit
MaxFileSize);
var quota = CreateQuotaWithSpace(MaxQuotaBytes);
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -161,7 +166,8 @@ public class SignUploadCommandHandlerTests
{
// Arrange
var command = CreateValidCommand();
var quota = CreateQuotaWithSpace(0); // No space left
// Create quota with no space left
var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize - 1);
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
@@ -184,6 +190,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -213,6 +221,8 @@ public class SignUploadCommandHandlerTests
var quota = CreateQuotaWithSpace(MaxQuotaBytes);
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -238,6 +248,8 @@ public class SignUploadCommandHandlerTests
var quota = CreateQuotaWithSpace(MaxQuotaBytes);
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -263,6 +275,8 @@ public class SignUploadCommandHandlerTests
var quota = CreateQuotaWithSpace(MaxQuotaBytes);
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -283,6 +297,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -303,6 +319,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns("https://example.com/upload");
@@ -328,6 +346,8 @@ public class SignUploadCommandHandlerTests
_quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.EnsureBucketExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
_storageProvider.GetPreSignedUploadUrlAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Storage connection failed"));
@@ -370,15 +390,13 @@ public class SignUploadCommandHandlerTests
TestFileSize);
}
private static UserStorageQuota CreateQuotaWithSpace(long availableSpace)
/// <summary>
/// EN: Create a real UserStorageQuota with available space.
/// VI: Tạo real UserStorageQuota với dung lượng khả dụng.
/// </summary>
private static UserStorageQuota CreateQuotaWithSpace(long maxStorage)
{
var quota = Substitute.For<UserStorageQuota>();
quota.CanUpload(Arg.Any<long>()).Returns(callInfo =>
{
var requestedSize = callInfo.Arg<long>();
return requestedSize <= availableSpace;
});
return quota;
return new UserStorageQuota(TestUserId, maxStorageBytes: maxStorage);
}
#endregion