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