feat: Thêm các unit test cho iam-service-net, cập nhật Dockerfile của merchant-service-net để tăng cường bảo mật và cải thiện quy trình build, đồng thời sửa đổi các unit test hiện có trong storage-service-net.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 18:58:04 +07:00
parent 056d93d338
commit 2fef02d04a
10 changed files with 1381 additions and 142 deletions

View File

@@ -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:

View File

@@ -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;
/// <summary>
/// EN: Unit tests for RemoveGroupMemberCommandHandler.
/// VI: Unit tests cho RemoveGroupMemberCommandHandler.
/// </summary>
public class RemoveGroupMemberCommandHandlerTests
{
private readonly Mock<IGroupRepository> _groupRepositoryMock;
private readonly Mock<ILogger<RemoveGroupMemberCommandHandler>> _loggerMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly RemoveGroupMemberCommandHandler _handler;
public RemoveGroupMemberCommandHandlerTests()
{
_groupRepositoryMock = new Mock<IGroupRepository>();
_loggerMock = new Mock<ILogger<RemoveGroupMemberCommandHandler>>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_unitOfWorkMock
.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()), 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<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Group?)null);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<DomainException>()
.WithMessage("*Group*not found*");
_groupRepositoryMock.Verify(r => r.Update(It.IsAny<Group>()), 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<CancellationToken>()))
.ReturnsAsync(group);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Guid>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
var act = async () => await _handler.Handle(command, cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
}

View File

@@ -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;
/// <summary>
/// EN: Unit tests for ArchiveOrganizationCommandHandler.
/// VI: Unit tests cho ArchiveOrganizationCommandHandler.
/// </summary>
public class ArchiveOrganizationCommandHandlerTests
{
private readonly Mock<IOrganizationRepository> _organizationRepositoryMock;
private readonly Mock<ILogger<ArchiveOrganizationCommandHandler>> _loggerMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly ArchiveOrganizationCommandHandler _handler;
public ArchiveOrganizationCommandHandlerTests()
{
_organizationRepositoryMock = new Mock<IOrganizationRepository>();
_loggerMock = new Mock<ILogger<ArchiveOrganizationCommandHandler>>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_unitOfWorkMock
.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(organization);
_organizationRepositoryMock
.Setup(r => r.GetChildrenAsync(orgId, It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(organization);
_organizationRepositoryMock
.Setup(r => r.GetChildrenAsync(orgId, It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_organizationRepositoryMock.Verify(r => r.Update(organization), Times.Once);
_unitOfWorkMock.Verify(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_OrganizationNotFound_ThrowsDomainException()
{
// Arrange
var command = new ArchiveOrganizationCommand(Guid.NewGuid());
_organizationRepositoryMock
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Organization?)null);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<DomainException>()
.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<CancellationToken>()))
.ReturnsAsync(organization);
_organizationRepositoryMock
.Setup(r => r.GetChildrenAsync(orgId, It.IsAny<CancellationToken>()))
.ReturnsAsync([childOrg]);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<DomainException>()
.WithMessage("*child organizations*");
_organizationRepositoryMock.Verify(r => r.Update(It.IsAny<Organization>()), 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<Guid>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
var act = async () => await _handler.Handle(command, cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
}

View File

@@ -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;
/// <summary>
/// EN: Unit tests for UpdateOrganizationCommandHandler.
/// VI: Unit tests cho UpdateOrganizationCommandHandler.
/// </summary>
public class UpdateOrganizationCommandHandlerTests
{
private readonly Mock<IOrganizationRepository> _organizationRepositoryMock;
private readonly Mock<ILogger<UpdateOrganizationCommandHandler>> _loggerMock;
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly UpdateOrganizationCommandHandler _handler;
public UpdateOrganizationCommandHandlerTests()
{
_organizationRepositoryMock = new Mock<IOrganizationRepository>();
_loggerMock = new Mock<ILogger<UpdateOrganizationCommandHandler>>();
_unitOfWorkMock = new Mock<IUnitOfWork>();
_unitOfWorkMock
.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()), 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<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Organization?)null);
// Act
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<DomainException>()
.WithMessage("*not found*");
_organizationRepositoryMock.Verify(r => r.Update(It.IsAny<Organization>()), 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<CancellationToken>()))
.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<Guid>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
// Act
var act = async () => await _handler.Handle(command, cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
}

View File

@@ -0,0 +1,402 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.AccessReviewAggregate;
using IamService.Domain.Events;
namespace IamService.UnitTests.Domain.AccessControl;
/// <summary>
/// EN: Unit tests for AccessReview aggregate root.
/// VI: Unit tests cho AccessReview aggregate root.
/// </summary>
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<ArgumentException>()
.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<ArgumentException>()
.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<ArgumentException>()
.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<AccessReviewCreatedEvent>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<AccessReviewStartedEvent>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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<InvalidOperationException>()
.WithMessage("*pending items*");
}
[Fact]
public void Complete_NotActive_ThrowsInvalidOperationException()
{
// Arrange
var review = CreateDraftReview();
// Act
var act = () => review.Complete();
// Assert
act.Should().Throw<InvalidOperationException>()
.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<AccessReviewCompletedEvent>();
}
#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<InvalidOperationException>()
.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
}

View File

@@ -0,0 +1,318 @@
using Xunit;
using FluentAssertions;
using IamService.Domain.AggregatesModel.VerificationAggregate;
using IamService.Domain.Events;
namespace IamService.UnitTests.Domain.Verification;
/// <summary>
/// EN: Unit tests for IdentityVerification aggregate root.
/// VI: Unit tests cho IdentityVerification aggregate root.
/// </summary>
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<ArgumentException>()
.WithMessage("*Phone number*empty*");
}
[Fact]
public void CreatePhoneVerification_EmptyUserId_ThrowsArgumentException()
{
// Arrange & Act
var act = () => IdentityVerification.CreatePhoneVerification(Guid.Empty, "+84901234567");
// Assert
act.Should().Throw<ArgumentException>()
.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<VerificationRequestedEvent>();
}
#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<ArgumentException>()
.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<InvalidOperationException>()
.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<VerificationCompletedEvent>();
}
#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<InvalidOperationException>()
.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<InvalidOperationException>()
.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
}

View File

@@ -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"]

View File

@@ -35,7 +35,6 @@ public class DeleteFileCommandHandlerTests
public DeleteFileCommandHandlerTests()
{
// EN: Setup mocks / VI: Setup mocks
_fileRepository = Substitute.For<IFileRepository>();
_quotaRepository = Substitute.For<IQuotaRepository>();
_storageProviderFactory = Substitute.For<IStorageProviderFactory>();
@@ -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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any<CancellationToken>())
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(true);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any<CancellationToken>())
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(true);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any<CancellationToken>())
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(true);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<string>(key => key.Contains(TestFileId.ToString())),
Arg.Is<string>(key => key.Contains(file.Id.ToString())),
Arg.Any<CancellationToken>());
}
@@ -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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
file.DidNotReceive().Delete();
file.IsDeleted.Should().BeFalse();
await _storageProvider.DidNotReceive().DeleteAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
@@ -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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns(quota);
_storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any<CancellationToken>())
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false); // Storage delete fails
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_quotaRepository.GetByUserIdAsync(TestUserId, Arg.Any<CancellationToken>())
.Returns((UserStorageQuota?)null);
_storageProvider.DeleteAsync(TestBucketName, TestObjectKey, Arg.Any<CancellationToken>())
_storageProvider.DeleteAsync(TestBucketName, Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(true);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_storageProvider.DeleteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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<StorageFile>();
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<UserStorageQuota>();
return quota;
return new StorageFile(
"document.pdf",
TestBucketName,
TestObjectKey,
"application/pdf",
TestFileSize,
TestUserId,
StorageProvider.MinIO);
}
#endregion

View File

@@ -16,8 +16,8 @@ using DomainFileShare = StorageService.Domain.AggregatesModel.FileShareAggregate
namespace StorageService.UnitTests.Handlers;
/// <summary>
/// EN: Unit tests for CreateFileShareCommandHandler and RevokeFileShareCommandHandler.
/// VI: Unit tests cho CreateFileShareCommandHandler và RevokeFileShareCommandHandler.
/// EN: Unit tests for CreateFileShareCommandHandler.
/// VI: Unit tests cho CreateFileShareCommandHandler.
/// </summary>
public class FileShareCommandHandlerTests
{
@@ -34,13 +34,11 @@ public class FileShareCommandHandlerTests
public FileShareCommandHandlerTests()
{
// EN: Setup mocks / VI: Setup mocks
_fileRepository = Substitute.For<IFileRepository>();
_fileShareRepository = Substitute.For<IFileShareRepository>();
_createLogger = Substitute.For<ILogger<CreateFileShareCommandHandler>>();
_unitOfWork = Substitute.For<IUnitOfWork>();
// EN: Setup configuration / VI: Setup configuration
var configData = new Dictionary<string, string?>
{
{ "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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
@@ -105,7 +103,7 @@ public class FileShareCommandHandlerTests
// Assert
await _fileShareRepository.Received(1).AddAsync(
Arg.Is<DomainFileShare>(s =>
s.FileId == TestFileId &&
s.FileId == file.Id &&
s.SharedBy == TestUserId),
Arg.Any<CancellationToken>());
}
@@ -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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).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<CancellationToken>())
_fileRepository.GetByIdAsync(file.Id, Arg.Any<CancellationToken>())
.Returns(file);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.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<StorageFile>();
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

View File

@@ -37,7 +37,6 @@ public class UploadFileCommandHandlerTests
public UploadFileCommandHandlerTests()
{
// EN: Setup mocks / VI: Setup mocks
_fileRepository = Substitute.For<IFileRepository>();
_quotaRepository = Substitute.For<IQuotaRepository>();
_storageProviderFactory = Substitute.For<IStorageProviderFactory>();
@@ -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<CancellationToken>())
.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<CancellationToken>())
.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<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