From 8783588ec4ee4b1853d9c5090cb2b381324b0696 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 15 Jan 2026 18:50:50 +0700 Subject: [PATCH] =?UTF-8?q?Th=C3=AAm=20c=C3=A1c=20b=C3=A0i=20ki=E1=BB=83m?= =?UTF-8?q?=20tra=20ch=E1=BB=A9c=20n=C4=83ng=20v=C3=A0=20=C4=91=C6=A1n=20v?= =?UTF-8?q?=E1=BB=8B=20cho=20qu=E1=BA=A3n=20l=C3=BD=20vai=20tr=C3=B2=20tro?= =?UTF-8?q?ng=20d=E1=BB=8Bch=20v=E1=BB=A5=20IAM,=20=C4=91=E1=BB=93ng=20th?= =?UTF-8?q?=E1=BB=9Di=20c=E1=BA=ADp=20nh=E1=BA=ADt=20c=E1=BA=A5u=20h=C3=AC?= =?UTF-8?q?nh=20Docker,=20Traefik=20v=C3=A0=20c=C3=A1c=20b=C3=A0i=20ki?= =?UTF-8?q?=E1=BB=83m=20tra=20d=E1=BB=8Bch=20v=E1=BB=A5=20th=C3=A0nh=20vi?= =?UTF-8?q?=C3=AAn.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/docker/docker-compose.dev.yml | 141 ++++++++ infra/traefik/dynamic/routes.yml | 21 +- .../Controllers/RolesControllerTests.cs | 317 ++++++++++++++++++ .../AssignRoleToUserCommandHandlerTests.cs | 188 +++++++++++ .../Roles/CreateRoleCommandHandlerTests.cs | 148 ++++++++ .../Domain/Roles/ApplicationRoleTests.cs | 201 +++++++++++ .../Controllers/LevelsControllerTests.cs | 1 + .../Controllers/MembersControllerTests.cs | 1 + services/merchant-service-net/Dockerfile | 75 ++--- .../merchant-service-net/docker-compose.yml | 66 +--- 10 files changed, 1058 insertions(+), 101 deletions(-) create mode 100644 services/iam-service-net/tests/IamService.FunctionalTests/Controllers/RolesControllerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/AssignRoleToUserCommandHandlerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Application/Commands/Roles/CreateRoleCommandHandlerTests.cs create mode 100644 services/iam-service-net/tests/IamService.UnitTests/Domain/Roles/ApplicationRoleTests.cs 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