diff --git a/infra/docker/docker-compose.dev.yml b/infra/docker/docker-compose.dev.yml
index d4470694..58ddccd9 100644
--- a/infra/docker/docker-compose.dev.yml
+++ b/infra/docker/docker-compose.dev.yml
@@ -1,3 +1,6 @@
+# EN: Development Docker Compose - Shared network and volumes
+# VI: Development Docker Compose - Network và volumes dùng chung
+
version: '3.8'
networks:
@@ -7,3 +10,141 @@ networks:
volumes:
postgres_data:
redis_data:
+ minio_data:
+
+services:
+ # EN: Traefik Reverse Proxy
+ # VI: Traefik Reverse Proxy
+ traefik:
+ image: traefik:v3.0
+ container_name: traefik
+ command:
+ - "--api.insecure=true"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--providers.file.directory=/etc/traefik/dynamic"
+ - "--providers.file.watch=true"
+ - "--entrypoints.web.address=:80"
+ - "--log.level=INFO"
+ ports:
+ - "80:80"
+ - "8080:8080"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ../traefik/dynamic:/etc/traefik/dynamic:ro
+ networks:
+ - microservices-network
+
+ # EN: Redis Cache
+ # VI: Redis Cache
+ redis:
+ image: redis:7-alpine
+ container_name: redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ networks:
+ - microservices-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+
+ # EN: MinIO Object Storage
+ # VI: MinIO Object Storage
+ minio:
+ image: minio/minio:latest
+ container_name: minio
+ ports:
+ - "9000:9000"
+ - "9001:9001"
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ volumes:
+ - minio_data:/data
+ command: server /data --console-address ":9001"
+ networks:
+ - microservices-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # EN: IAM Service
+ # VI: IAM Service
+ iam-service-net:
+ build:
+ context: ../../services/iam-service-net
+ dockerfile: Dockerfile
+ container_name: iam-service-net
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ networks:
+ - microservices-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # EN: Storage Service
+ # VI: Storage Service
+ storage-service-net:
+ build:
+ context: ../../services/storage-service-net
+ dockerfile: Dockerfile
+ container_name: storage-service-net
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ networks:
+ - microservices-network
+ depends_on:
+ - minio
+ - redis
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # EN: Membership Service
+ # VI: Membership Service
+ membership-service-net:
+ build:
+ context: ../../services/membership-service-net
+ dockerfile: Dockerfile
+ container_name: membership-service-net
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ networks:
+ - microservices-network
+ depends_on:
+ - redis
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # EN: Merchant Service
+ # VI: Merchant Service
+ merchant-service-net:
+ build:
+ context: ../../services/merchant-service-net
+ dockerfile: Dockerfile
+ container_name: merchant-service-net
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ networks:
+ - microservices-network
+ depends_on:
+ - redis
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml
index c53056bb..8fb0eccd 100644
--- a/infra/traefik/dynamic/routes.yml
+++ b/infra/traefik/dynamic/routes.yml
@@ -64,6 +64,18 @@ http:
entryPoints:
- web
+ # EN: Merchant Service - Merchant & Shop Management
+ # VI: Merchant Service - Quản lý Merchant & Shop
+ merchant-service-router:
+ rule: "PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`)"
+ service: merchant-service
+ priority: 100
+ middlewares:
+ - cors
+ - secure-headers
+ entryPoints:
+ - web
+
services:
iam-service:
loadBalancer:
@@ -92,4 +104,11 @@ http:
membership-service:
loadBalancer:
servers:
- - url: "http://membership-service-net:8080"
\ No newline at end of file
+ - url: "http://membership-service-net:8080"
+
+ # EN: Merchant Service
+ # VI: Merchant Service
+ merchant-service:
+ loadBalancer:
+ servers:
+ - url: "http://merchant-service-net:8080"
\ No newline at end of file
diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/RolesControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/RolesControllerTests.cs
new file mode 100644
index 00000000..9d215f4f
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/RolesControllerTests.cs
@@ -0,0 +1,317 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+using FluentAssertions;
+using IamService.API.Application.Common;
+using IamService.API.Controllers;
+
+namespace IamService.FunctionalTests.Controllers;
+
+///
+/// EN: Functional tests for RolesController endpoints.
+/// VI: Functional tests cho các endpoints của RolesController.
+///
+public class RolesControllerTests : IClassFixture
+{
+ private readonly HttpClient _client;
+ private readonly CustomWebApplicationFactory _factory;
+
+ public RolesControllerTests(CustomWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient();
+
+ // EN: Add authorization header with test token
+ // VI: Thêm authorization header với test token
+ _client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GenerateTestToken());
+ }
+
+ #region Create Role Tests
+
+ [Fact]
+ public async Task CreateRole_ValidRequest_Returns201()
+ {
+ // Arrange
+ var request = new CreateRoleRequest
+ {
+ Name = $"TestRole_{Guid.NewGuid():N}",
+ Description = "A test role"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/roles", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ response.Headers.Location.Should().NotBeNull();
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result.Should().NotBeNull();
+ result!.Success.Should().BeTrue();
+ result.Data.Should().NotBeNull();
+ result.Data!.Name.Should().Be(request.Name);
+ result.Data.Description.Should().Be(request.Description);
+ }
+
+ [Fact]
+ public async Task CreateRole_WithoutDescription_Returns201()
+ {
+ // Arrange
+ var request = new CreateRoleRequest
+ {
+ Name = $"NoDescRole_{Guid.NewGuid():N}"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/roles", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result!.Data!.Description.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task CreateRole_DuplicateName_Returns409()
+ {
+ // Arrange
+ var uniqueName = $"DupRole_{Guid.NewGuid():N}";
+ var request = new CreateRoleRequest { Name = uniqueName };
+
+ // First creation - should succeed
+ var firstResponse = await _client.PostAsJsonAsync("/api/v1/roles", request);
+ firstResponse.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ // Second creation with same name - should fail
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/v1/roles", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Conflict);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result!.Success.Should().BeFalse();
+ result.Error!.Code.Should().Be("ROLE_EXISTS");
+ }
+
+ #endregion
+
+ #region Get Role Tests
+
+ [Fact]
+ public async Task GetRoleById_ExistingId_Returns200()
+ {
+ // Arrange - Create role first
+ var createRequest = new CreateRoleRequest
+ {
+ Name = $"GetRole_{Guid.NewGuid():N}"
+ };
+ var createResponse = await _client.PostAsJsonAsync("/api/v1/roles", createRequest);
+ var createResult = await createResponse.Content.ReadFromJsonAsync>();
+ var roleId = createResult!.Data!.Id;
+
+ // Act
+ var response = await _client.GetAsync($"/api/v1/roles/{roleId}");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result!.Data!.Id.Should().Be(roleId);
+ result.Data.Name.Should().Be(createRequest.Name);
+ }
+
+ [Fact]
+ public async Task GetRoleById_NonExistingId_Returns404()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act
+ var response = await _client.GetAsync($"/api/v1/roles/{nonExistentId}");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result!.Error!.Code.Should().Be("ROLE_NOT_FOUND");
+ }
+
+ [Fact]
+ public async Task GetRoles_Returns200WithPagination()
+ {
+ // Arrange - Create at least one role
+ var createRequest = new CreateRoleRequest
+ {
+ Name = $"ListRole_{Guid.NewGuid():N}"
+ };
+ await _client.PostAsJsonAsync("/api/v1/roles", createRequest);
+
+ // Act
+ var response = await _client.GetAsync("/api/v1/roles?pageNumber=1&pageSize=10");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync>>();
+ result!.Success.Should().BeTrue();
+ result.Pagination.Should().NotBeNull();
+ result.Pagination!.PageNumber.Should().Be(1);
+ }
+
+ #endregion
+
+ #region Update Role Tests
+
+ [Fact]
+ public async Task UpdateRole_ValidRequest_Returns200()
+ {
+ // Arrange - Create role first
+ var createRequest = new CreateRoleRequest
+ {
+ Name = $"UpdateRole_{Guid.NewGuid():N}",
+ Description = "Original description"
+ };
+ var createResponse = await _client.PostAsJsonAsync("/api/v1/roles", createRequest);
+ var createResult = await createResponse.Content.ReadFromJsonAsync>();
+ var roleId = createResult!.Data!.Id;
+
+ var updateRequest = new UpdateRoleRequest
+ {
+ Name = $"UpdatedRole_{Guid.NewGuid():N}",
+ Description = "Updated description"
+ };
+
+ // Act
+ var response = await _client.PutAsJsonAsync($"/api/v1/roles/{roleId}", updateRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync>();
+ result!.Data!.Name.Should().Be(updateRequest.Name);
+ result.Data.Description.Should().Be(updateRequest.Description);
+ }
+
+ [Fact]
+ public async Task UpdateRole_NonExistingId_Returns404()
+ {
+ // Arrange
+ var updateRequest = new UpdateRoleRequest
+ {
+ Name = "UpdatedRole"
+ };
+
+ // Act
+ var response = await _client.PutAsJsonAsync($"/api/v1/roles/{Guid.NewGuid()}", updateRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ #endregion
+
+ #region Delete Role Tests
+
+ [Fact]
+ public async Task DeleteRole_ExistingRole_Returns200()
+ {
+ // Arrange - Create role first
+ var createRequest = new CreateRoleRequest
+ {
+ Name = $"DeleteRole_{Guid.NewGuid():N}"
+ };
+ var createResponse = await _client.PostAsJsonAsync("/api/v1/roles", createRequest);
+ var createResult = await createResponse.Content.ReadFromJsonAsync>();
+ var roleId = createResult!.Data!.Id;
+
+ // Act
+ var response = await _client.DeleteAsync($"/api/v1/roles/{roleId}");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task DeleteRole_NonExistingId_Returns404()
+ {
+ // Act
+ var response = await _client.DeleteAsync($"/api/v1/roles/{Guid.NewGuid()}");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ #endregion
+
+ #region Assign/Remove Role Tests
+
+ [Fact]
+ public async Task AssignRoleToUser_ValidRequest_Returns200()
+ {
+ // Arrange - Create role first
+ var roleName = $"AssignRole_{Guid.NewGuid():N}";
+ await _client.PostAsJsonAsync("/api/v1/roles", new CreateRoleRequest { Name = roleName });
+
+ // Create user via registration
+ var registerRequest = new
+ {
+ Email = $"assign-test-{Guid.NewGuid():N}@example.com",
+ Password = "Test123!",
+ FirstName = "Test",
+ LastName = "User"
+ };
+ var registerResponse = await _client.PostAsJsonAsync("/api/v1/auth/register", registerRequest);
+ if (!registerResponse.IsSuccessStatusCode)
+ {
+ // Skip if can't register user
+ return;
+ }
+
+ // Get user ID from response - may vary based on response structure
+ var userId = Guid.NewGuid(); // Placeholder if user creation works differently
+
+ var assignRequest = new AssignRoleRequest { RoleName = roleName };
+
+ // Act
+ var response = await _client.PostAsJsonAsync($"/api/v1/users/{userId}/roles", assignRequest);
+
+ // Assert - Accept 200 or 404 (if user registration flow is different)
+ response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static string GenerateTestToken()
+ {
+ var claims = new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
+ new Claim(JwtRegisteredClaimNames.Email, "test@example.com"),
+ new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
+ new Claim("name", "Test User")
+ };
+
+ var key = new SymmetricSecurityKey("test-secret-key-for-testing-purposes-only-32-chars!"u8.ToArray());
+ var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var token = new JwtSecurityToken(
+ issuer: "http://localhost",
+ audience: "api",
+ claims: claims,
+ expires: DateTime.UtcNow.AddHours(1),
+ signingCredentials: creds
+ );
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ #endregion
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/AssignRoleToUserCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/AssignRoleToUserCommandHandlerTests.cs
new file mode 100644
index 00000000..f0057fc1
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/AssignRoleToUserCommandHandlerTests.cs
@@ -0,0 +1,188 @@
+using Xunit;
+using Moq;
+using FluentAssertions;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using IamService.API.Application.Commands.Roles;
+using IamService.Domain.AggregatesModel.UserAggregate;
+using IamService.Domain.AggregatesModel.RoleAggregate;
+using IamService.Domain.Exceptions;
+
+namespace IamService.UnitTests.Application.Commands.Roles;
+
+///
+/// EN: Unit tests for AssignRoleToUserCommandHandler.
+/// VI: Unit tests cho AssignRoleToUserCommandHandler.
+///
+public class AssignRoleToUserCommandHandlerTests
+{
+ private readonly Mock> _userManagerMock;
+ private readonly Mock> _roleManagerMock;
+ private readonly Mock> _loggerMock;
+ private readonly AssignRoleToUserCommandHandler _handler;
+
+ public AssignRoleToUserCommandHandlerTests()
+ {
+ // EN: Setup UserManager mock
+ // VI: Thiết lập mock cho UserManager
+ var userStoreMock = new Mock>();
+ _userManagerMock = new Mock>(
+ userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!);
+
+ // EN: Setup RoleManager mock
+ // VI: Thiết lập mock cho RoleManager
+ var roleStoreMock = new Mock>();
+ _roleManagerMock = new Mock>(
+ roleStoreMock.Object, null!, null!, null!, null!);
+
+ _loggerMock = new Mock>();
+
+ _handler = new AssignRoleToUserCommandHandler(
+ _userManagerMock.Object,
+ _roleManagerMock.Object,
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ValidCommand_AssignsRoleToUser()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var roleName = "Administrator";
+ var command = new AssignRoleToUserCommand(userId, roleName);
+ var user = new ApplicationUser("test@example.com", "Test", "User");
+
+ _userManagerMock
+ .Setup(u => u.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _roleManagerMock
+ .Setup(r => r.RoleExistsAsync(roleName))
+ .ReturnsAsync(true);
+
+ _userManagerMock
+ .Setup(u => u.IsInRoleAsync(user, roleName))
+ .ReturnsAsync(false);
+
+ _userManagerMock
+ .Setup(u => u.AddToRoleAsync(user, roleName))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().BeTrue();
+ _userManagerMock.Verify(u => u.AddToRoleAsync(user, roleName), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_UserNotFound_ThrowsDomainException()
+ {
+ // Arrange
+ var command = new AssignRoleToUserCommand(Guid.NewGuid(), "Admin");
+
+ _userManagerMock
+ .Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync((ApplicationUser?)null);
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*User*not found*");
+
+ _userManagerMock.Verify(u => u.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_RoleNotFound_ThrowsDomainException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var command = new AssignRoleToUserCommand(userId, "NonExistentRole");
+ var user = new ApplicationUser("test@example.com", "Test", "User");
+
+ _userManagerMock
+ .Setup(u => u.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _roleManagerMock
+ .Setup(r => r.RoleExistsAsync("NonExistentRole"))
+ .ReturnsAsync(false);
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*Role*not found*");
+
+ _userManagerMock.Verify(u => u.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_UserAlreadyHasRole_ThrowsDomainException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var roleName = "Admin";
+ var command = new AssignRoleToUserCommand(userId, roleName);
+ var user = new ApplicationUser("test@example.com", "Test", "User");
+
+ _userManagerMock
+ .Setup(u => u.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _roleManagerMock
+ .Setup(r => r.RoleExistsAsync(roleName))
+ .ReturnsAsync(true);
+
+ _userManagerMock
+ .Setup(u => u.IsInRoleAsync(user, roleName))
+ .ReturnsAsync(true); // User already has the role
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*already has role*");
+
+ _userManagerMock.Verify(u => u.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_AddToRoleFails_ThrowsDomainException()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var roleName = "Admin";
+ var command = new AssignRoleToUserCommand(userId, roleName);
+ var user = new ApplicationUser("test@example.com", "Test", "User");
+
+ _userManagerMock
+ .Setup(u => u.FindByIdAsync(userId.ToString()))
+ .ReturnsAsync(user);
+
+ _roleManagerMock
+ .Setup(r => r.RoleExistsAsync(roleName))
+ .ReturnsAsync(true);
+
+ _userManagerMock
+ .Setup(u => u.IsInRoleAsync(user, roleName))
+ .ReturnsAsync(false);
+
+ _userManagerMock
+ .Setup(u => u.AddToRoleAsync(user, roleName))
+ .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "Assignment failed" }));
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*Failed to assign role*");
+ }
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/CreateRoleCommandHandlerTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/CreateRoleCommandHandlerTests.cs
new file mode 100644
index 00000000..1b6077eb
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/CreateRoleCommandHandlerTests.cs
@@ -0,0 +1,148 @@
+using Xunit;
+using Moq;
+using FluentAssertions;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using IamService.API.Application.Commands.Roles;
+using IamService.Domain.AggregatesModel.RoleAggregate;
+using IamService.Domain.Exceptions;
+
+namespace IamService.UnitTests.Application.Commands.Roles;
+
+///
+/// EN: Unit tests for CreateRoleCommandHandler.
+/// VI: Unit tests cho CreateRoleCommandHandler.
+///
+public class CreateRoleCommandHandlerTests
+{
+ private readonly Mock> _roleManagerMock;
+ private readonly Mock> _loggerMock;
+ private readonly CreateRoleCommandHandler _handler;
+
+ public CreateRoleCommandHandlerTests()
+ {
+ // EN: Setup RoleManager mock
+ // VI: Thiết lập mock cho RoleManager
+ var roleStoreMock = new Mock>();
+ _roleManagerMock = new Mock>(
+ roleStoreMock.Object,
+ null!, null!, null!, null!);
+
+ _loggerMock = new Mock>();
+
+ _handler = new CreateRoleCommandHandler(
+ _roleManagerMock.Object,
+ _loggerMock.Object);
+ }
+
+ [Fact]
+ public async Task Handle_ValidCommand_CreatesRoleAndReturnsResult()
+ {
+ // Arrange
+ var command = new CreateRoleCommand("Administrator", "System administrator role");
+
+ _roleManagerMock
+ .Setup(r => r.FindByNameAsync(command.Name))
+ .ReturnsAsync((ApplicationRole?)null);
+
+ _roleManagerMock
+ .Setup(r => r.CreateAsync(It.IsAny()))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Id.Should().NotBeEmpty();
+ result.Name.Should().Be("Administrator");
+ result.Description.Should().Be("System administrator role");
+ result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public async Task Handle_ValidCommand_CallsRoleManagerCreate()
+ {
+ // Arrange
+ var command = new CreateRoleCommand("Editor", null);
+
+ _roleManagerMock
+ .Setup(r => r.FindByNameAsync(command.Name))
+ .ReturnsAsync((ApplicationRole?)null);
+
+ _roleManagerMock
+ .Setup(r => r.CreateAsync(It.IsAny()))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ _roleManagerMock.Verify(r => r.CreateAsync(It.Is(
+ role => role.Name == "Editor")), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_RoleAlreadyExists_ThrowsDomainException()
+ {
+ // Arrange
+ var command = new CreateRoleCommand("ExistingRole", null);
+ var existingRole = new ApplicationRole("ExistingRole");
+
+ _roleManagerMock
+ .Setup(r => r.FindByNameAsync(command.Name))
+ .ReturnsAsync(existingRole);
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*already exists*");
+
+ _roleManagerMock.Verify(r => r.CreateAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Handle_CreateFails_ThrowsDomainException()
+ {
+ // Arrange
+ var command = new CreateRoleCommand("NewRole", null);
+
+ _roleManagerMock
+ .Setup(r => r.FindByNameAsync(command.Name))
+ .ReturnsAsync((ApplicationRole?)null);
+
+ _roleManagerMock
+ .Setup(r => r.CreateAsync(It.IsAny()))
+ .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "Creation failed" }));
+
+ // Act
+ var act = async () => await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*Failed to create role*");
+ }
+
+ [Fact]
+ public async Task Handle_WithoutDescription_CreatesRoleWithNullDescription()
+ {
+ // Arrange
+ var command = new CreateRoleCommand("Viewer", null);
+
+ _roleManagerMock
+ .Setup(r => r.FindByNameAsync(command.Name))
+ .ReturnsAsync((ApplicationRole?)null);
+
+ _roleManagerMock
+ .Setup(r => r.CreateAsync(It.IsAny()))
+ .ReturnsAsync(IdentityResult.Success);
+
+ // Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ result.Description.Should().BeNull();
+ }
+}
diff --git a/services/iam-service-net/tests/IamService.UnitTests/Domain/Roles/ApplicationRoleTests.cs b/services/iam-service-net/tests/IamService.UnitTests/Domain/Roles/ApplicationRoleTests.cs
new file mode 100644
index 00000000..75f7a56e
--- /dev/null
+++ b/services/iam-service-net/tests/IamService.UnitTests/Domain/Roles/ApplicationRoleTests.cs
@@ -0,0 +1,201 @@
+using Xunit;
+using FluentAssertions;
+using IamService.Domain.AggregatesModel.RoleAggregate;
+
+namespace IamService.UnitTests.Domain.Roles;
+
+///
+/// EN: Unit tests for ApplicationRole entity.
+/// VI: Unit tests cho entity ApplicationRole.
+///
+public class ApplicationRoleTests
+{
+ #region Creation Tests
+
+ [Fact]
+ public void Create_ValidParameters_CreatesRoleWithGeneratedId()
+ {
+ // Arrange
+ var name = "Administrator";
+ var description = "System administrator role";
+
+ // Act
+ var role = new ApplicationRole(name, description);
+
+ // Assert
+ role.Should().NotBeNull();
+ role.Id.Should().NotBeEmpty();
+ role.Name.Should().Be(name);
+ role.NormalizedName.Should().Be(name.ToUpperInvariant());
+ role.Description.Should().Be(description);
+ role.IsSystemRole.Should().BeFalse();
+ role.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public void Create_WithoutDescription_CreatesRoleWithNullDescription()
+ {
+ // Arrange & Act
+ var role = new ApplicationRole("Editor");
+
+ // Assert
+ role.Name.Should().Be("Editor");
+ role.Description.Should().BeNull();
+ }
+
+ [Fact]
+ public void Create_AsSystemRole_SetsIsSystemRoleTrue()
+ {
+ // Arrange & Act
+ var role = new ApplicationRole("SuperAdmin", "Super Administrator", isSystemRole: true);
+
+ // Assert
+ role.IsSystemRole.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Create_InvalidName_ThrowsArgumentException(string? name)
+ {
+ // Arrange & Act
+ var act = () => new ApplicationRole(name!);
+
+ // Assert
+ act.Should().Throw()
+ .WithMessage("*Role name*empty*");
+ }
+
+ [Fact]
+ public void Create_NormalizesNameToUppercase()
+ {
+ // Arrange & Act
+ var role = new ApplicationRole("user-manager");
+
+ // Assert
+ role.NormalizedName.Should().Be("USER-MANAGER");
+ }
+
+ #endregion
+
+ #region Update Tests
+
+ [Fact]
+ public void Update_ValidData_UpdatesNameAndDescription()
+ {
+ // Arrange
+ var role = new ApplicationRole("OldName", "Old description");
+ var newName = "NewName";
+ var newDescription = "New description";
+
+ // Act
+ role.Update(newName, newDescription);
+
+ // Assert
+ role.Name.Should().Be(newName);
+ role.NormalizedName.Should().Be(newName.ToUpperInvariant());
+ role.Description.Should().Be(newDescription);
+ }
+
+ [Fact]
+ public void Update_WithNullDescription_SetsDescriptionNull()
+ {
+ // Arrange
+ var role = new ApplicationRole("TestRole", "Initial description");
+
+ // Act
+ role.Update("TestRole", null);
+
+ // Assert
+ role.Description.Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Update_InvalidName_ThrowsArgumentException(string? name)
+ {
+ // Arrange
+ var role = new ApplicationRole("OldName");
+
+ // Act
+ var act = () => role.Update(name!, "description");
+
+ // Assert
+ act.Should().Throw()
+ .WithMessage("*Role name*empty*");
+ }
+
+ #endregion
+
+ #region Domain Events Tests
+
+ [Fact]
+ public void AddDomainEvent_AddsEventToCollection()
+ {
+ // Arrange
+ var role = new ApplicationRole("TestRole");
+ var domainEvent = new TestDomainEvent();
+
+ // Act
+ role.AddDomainEvent(domainEvent);
+
+ // Assert
+ role.DomainEvents.Should().ContainSingle();
+ role.DomainEvents.First().Should().Be(domainEvent);
+ }
+
+ [Fact]
+ public void ClearDomainEvents_RemovesAllEvents()
+ {
+ // Arrange
+ var role = new ApplicationRole("TestRole");
+ role.AddDomainEvent(new TestDomainEvent());
+ role.AddDomainEvent(new TestDomainEvent());
+
+ // Act
+ role.ClearDomainEvents();
+
+ // Assert
+ role.DomainEvents.Should().BeEmpty();
+ }
+
+ #endregion
+
+ #region Equality Tests
+
+ [Fact]
+ public void TwoRoles_SameId_ShouldBeEqual()
+ {
+ // Arrange
+ var role1 = new ApplicationRole("Admin");
+ var role2Id = role1.Id;
+
+ // Assert - role IDs are Guids, so comparing by Id
+ role1.Id.Should().Be(role2Id);
+ }
+
+ [Fact]
+ public void TwoRoles_DifferentIds_ShouldNotBeEqual()
+ {
+ // Arrange
+ var role1 = new ApplicationRole("Admin");
+ var role2 = new ApplicationRole("Admin");
+
+ // Assert
+ role1.Id.Should().NotBe(role2.Id);
+ }
+
+ #endregion
+
+ ///
+ /// EN: Test domain event for testing purposes.
+ /// VI: Domain event test cho mục đích testing.
+ ///
+ private class TestDomainEvent : IamService.Domain.SeedWork.IDomainEvent
+ {
+ public DateTime OccurredOn { get; } = DateTime.UtcNow;
+ }
+}
diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs
index 0158f385..ee5c2ef3 100644
--- a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs
+++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/LevelsControllerTests.cs
@@ -10,6 +10,7 @@ namespace MembershipService.FunctionalTests.Controllers;
/// EN: Functional tests for LevelsController.
/// VI: Functional tests cho LevelsController.
///
+[Collection("Sequential")]
public class LevelsControllerTests : IClassFixture
{
private readonly CustomWebApplicationFactory _factory;
diff --git a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs
index 7f45c191..73680f2e 100644
--- a/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs
+++ b/services/membership-service-net/tests/MembershipService.FunctionalTests/Controllers/MembersControllerTests.cs
@@ -10,6 +10,7 @@ namespace MembershipService.FunctionalTests.Controllers;
/// EN: Functional tests for MembersController - Authorization tests.
/// VI: Functional tests cho MembersController - Tests Authorization.
///
+[Collection("Sequential")]
public class MembersControllerTests : IClassFixture
{
private readonly CustomWebApplicationFactory _factory;
diff --git a/services/merchant-service-net/Dockerfile b/services/merchant-service-net/Dockerfile
index 785f0e3e..5a69edc9 100644
--- a/services/merchant-service-net/Dockerfile
+++ b/services/merchant-service-net/Dockerfile
@@ -1,66 +1,49 @@
-# Build stage / Giai đoạn build
-FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+# 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
WORKDIR /src
-# EN: Copy project files for layer caching
-# VI: Sao chép các file project để tận dụng layer caching
+# EN: Copy csproj files and restore dependencies
+# VI: Copy các file csproj và restore dependencies
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/"]
-COPY ["Directory.Build.props", "./"]
-# EN: Restore dependencies
-# VI: Khôi phục dependencies
RUN dotnet restore "src/MerchantService.API/MerchantService.API.csproj"
# EN: Copy all source code
-# VI: Sao chép toàn bộ source code
-COPY src/ ./src/
+# VI: Copy toàn bộ source code
+COPY . .
-# EN: Build the application
-# VI: Build ứng dụng
+# EN: Build and publish
+# VI: Build và publish
WORKDIR "/src/src/MerchantService.API"
-RUN dotnet build "MerchantService.API.csproj" -c Release -o /app/build --no-restore
+RUN dotnet build "MerchantService.API.csproj" -c Release -o /app/build
+RUN dotnet publish "MerchantService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
-# Publish stage / Giai đoạn publish
-FROM build AS publish
-RUN dotnet publish "MerchantService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
-
-# Runtime stage / Giai đoạn runtime
-FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
+# Stage 2: Runtime
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
WORKDIR /app
-# 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: Thiết lập biến môi trường
+# VI: Đặt biến môi trường
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
-# 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: Copy published files
+# VI: Copy các file đã publish
+COPY --from=build /app/publish .
-# EN: Start the application
-# VI: Khởi động ứng dụng
+# 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
ENTRYPOINT ["dotnet", "MerchantService.API.dll"]
diff --git a/services/merchant-service-net/docker-compose.yml b/services/merchant-service-net/docker-compose.yml
index 254ceb12..82c22f3a 100644
--- a/services/merchant-service-net/docker-compose.yml
+++ b/services/merchant-service-net/docker-compose.yml
@@ -1,72 +1,30 @@
-version: '3.8'
-
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
- myservice-api:
+ merchant-service-net:
build:
context: .
dockerfile: Dockerfile
- container_name: myservice-api
+ container_name: merchant-service-net
ports:
- - "5000:8080"
+ - "5005:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- - REDIS_URL=redis:6379
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- networks:
- - myservice-network
+ - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=merchant_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
-
- postgres:
- image: postgres:16-alpine
- container_name: myservice-postgres
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: myservice_db
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
networks:
- - myservice-network
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- redis:
- image: redis:7-alpine
- container_name: myservice-redis
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
- networks:
- - myservice-network
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
-
-volumes:
- postgres_data:
- redis_data:
+ - microservices-network
+ restart: unless-stopped
networks:
- myservice-network:
- driver: bridge
+ microservices-network:
+ external: true