diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestStatusTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestStatusTests.cs new file mode 100644 index 00000000..359b0298 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestStatusTests.cs @@ -0,0 +1,80 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; + +namespace IamService.UnitTests.Domain.AccessControl; + +/// +/// EN: Unit tests for AccessRequestStatus enumeration. +/// VI: Unit tests cho AccessRequestStatus enumeration. +/// +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); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestTests.cs new file mode 100644 index 00000000..365502e8 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessRequestTests.cs @@ -0,0 +1,488 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.AccessRequestAggregate; +using IamService.Domain.Events; + +namespace IamService.UnitTests.Domain.AccessControl; + +/// +/// EN: Unit tests for AccessRequest aggregate root. +/// VI: Unit tests cho AccessRequest aggregate root. +/// +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() + .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() + .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() + .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() + .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(); + } + + #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() + .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() + .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() + .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(); + } + + #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() + .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() + .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(); + } + + #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() + .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() + .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() + .WithMessage("*submitted request*"); + } + + #endregion +} diff --git a/services/merchant-service-net/docker-compose.yml b/services/merchant-service-net/docker-compose.yml deleted file mode 100644 index 82c22f3a..00000000 --- a/services/merchant-service-net/docker-compose.yml +++ /dev/null @@ -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 diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/SignUploadCommandHandlerTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/SignUploadCommandHandlerTests.cs index 6f5a25a3..ff2b3a1d 100644 --- a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/SignUploadCommandHandlerTests.cs +++ b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/SignUploadCommandHandlerTests.cs @@ -34,7 +34,6 @@ public class SignUploadCommandHandlerTests public SignUploadCommandHandlerTests() { - // EN: Setup mocks / VI: Setup mocks _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); _storageProvider = Substitute.For(); @@ -68,6 +67,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(expectedUrl); @@ -92,6 +93,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .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()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .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()) .Returns(quota); @@ -184,6 +190,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -213,6 +221,8 @@ public class SignUploadCommandHandlerTests var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -238,6 +248,8 @@ public class SignUploadCommandHandlerTests var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -263,6 +275,8 @@ public class SignUploadCommandHandlerTests var quota = CreateQuotaWithSpace(MaxQuotaBytes); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -283,6 +297,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -303,6 +319,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload"); @@ -328,6 +346,8 @@ public class SignUploadCommandHandlerTests _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); + _storageProvider.EnsureBucketExistsAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); _storageProvider.GetPreSignedUploadUrlAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Storage connection failed")); @@ -370,15 +390,13 @@ public class SignUploadCommandHandlerTests TestFileSize); } - private static UserStorageQuota CreateQuotaWithSpace(long availableSpace) + /// + /// EN: Create a real UserStorageQuota with available space. + /// VI: Tạo real UserStorageQuota với dung lượng khả dụng. + /// + private static UserStorageQuota CreateQuotaWithSpace(long maxStorage) { - var quota = Substitute.For(); - quota.CanUpload(Arg.Any()).Returns(callInfo => - { - var requestedSize = callInfo.Arg(); - return requestedSize <= availableSpace; - }); - return quota; + return new UserStorageQuota(TestUserId, maxStorageBytes: maxStorage); } #endregion