From 2fef02d04a6a5d86d18d7f37312e6dc734df26b5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 15 Jan 2026 18:58:04 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Th=C3=AAm=20c=C3=A1c=20unit=20test=20ch?= =?UTF-8?q?o=20`iam-service-net`,=20c=E1=BA=ADp=20nh=E1=BA=ADt=20Dockerfil?= =?UTF-8?q?e=20c=E1=BB=A7a=20`merchant-service-net`=20=C4=91=E1=BB=83=20t?= =?UTF-8?q?=C4=83ng=20c=C6=B0=E1=BB=9Dng=20b=E1=BA=A3o=20m=E1=BA=ADt=20v?= =?UTF-8?q?=C3=A0=20c=E1=BA=A3i=20thi=E1=BB=87n=20quy=20tr=C3=ACnh=20build?= =?UTF-8?q?,=20=C4=91=E1=BB=93ng=20th=E1=BB=9Di=20s=E1=BB=ADa=20=C4=91?= =?UTF-8?q?=E1=BB=95i=20c=C3=A1c=20unit=20test=20hi=E1=BB=87n=20c=C3=B3=20?= =?UTF-8?q?trong=20`storage-service-net`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployments/local/docker-compose.yml | 46 ++ .../RemoveGroupMemberCommandHandlerTests.cs | 148 +++++++ .../ArchiveOrganizationCommandHandlerTests.cs | 152 +++++++ .../UpdateOrganizationCommandHandlerTests.cs | 141 ++++++ .../Domain/AccessControl/AccessReviewTests.cs | 402 ++++++++++++++++++ .../Verification/IdentityVerificationTests.cs | 318 ++++++++++++++ services/merchant-service-net/Dockerfile | 75 ++-- .../Handlers/DeleteFileCommandHandlerTests.cs | 126 +++--- .../Handlers/FileShareCommandHandlerTests.cs | 94 ++-- .../Handlers/UploadFileCommandHandlerTests.cs | 21 +- 10 files changed, 1381 insertions(+), 142 deletions(-) create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Groups/RemoveGroupMemberCommandHandlerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/ArchiveOrganizationCommandHandlerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/UpdateOrganizationCommandHandlerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessReviewTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Domain/Verification/IdentityVerificationTests.cs diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 6a1fb178..ae8493b7 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -158,6 +158,52 @@ services: - "traefik.http.services.membership-service.loadbalancer.healthcheck.path=/health/live" - "traefik.http.services.membership-service.loadbalancer.healthcheck.interval=10s" + # Merchant Service .NET - Merchant & Shop Management + merchant-service-net: + build: + context: ../../services/merchant-service-net + dockerfile: Dockerfile + image: goodgo/merchant-service-net:latest + container_name: merchant-service-net-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # EN: Database - Neon PostgreSQL + # VI: Cơ sở dữ liệu - Neon PostgreSQL + - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=merchant_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require + # EN: IAM Service Communication + # VI: Giao tiếp IAM Service + - IamService__BaseUrl=http://iam-service-net:8080 + - IamService__ServiceName=merchant-service + # EN: JWT Configuration + # VI: Cấu hình JWT + - Jwt__Authority=http://iam-service-net:8080 + - Jwt__Audience=goodgo-api + - Jwt__RequireHttpsMetadata=false + ports: + - "5005:8080" + depends_on: + iam-service-net: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.merchant-service.rule=PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`)" + - "traefik.http.routers.merchant-service.entrypoints=web" + - "traefik.http.services.merchant-service.loadbalancer.server.port=8080" + - "traefik.http.services.merchant-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.merchant-service.loadbalancer.healthcheck.interval=10s" + # IAM Service .NET - Identity and Access Management (Duende IdentityServer) iam-service-net: build: diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Groups/RemoveGroupMemberCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Groups/RemoveGroupMemberCommandHandlerTests.cs new file mode 100644 index 00000000..00df56a7 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Groups/RemoveGroupMemberCommandHandlerTests.cs @@ -0,0 +1,148 @@ +using Xunit; +using Moq; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using IamService.API.Application.Commands.Groups; +using IamService.Domain.AggregatesModel.GroupAggregate; +using IamService.Domain.Exceptions; +using IamService.Domain.SeedWork; + +namespace IamService.UnitTests.Application.Commands.Groups; + +/// +/// EN: Unit tests for RemoveGroupMemberCommandHandler. +/// VI: Unit tests cho RemoveGroupMemberCommandHandler. +/// +public class RemoveGroupMemberCommandHandlerTests +{ + private readonly Mock _groupRepositoryMock; + private readonly Mock> _loggerMock; + private readonly Mock _unitOfWorkMock; + private readonly RemoveGroupMemberCommandHandler _handler; + + public RemoveGroupMemberCommandHandlerTests() + { + _groupRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _unitOfWorkMock = new Mock(); + + _unitOfWorkMock + .Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _groupRepositoryMock + .Setup(r => r.UnitOfWork) + .Returns(_unitOfWorkMock.Object); + + _handler = new RemoveGroupMemberCommandHandler( + _groupRepositoryMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task Handle_ValidCommand_RemovesMemberFromGroup() + { + // Arrange + var groupId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var group = Group.Create(Guid.NewGuid(), "Test Group"); + group.AddMember(userId); + + var command = new RemoveGroupMemberCommand(groupId, userId); + + _groupRepositoryMock + .Setup(r => r.GetByIdWithMembersAsync(groupId, It.IsAny())) + .ReturnsAsync(group); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().BeTrue(); + group.Members.Should().NotContain(m => m.UserId == userId); + } + + [Fact] + public async Task Handle_ValidCommand_PersistsChanges() + { + // Arrange + var groupId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var group = Group.Create(Guid.NewGuid(), "Test Group"); + group.AddMember(userId); + + var command = new RemoveGroupMemberCommand(groupId, userId); + + _groupRepositoryMock + .Setup(r => r.GetByIdWithMembersAsync(groupId, It.IsAny())) + .ReturnsAsync(group); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _groupRepositoryMock.Verify(r => r.Update(group), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_GroupNotFound_ThrowsDomainException() + { + // Arrange + var command = new RemoveGroupMemberCommand(Guid.NewGuid(), Guid.NewGuid()); + + _groupRepositoryMock + .Setup(r => r.GetByIdWithMembersAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Group?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Group*not found*"); + + _groupRepositoryMock.Verify(r => r.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_MemberNotInGroup_ThrowsInvalidOperationException() + { + // Arrange + var groupId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var group = Group.Create(Guid.NewGuid(), "Test Group"); + // NOT adding userId to group + + var command = new RemoveGroupMemberCommand(groupId, userId); + + _groupRepositoryMock + .Setup(r => r.GetByIdWithMembersAsync(groupId, It.IsAny())) + .ReturnsAsync(group); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not a member*"); + } + + [Fact] + public async Task Handle_CancellationRequested_PropagatesCancellation() + { + // Arrange + var command = new RemoveGroupMemberCommand(Guid.NewGuid(), Guid.NewGuid()); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _groupRepositoryMock + .Setup(r => r.GetByIdWithMembersAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // Act + var act = async () => await _handler.Handle(command, cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/ArchiveOrganizationCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/ArchiveOrganizationCommandHandlerTests.cs new file mode 100644 index 00000000..cc5f9b06 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/ArchiveOrganizationCommandHandlerTests.cs @@ -0,0 +1,152 @@ +using Xunit; +using Moq; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using IamService.API.Application.Commands.Organizations; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; +using IamService.Domain.SeedWork; + +namespace IamService.UnitTests.Application.Commands.Organizations; + +/// +/// EN: Unit tests for ArchiveOrganizationCommandHandler. +/// VI: Unit tests cho ArchiveOrganizationCommandHandler. +/// +public class ArchiveOrganizationCommandHandlerTests +{ + private readonly Mock _organizationRepositoryMock; + private readonly Mock> _loggerMock; + private readonly Mock _unitOfWorkMock; + private readonly ArchiveOrganizationCommandHandler _handler; + + public ArchiveOrganizationCommandHandlerTests() + { + _organizationRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _unitOfWorkMock = new Mock(); + + _unitOfWorkMock + .Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _organizationRepositoryMock + .Setup(r => r.UnitOfWork) + .Returns(_unitOfWorkMock.Object); + + _handler = new ArchiveOrganizationCommandHandler( + _organizationRepositoryMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task Handle_ValidCommand_ArchivesOrganization() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Test Org", "test-org"); + var command = new ArchiveOrganizationCommand(orgId); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + _organizationRepositoryMock + .Setup(r => r.GetChildrenAsync(orgId, It.IsAny())) + .ReturnsAsync([]); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().BeTrue(); + organization.Status.Should().Be(OrganizationStatus.Archived); + } + + [Fact] + public async Task Handle_ValidCommand_PersistsChanges() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Test Org", "test-org"); + var command = new ArchiveOrganizationCommand(orgId); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + _organizationRepositoryMock + .Setup(r => r.GetChildrenAsync(orgId, It.IsAny())) + .ReturnsAsync([]); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _organizationRepositoryMock.Verify(r => r.Update(organization), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_OrganizationNotFound_ThrowsDomainException() + { + // Arrange + var command = new ArchiveOrganizationCommand(Guid.NewGuid()); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Organization?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task Handle_HasChildOrganizations_ThrowsDomainException() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Parent Org", "parent-org"); + var childOrg = Organization.Create("Child Org", "child-org"); + var command = new ArchiveOrganizationCommand(orgId); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + _organizationRepositoryMock + .Setup(r => r.GetChildrenAsync(orgId, It.IsAny())) + .ReturnsAsync([childOrg]); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*child organizations*"); + + _organizationRepositoryMock.Verify(r => r.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_CancellationRequested_PropagatesCancellation() + { + // Arrange + var command = new ArchiveOrganizationCommand(Guid.NewGuid()); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // Act + var act = async () => await _handler.Handle(command, cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/UpdateOrganizationCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/UpdateOrganizationCommandHandlerTests.cs new file mode 100644 index 00000000..7c7f9da9 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Organizations/UpdateOrganizationCommandHandlerTests.cs @@ -0,0 +1,141 @@ +using Xunit; +using Moq; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using IamService.API.Application.Commands.Organizations; +using IamService.Domain.AggregatesModel.OrganizationAggregate; +using IamService.Domain.Exceptions; +using IamService.Domain.SeedWork; + +namespace IamService.UnitTests.Application.Commands.Organizations; + +/// +/// EN: Unit tests for UpdateOrganizationCommandHandler. +/// VI: Unit tests cho UpdateOrganizationCommandHandler. +/// +public class UpdateOrganizationCommandHandlerTests +{ + private readonly Mock _organizationRepositoryMock; + private readonly Mock> _loggerMock; + private readonly Mock _unitOfWorkMock; + private readonly UpdateOrganizationCommandHandler _handler; + + public UpdateOrganizationCommandHandlerTests() + { + _organizationRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _unitOfWorkMock = new Mock(); + + _unitOfWorkMock + .Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + _organizationRepositoryMock + .Setup(r => r.UnitOfWork) + .Returns(_unitOfWorkMock.Object); + + _handler = new UpdateOrganizationCommandHandler( + _organizationRepositoryMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task Handle_ValidCommand_UpdatesOrganization() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Old Name", "old-slug", "Old Description"); + + var command = new UpdateOrganizationCommand(orgId, "New Name", "New Description"); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be("New Name"); + result.Description.Should().Be("New Description"); + result.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Handle_ValidCommand_PersistsChanges() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Old Name", "old-slug"); + var command = new UpdateOrganizationCommand(orgId, "New Name", null); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _organizationRepositoryMock.Verify(r => r.Update(organization), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_OrganizationNotFound_ThrowsDomainException() + { + // Arrange + var command = new UpdateOrganizationCommand(Guid.NewGuid(), "New Name", null); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Organization?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + + _organizationRepositoryMock.Verify(r => r.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithNullDescription_UpdatesWithNullDescription() + { + // Arrange + var orgId = Guid.NewGuid(); + var organization = Organization.Create("Old Name", "old-slug", "Old Description"); + var command = new UpdateOrganizationCommand(orgId, "New Name", null); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(orgId, It.IsAny())) + .ReturnsAsync(organization); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Description.Should().BeNull(); + } + + [Fact] + public async Task Handle_CancellationRequested_PropagatesCancellation() + { + // Arrange + var command = new UpdateOrganizationCommand(Guid.NewGuid(), "Name", null); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _organizationRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // Act + var act = async () => await _handler.Handle(command, cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessReviewTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessReviewTests.cs new file mode 100644 index 00000000..49e3f2bb --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/AccessControl/AccessReviewTests.cs @@ -0,0 +1,402 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.AccessReviewAggregate; +using IamService.Domain.Events; + +namespace IamService.UnitTests.Domain.AccessControl; + +/// +/// EN: Unit tests for AccessReview aggregate root. +/// VI: Unit tests cho AccessReview aggregate root. +/// +public class AccessReviewTests +{ + private readonly Guid _validOwnerId = Guid.NewGuid(); + + #region Creation Tests + + [Fact] + public void Create_ValidParameters_CreatesAccessReviewInDraftStatus() + { + // Arrange + var dueDate = DateTime.UtcNow.AddDays(7); + + // Act + var review = AccessReview.Create( + "Q1 Access Review", + "Quarterly access certification", + _validOwnerId, + "Organization:org-123", + dueDate); + + // Assert + review.Should().NotBeNull(); + review.Id.Should().NotBeEmpty(); + review.Name.Should().Be("Q1 Access Review"); + review.Description.Should().Be("Quarterly access certification"); + review.OwnerId.Should().Be(_validOwnerId); + review.Scope.Should().Be("Organization:org-123"); + review.Status.Should().Be(AccessReviewStatus.Draft); + review.DueDate.Should().Be(dueDate); + review.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + review.Items.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_InvalidName_ThrowsArgumentException(string? name) + { + // Arrange & Act + var act = () => AccessReview.Create( + name!, + null, + _validOwnerId, + "Role:admin", + DateTime.UtcNow.AddDays(7)); + + // Assert + act.Should().Throw() + .WithMessage("*Name*empty*"); + } + + [Fact] + public void Create_EmptyOwnerId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => AccessReview.Create( + "Test Review", + null, + Guid.Empty, + "Role:admin", + DateTime.UtcNow.AddDays(7)); + + // Assert + act.Should().Throw() + .WithMessage("*Owner ID*empty*"); + } + + [Fact] + public void Create_PastDueDate_ThrowsArgumentException() + { + // Arrange & Act + var act = () => AccessReview.Create( + "Test Review", + null, + _validOwnerId, + "Role:admin", + DateTime.UtcNow.AddDays(-1)); + + // Assert + act.Should().Throw() + .WithMessage("*Due date*future*"); + } + + [Fact] + public void Create_RaisesAccessReviewCreatedEvent() + { + // Act + var review = AccessReview.Create( + "Test Review", + null, + _validOwnerId, + "Role:admin", + DateTime.UtcNow.AddDays(7)); + + // Assert + review.DomainEvents.Should().ContainSingle(); + review.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region AddItem Tests + + [Fact] + public void AddItem_InDraftStatus_AddsReviewItem() + { + // Arrange + var review = CreateDraftReview(); + var userId = Guid.NewGuid(); + var resourceId = Guid.NewGuid(); + + // Act + var item = review.AddItem(userId, "Project", resourceId, "write"); + + // Assert + review.Items.Should().HaveCount(1); + item.UserId.Should().Be(userId); + item.ResourceType.Should().Be("Project"); + item.Permission.Should().Be("write"); + } + + [Fact] + public void AddItem_AfterStart_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateDraftReview(); + review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + review.Start(); + + // Act + var act = () => review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "write"); + + // Assert + act.Should().Throw() + .WithMessage("*draft reviews*"); + } + + #endregion + + #region Start Tests + + [Fact] + public void Start_WithItems_ChangesStatusToActive() + { + // Arrange + var review = CreateDraftReview(); + review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + + // Act + review.Start(); + + // Assert + review.Status.Should().Be(AccessReviewStatus.Active); + review.StartedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Start_WithoutItems_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateDraftReview(); + + // Act + var act = () => review.Start(); + + // Assert + act.Should().Throw() + .WithMessage("*at least one item*"); + } + + [Fact] + public void Start_NotInDraft_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateDraftReview(); + review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + review.Start(); + + // Act + var act = () => review.Start(); + + // Assert + act.Should().Throw() + .WithMessage("*draft reviews*"); + } + + [Fact] + public void Start_RaisesAccessReviewStartedEvent() + { + // Arrange + var review = CreateDraftReview(); + review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + review.ClearDomainEvents(); + + // Act + review.Start(); + + // Assert + review.DomainEvents.Should().ContainSingle(); + review.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region ReviewItem Tests + + [Fact] + public void ReviewItem_Certify_SetsDecisionToCertify() + { + // Arrange + var review = CreateActiveReviewWithItem(out var item); + var reviewerId = Guid.NewGuid(); + + // Act + review.ReviewItem(item.Id, reviewerId, certify: true, "Approved access"); + + // Assert + item.Decision.Should().Be(ReviewDecision.Certify); + item.ReviewedByUserId.Should().Be(reviewerId); + } + + [Fact] + public void ReviewItem_Revoke_SetsDecisionToRevoke() + { + // Arrange + var review = CreateActiveReviewWithItem(out var item); + var reviewerId = Guid.NewGuid(); + + // Act + review.ReviewItem(item.Id, reviewerId, certify: false, "Access no longer needed"); + + // Assert + item.Decision.Should().Be(ReviewDecision.Revoke); + } + + [Fact] + public void ReviewItem_NotActive_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateDraftReview(); + review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + + // Act + var act = () => review.ReviewItem(Guid.NewGuid(), Guid.NewGuid(), true); + + // Assert + act.Should().Throw() + .WithMessage("*active reviews*"); + } + + [Fact] + public void ReviewItem_InvalidItemId_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateActiveReviewWithItem(out _); + + // Act + var act = () => review.ReviewItem(Guid.NewGuid(), Guid.NewGuid(), true); + + // Assert + act.Should().Throw() + .WithMessage("*Item not found*"); + } + + #endregion + + #region Complete Tests + + [Fact] + public void Complete_AllItemsReviewed_ChangesStatusToCompleted() + { + // Arrange + var review = CreateActiveReviewWithItem(out var item); + review.ReviewItem(item.Id, Guid.NewGuid(), true); + + // Act + review.Complete(); + + // Assert + review.Status.Should().Be(AccessReviewStatus.Completed); + review.CompletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Complete_PendingItems_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateActiveReviewWithItem(out _); + + // Act + var act = () => review.Complete(); + + // Assert + act.Should().Throw() + .WithMessage("*pending items*"); + } + + [Fact] + public void Complete_NotActive_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateDraftReview(); + + // Act + var act = () => review.Complete(); + + // Assert + act.Should().Throw() + .WithMessage("*active reviews*"); + } + + [Fact] + public void Complete_RaisesAccessReviewCompletedEvent() + { + // Arrange + var review = CreateActiveReviewWithItem(out var item); + review.ReviewItem(item.Id, Guid.NewGuid(), true); + review.ClearDomainEvents(); + + // Act + review.Complete(); + + // Assert + review.DomainEvents.Should().ContainSingle(); + review.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region Cancel Tests + + [Fact] + public void Cancel_DraftReview_ChangesStatusToCancelled() + { + // Arrange + var review = CreateDraftReview(); + + // Act + review.Cancel(); + + // Assert + review.Status.Should().Be(AccessReviewStatus.Cancelled); + } + + [Fact] + public void Cancel_ActiveReview_ChangesStatusToCancelled() + { + // Arrange + var review = CreateActiveReviewWithItem(out _); + + // Act + review.Cancel(); + + // Assert + review.Status.Should().Be(AccessReviewStatus.Cancelled); + } + + [Fact] + public void Cancel_CompletedReview_ThrowsInvalidOperationException() + { + // Arrange + var review = CreateActiveReviewWithItem(out var item); + review.ReviewItem(item.Id, Guid.NewGuid(), true); + review.Complete(); + + // Act + var act = () => review.Cancel(); + + // Assert + act.Should().Throw() + .WithMessage("*completed review*"); + } + + #endregion + + #region Helper Methods + + private AccessReview CreateDraftReview() => + AccessReview.Create("Test Review", null, _validOwnerId, "Role:admin", DateTime.UtcNow.AddDays(7)); + + private AccessReview CreateActiveReviewWithItem(out AccessReviewItem item) + { + var review = CreateDraftReview(); + item = review.AddItem(Guid.NewGuid(), "Project", Guid.NewGuid(), "read"); + review.Start(); + return review; + } + + #endregion +} diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Verification/IdentityVerificationTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Verification/IdentityVerificationTests.cs new file mode 100644 index 00000000..e2408a04 --- /dev/null +++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Verification/IdentityVerificationTests.cs @@ -0,0 +1,318 @@ +using Xunit; +using FluentAssertions; +using IamService.Domain.AggregatesModel.VerificationAggregate; +using IamService.Domain.Events; + +namespace IamService.UnitTests.Domain.Verification; + +/// +/// EN: Unit tests for IdentityVerification aggregate root. +/// VI: Unit tests cho IdentityVerification aggregate root. +/// +public class IdentityVerificationTests +{ + private readonly Guid _validUserId = Guid.NewGuid(); + + #region Phone Verification Creation Tests + + [Fact] + public void CreatePhoneVerification_ValidParameters_CreatesVerificationAndOtp() + { + // Arrange & Act + var (verification, otp) = IdentityVerification.CreatePhoneVerification( + _validUserId, "+84901234567"); + + // Assert + verification.Should().NotBeNull(); + verification.Id.Should().NotBeEmpty(); + verification.UserId.Should().Be(_validUserId); + verification.Type.Should().Be(VerificationType.Phone); + verification.Status.Should().Be(VerificationStatus.Pending); + verification.VerificationData.Should().Be("+84901234567"); + verification.VerificationCodeHash.Should().NotBeNullOrEmpty(); + verification.RequestedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + verification.ExpiresAt.Should().BeAfter(DateTime.UtcNow); + verification.AttemptCount.Should().Be(0); + otp.Should().HaveLength(6); + otp.Should().MatchRegex("^[0-9]{6}$"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CreatePhoneVerification_InvalidPhoneNumber_ThrowsArgumentException(string? phoneNumber) + { + // Arrange & Act + var act = () => IdentityVerification.CreatePhoneVerification(_validUserId, phoneNumber!); + + // Assert + act.Should().Throw() + .WithMessage("*Phone number*empty*"); + } + + [Fact] + public void CreatePhoneVerification_EmptyUserId_ThrowsArgumentException() + { + // Arrange & Act + var act = () => IdentityVerification.CreatePhoneVerification(Guid.Empty, "+84901234567"); + + // Assert + act.Should().Throw() + .WithMessage("*User ID*empty*"); + } + + [Fact] + public void CreatePhoneVerification_RaisesVerificationRequestedEvent() + { + // Arrange & Act + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Assert + verification.DomainEvents.Should().ContainSingle(); + verification.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region Document Verification Creation Tests + + [Fact] + public void CreateDocumentVerification_ValidParameters_CreatesVerification() + { + // Arrange & Act + var verification = IdentityVerification.CreateDocumentVerification( + _validUserId, + "https://storage.example.com/documents/id-card.jpg", + "ID_CARD"); + + // Assert + verification.Should().NotBeNull(); + verification.Id.Should().NotBeEmpty(); + verification.UserId.Should().Be(_validUserId); + verification.Type.Should().Be(VerificationType.Document); + verification.Status.Should().Be(VerificationStatus.InProgress); + verification.VerificationData.Should().Be("https://storage.example.com/documents/id-card.jpg"); + verification.Metadata.Should().Contain("ID_CARD"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CreateDocumentVerification_InvalidDocumentUrl_ThrowsArgumentException(string? documentUrl) + { + // Arrange & Act + var act = () => IdentityVerification.CreateDocumentVerification(_validUserId, documentUrl!); + + // Assert + act.Should().Throw() + .WithMessage("*Document URL*empty*"); + } + + #endregion + + #region VerifyCode Tests + + [Fact] + public void VerifyCode_CorrectCode_ReturnsTrue() + { + // Arrange + var (verification, otp) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Act + var result = verification.VerifyCode(otp); + + // Assert + result.Should().BeTrue(); + verification.Status.Should().Be(VerificationStatus.Verified); + verification.VerifiedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void VerifyCode_IncorrectCode_ReturnsFalse() + { + // Arrange + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Act + var result = verification.VerifyCode("000000"); + + // Assert + result.Should().BeFalse(); + verification.Status.Should().Be(VerificationStatus.Pending); + verification.AttemptCount.Should().Be(1); + } + + [Fact] + public void VerifyCode_ExceedsMaxAttempts_RejectsVerification() + { + // Arrange + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Act - Try 6 times (max is 5) + for (int i = 0; i < 6; i++) + { + verification.VerifyCode("000000"); + } + + // Assert + verification.Status.Should().Be(VerificationStatus.Rejected); + verification.RejectionReason.Should().Contain("Maximum attempts"); + } + + [Fact] + public void VerifyCode_AlreadyVerified_ThrowsInvalidOperationException() + { + // Arrange + var (verification, otp) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + verification.VerifyCode(otp); // Verify first + + // Act + var act = () => verification.VerifyCode("000000"); + + // Assert + act.Should().Throw() + .WithMessage("*final status*"); + } + + #endregion + + #region MarkAsVerified Tests + + [Fact] + public void MarkAsVerified_PendingStatus_ChangesToVerified() + { + // Arrange + var verification = IdentityVerification.CreateDocumentVerification( + _validUserId, "https://example.com/doc.jpg"); + verification.ClearDomainEvents(); + + // Act + verification.MarkAsVerified(); + + // Assert + verification.Status.Should().Be(VerificationStatus.Verified); + verification.VerifiedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void MarkAsVerified_RaisesVerificationCompletedEvent() + { + // Arrange + var verification = IdentityVerification.CreateDocumentVerification( + _validUserId, "https://example.com/doc.jpg"); + verification.ClearDomainEvents(); + + // Act + verification.MarkAsVerified(); + + // Assert + verification.DomainEvents.Should().ContainSingle(); + verification.DomainEvents.First().Should().BeOfType(); + } + + #endregion + + #region MarkAsRejected Tests + + [Fact] + public void MarkAsRejected_PendingStatus_ChangesToRejected() + { + // Arrange + var verification = IdentityVerification.CreateDocumentVerification( + _validUserId, "https://example.com/doc.jpg"); + + // Act + verification.MarkAsRejected("Document is blurry"); + + // Assert + verification.Status.Should().Be(VerificationStatus.Rejected); + verification.RejectionReason.Should().Be("Document is blurry"); + } + + [Fact] + public void MarkAsRejected_AlreadyFinal_ThrowsInvalidOperationException() + { + // Arrange + var (verification, otp) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + verification.VerifyCode(otp); + + // Act + var act = () => verification.MarkAsRejected("Some reason"); + + // Assert + act.Should().Throw() + .WithMessage("*final status*"); + } + + #endregion + + #region Cancel Tests + + [Fact] + public void Cancel_PendingStatus_ChangesToCancelled() + { + // Arrange + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Act + verification.Cancel(); + + // Assert + verification.Status.Should().Be(VerificationStatus.Cancelled); + } + + [Fact] + public void Cancel_FinalStatus_ThrowsInvalidOperationException() + { + // Arrange + var (verification, otp) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + verification.VerifyCode(otp); + + // Act + var act = () => verification.Cancel(); + + // Assert + act.Should().Throw() + .WithMessage("*final status*"); + } + + #endregion + + #region Helper Properties Tests + + [Fact] + public void IsExpired_BeforeExpiration_ReturnsFalse() + { + // Arrange + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + + // Assert + verification.IsExpired.Should().BeFalse(); + } + + [Fact] + public void CanRetry_UnderMaxAttempts_ReturnsTrue() + { + // Arrange + var (verification, _) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + verification.VerifyCode("000000"); // 1 failed attempt + + // Assert + verification.CanRetry.Should().BeTrue(); + } + + [Fact] + public void CanRetry_AfterVerified_ReturnsFalse() + { + // Arrange + var (verification, otp) = IdentityVerification.CreatePhoneVerification(_validUserId, "+84901234567"); + verification.VerifyCode(otp); + + // Assert + verification.CanRetry.Should().BeFalse(); + } + + #endregion +} diff --git a/services/merchant-service-net/Dockerfile b/services/merchant-service-net/Dockerfile index 5a69edc9..76a654eb 100644 --- a/services/merchant-service-net/Dockerfile +++ b/services/merchant-service-net/Dockerfile @@ -1,49 +1,70 @@ -# EN: Multi-stage build for MerchantService.API -# VI: Multi-stage build cho MerchantService.API - -# Stage 1: Build -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -# EN: Copy csproj files and restore dependencies -# VI: Copy các file csproj và restore dependencies +# EN: Copy solution and project files for layer caching +# VI: Sao chép solution và các file project để tận dụng layer caching +COPY ["Directory.Build.props", "./"] COPY ["src/MerchantService.API/MerchantService.API.csproj", "src/MerchantService.API/"] COPY ["src/MerchantService.Domain/MerchantService.Domain.csproj", "src/MerchantService.Domain/"] COPY ["src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj", "src/MerchantService.Infrastructure/"] +# EN: Restore dependencies +# VI: Khôi phục dependencies RUN dotnet restore "src/MerchantService.API/MerchantService.API.csproj" # EN: Copy all source code -# VI: Copy toàn bộ source code -COPY . . +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ -# EN: Build and publish -# VI: Build và publish +# EN: Build the application +# VI: Build ứng dụng WORKDIR "/src/src/MerchantService.API" RUN dotnet build "MerchantService.API.csproj" -c Release -o /app/build + +# Publish stage / Giai đoạn publish +FROM build AS publish RUN dotnet publish "MerchantService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false -# Stage 2: Runtime -FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app +# EN: Install curl for health checks +# VI: Cài đặt curl cho health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + # EN: Set environment variables -# VI: Đặt biến môi trường +# VI: Thiết lập biến môi trường ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production -# EN: Copy published files -# VI: Copy các file đã publish -COPY --from=build /app/publish . +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 -# EN: Add healthcheck -# VI: Thêm healthcheck -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl --fail http://localhost:8080/health || exit 1 - -# EN: Expose port / VI: Mở port -EXPOSE 8080 - -# EN: Run the application -# VI: Chạy ứng dụng +# EN: Start the application +# VI: Khởi động ứng dụng ENTRYPOINT ["dotnet", "MerchantService.API.dll"] diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/DeleteFileCommandHandlerTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/DeleteFileCommandHandlerTests.cs index b208182b..ffd74d93 100644 --- a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/DeleteFileCommandHandlerTests.cs +++ b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/DeleteFileCommandHandlerTests.cs @@ -35,7 +35,6 @@ public class DeleteFileCommandHandlerTests public DeleteFileCommandHandlerTests() { - // EN: Setup mocks / VI: Setup mocks _fileRepository = Substitute.For(); _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); @@ -61,15 +60,16 @@ public class DeleteFileCommandHandlerTests public async Task Handle_FileExists_SoftDeletesFile() { // Arrange - var file = CreateMockStorageFile(); - var quota = CreateMockQuota(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); + quota.AddUsage(TestFileSize); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); - _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) + _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -79,7 +79,7 @@ public class DeleteFileCommandHandlerTests // Assert result.Success.Should().BeTrue(); result.Error.Should().BeNull(); - file.Received(1).Delete(); + file.IsDeleted.Should().BeTrue(); _fileRepository.Received(1).Update(file); } @@ -87,15 +87,17 @@ public class DeleteFileCommandHandlerTests public async Task Handle_ValidDelete_UpdatesQuota() { // Arrange - var file = CreateMockStorageFile(); - var quota = CreateMockQuota(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); + quota.AddUsage(TestFileSize); + var initialUsage = quota.UsedStorageBytes; + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); - _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) + _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -103,7 +105,7 @@ public class DeleteFileCommandHandlerTests await _handler.Handle(command, CancellationToken.None); // Assert - quota.Received(1).RemoveUsage(TestFileSize); + quota.UsedStorageBytes.Should().BeLessThan(initialUsage); _quotaRepository.Received(1).Update(quota); } @@ -111,15 +113,15 @@ public class DeleteFileCommandHandlerTests public async Task Handle_ValidDelete_InvalidatesCache() { // Arrange - var file = CreateMockStorageFile(); - var quota = CreateMockQuota(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); - _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) + _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -127,10 +129,8 @@ public class DeleteFileCommandHandlerTests await _handler.Handle(command, CancellationToken.None); // Assert - // EN: Should invalidate file metadata cache - // VI: Nên invalidate file metadata cache await _cache.Received().DeleteAsync( - Arg.Is(key => key.Contains(TestFileId.ToString())), + Arg.Is(key => key.Contains(file.Id.ToString())), Arg.Any()); } @@ -180,11 +180,17 @@ public class DeleteFileCommandHandlerTests public async Task Handle_NotOwner_ReturnsPermissionError() { // Arrange - var file = CreateMockStorageFile(); - file.UserId.Returns("different-user"); // Different owner - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = new StorageFile( + "document.pdf", + TestBucketName, + TestObjectKey, + "application/pdf", + TestFileSize, + "different-user", // Different owner + StorageProvider.MinIO); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act @@ -199,18 +205,24 @@ public class DeleteFileCommandHandlerTests public async Task Handle_NotOwner_DoesNotDeleteFile() { // Arrange - var file = CreateMockStorageFile(); - file.UserId.Returns("different-user"); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = new StorageFile( + "document.pdf", + TestBucketName, + TestObjectKey, + "application/pdf", + TestFileSize, + "different-user", + StorageProvider.MinIO); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act await _handler.Handle(command, CancellationToken.None); // Assert - file.DidNotReceive().Delete(); + file.IsDeleted.Should().BeFalse(); await _storageProvider.DidNotReceive().DeleteAsync( Arg.Any(), Arg.Any(), Arg.Any()); } @@ -223,15 +235,15 @@ public class DeleteFileCommandHandlerTests public async Task Handle_StorageDeleteFails_StillSoftDeletesFile() { // Arrange - var file = CreateMockStorageFile(); - var quota = CreateMockQuota(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize * 10); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns(quota); - _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) + _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(false); // Storage delete fails _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -239,10 +251,8 @@ public class DeleteFileCommandHandlerTests var result = await _handler.Handle(command, CancellationToken.None); // Assert - // EN: Should still succeed with soft delete even if storage delete fails - // VI: Vẫn nên thành công với soft delete ngay cả khi storage delete thất bại result.Success.Should().BeTrue(); - file.Received(1).Delete(); + file.IsDeleted.Should().BeTrue(); } #endregion @@ -253,14 +263,14 @@ public class DeleteFileCommandHandlerTests public async Task Handle_QuotaNotFound_StillDeletesFile() { // Arrange - var file = CreateMockStorageFile(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any()) .Returns((UserStorageQuota?)null); - _storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any()) + _storageProvider.DeleteAsync(TestBucketName, Arg.Any(), Arg.Any()) .Returns(true); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -269,7 +279,7 @@ public class DeleteFileCommandHandlerTests // Assert result.Success.Should().BeTrue(); - file.Received(1).Delete(); + file.IsDeleted.Should().BeTrue(); } #endregion @@ -297,10 +307,10 @@ public class DeleteFileCommandHandlerTests public async Task Handle_StorageProviderThrows_ReturnsFailure() { // Arrange - var file = CreateMockStorageFile(); - var command = new DeleteFileCommand(TestFileId, TestUserId); + var file = CreateRealStorageFile(); + var command = new DeleteFileCommand(file.Id, TestUserId); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _storageProvider.DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Storage connection failed")); @@ -316,22 +326,16 @@ public class DeleteFileCommandHandlerTests #region Helper Methods - private static StorageFile CreateMockStorageFile() + private static StorageFile CreateRealStorageFile() { - var file = Substitute.For(); - file.Id.Returns(TestFileId); - file.UserId.Returns(TestUserId); - file.BucketName.Returns(TestBucketName); - file.ObjectKey.Returns(TestObjectKey); - file.FileSizeBytes.Returns(TestFileSize); - file.Provider.Returns(StorageProvider.MinIO); - return file; - } - - private static UserStorageQuota CreateMockQuota() - { - var quota = Substitute.For(); - return quota; + return new StorageFile( + "document.pdf", + TestBucketName, + TestObjectKey, + "application/pdf", + TestFileSize, + TestUserId, + StorageProvider.MinIO); } #endregion diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/FileShareCommandHandlerTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/FileShareCommandHandlerTests.cs index 13e47423..e14cda4b 100644 --- a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/FileShareCommandHandlerTests.cs +++ b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/FileShareCommandHandlerTests.cs @@ -16,8 +16,8 @@ using DomainFileShare = StorageService.Domain.AggregatesModel.FileShareAggregate namespace StorageService.UnitTests.Handlers; /// -/// EN: Unit tests for CreateFileShareCommandHandler and RevokeFileShareCommandHandler. -/// VI: Unit tests cho CreateFileShareCommandHandler và RevokeFileShareCommandHandler. +/// EN: Unit tests for CreateFileShareCommandHandler. +/// VI: Unit tests cho CreateFileShareCommandHandler. /// public class FileShareCommandHandlerTests { @@ -34,13 +34,11 @@ public class FileShareCommandHandlerTests public FileShareCommandHandlerTests() { - // EN: Setup mocks / VI: Setup mocks _fileRepository = Substitute.For(); _fileShareRepository = Substitute.For(); _createLogger = Substitute.For>(); _unitOfWork = Substitute.For(); - // EN: Setup configuration / VI: Setup configuration var configData = new Dictionary { { "App:BaseUrl", TestBaseUrl } @@ -64,13 +62,13 @@ public class FileShareCommandHandlerTests public async Task CreateShare_ValidRequest_CreatesShare() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -89,13 +87,13 @@ public class FileShareCommandHandlerTests public async Task CreateShare_ValidRequest_SavesShareToRepository() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -105,7 +103,7 @@ public class FileShareCommandHandlerTests // Assert await _fileShareRepository.Received(1).AddAsync( Arg.Is(s => - s.FileId == TestFileId && + s.FileId == file.Id && s.SharedBy == TestUserId), Arg.Any()); } @@ -114,14 +112,14 @@ public class FileShareCommandHandlerTests public async Task CreateShare_WithPassword_CreatesProtectedShare() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download, Password: "SecretP@ss123"); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -142,15 +140,15 @@ public class FileShareCommandHandlerTests public async Task CreateShare_WithExpiration_SetsExpiresAt() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var expiresAt = DateTime.UtcNow.AddDays(7); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download, ExpiresAt: expiresAt); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -171,14 +169,14 @@ public class FileShareCommandHandlerTests public async Task CreateShare_WithMaxDownloads_SetsLimit() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download, MaxDownloads: 10); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -247,15 +245,21 @@ public class FileShareCommandHandlerTests public async Task CreateShare_NotOwner_ReturnsError() { // Arrange - var file = CreateMockStorageFile(); - file.UserId.Returns("different-user"); // Different owner + var file = new StorageFile( + "document.pdf", + "bucket", + "key", + "application/pdf", + 1024, + "different-user", // Different owner + StorageProvider.MinIO); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act @@ -270,15 +274,21 @@ public class FileShareCommandHandlerTests public async Task CreateShare_NotOwner_DoesNotCreateShare() { // Arrange - var file = CreateMockStorageFile(); - file.UserId.Returns("different-user"); + var file = new StorageFile( + "document.pdf", + "bucket", + "key", + "application/pdf", + 1024, + "different-user", + StorageProvider.MinIO); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); // Act @@ -297,13 +307,13 @@ public class FileShareCommandHandlerTests public async Task CreateShare_GeneratesCorrectShareUrl() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); @@ -344,13 +354,13 @@ public class FileShareCommandHandlerTests public async Task CreateShare_SaveChangesThrows_ReturnsFailure() { // Arrange - var file = CreateMockStorageFile(); + var file = CreateRealStorageFile(); var command = new CreateFileShareCommand( - TestFileId, + file.Id, TestUserId, SharePermission.Download); - _fileRepository.GetByIdAsync(TestFileId, Arg.Any()) + _fileRepository.GetByIdAsync(file.Id, Arg.Any()) .Returns(file); _unitOfWork.SaveEntitiesAsync(Arg.Any()) .ThrowsAsync(new Exception("Save failed")); @@ -366,14 +376,16 @@ public class FileShareCommandHandlerTests #region Helper Methods - private static StorageFile CreateMockStorageFile() + private static StorageFile CreateRealStorageFile() { - var file = Substitute.For(); - file.Id.Returns(TestFileId); - file.UserId.Returns(TestUserId); - file.FileName.Returns("document.pdf"); - file.IsDeleted.Returns(false); - return file; + return new StorageFile( + "document.pdf", + "storage-bucket", + "private/user-123/20260115/abc123_document.pdf", + "application/pdf", + 1024, + TestUserId, + StorageProvider.MinIO); } #endregion diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/UploadFileCommandHandlerTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/UploadFileCommandHandlerTests.cs index 5eb4fd4c..2c2c808f 100644 --- a/services/storage-service-net/tests/StorageService.UnitTests/Handlers/UploadFileCommandHandlerTests.cs +++ b/services/storage-service-net/tests/StorageService.UnitTests/Handlers/UploadFileCommandHandlerTests.cs @@ -37,7 +37,6 @@ public class UploadFileCommandHandlerTests public UploadFileCommandHandlerTests() { - // EN: Setup mocks / VI: Setup mocks _fileRepository = Substitute.For(); _quotaRepository = Substitute.For(); _storageProviderFactory = Substitute.For(); @@ -139,7 +138,8 @@ public class UploadFileCommandHandlerTests await _handler.Handle(command, CancellationToken.None); // Assert - quota.Received(1).AddUsage(TestFileSize); + // EN: Verify quota usage was added / VI: Xác minh usage đã được thêm + quota.UsedStorageBytes.Should().Be(TestFileSize); _quotaRepository.Received(1).Update(quota); } @@ -179,7 +179,7 @@ public class UploadFileCommandHandlerTests stream, TestFileName, TestContentType, - MaxFileSize + 1, // Exceeds max + MaxFileSize + 1, TestUserId); // Act @@ -222,7 +222,8 @@ public class UploadFileCommandHandlerTests // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); - var quota = CreateQuotaWithSpace(0); // No space + // Create quota with no space + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize - 1); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); @@ -241,7 +242,7 @@ public class UploadFileCommandHandlerTests // Arrange using var stream = new MemoryStream(new byte[TestFileSize]); var command = CreateValidCommand(stream); - var quota = CreateQuotaWithSpace(0); + var quota = new UserStorageQuota(TestUserId, maxStorageBytes: TestFileSize - 1); _quotaRepository.GetOrCreateAsync(TestUserId, Arg.Any()) .Returns(quota); @@ -389,15 +390,9 @@ public class UploadFileCommandHandlerTests TestUserId); } - private static UserStorageQuota CreateQuotaWithSpace(long availableSpace) + 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