From 4e7950e85016ebe040be5d44df29bc6bdf90ba32 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Tue, 31 Mar 2026 20:02:16 -0300 Subject: [PATCH 1/5] test(domain): add Update tests for Material entity --- .../Entities/MaterialTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Voltiq.Domain.Tests/Entities/MaterialTests.cs b/tests/Voltiq.Domain.Tests/Entities/MaterialTests.cs index 69dbba4..d48416f 100644 --- a/tests/Voltiq.Domain.Tests/Entities/MaterialTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/MaterialTests.cs @@ -90,4 +90,61 @@ public void Activate_ShouldSetIsActiveToTrue() material.IsActive.ShouldBeTrue(); } + + [Fact] + public void Update_WithValidData_ShouldUpdateFields() + { + var material = Material.Register(ValidUserId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + material.Update("Fio 6mm", 8.00m, MaterialUnit.Unidade); + + material.Name.ShouldBe("Fio 6mm"); + material.DefaultPrice.ShouldBe(8.00m); + material.Unit.ShouldBe(MaterialUnit.Unidade); + } + + [Fact] + public void Update_ShouldTrimName() + { + var material = Material.Register(ValidUserId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + material.Update(" Fio 6mm ", 8.00m, MaterialUnit.Unidade); + + material.Name.ShouldBe("Fio 6mm"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Update_WithNullOrEmptyName_ShouldThrowDomainException(string? name) + { + var material = Material.Register(ValidUserId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + Should.Throw(() => material.Update(name!, 8.00m, MaterialUnit.Unidade)) + .Message.ShouldBe(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-0.01)] + public void Update_WithInvalidPrice_ShouldThrowDomainException(decimal price) + { + var material = Material.Register(ValidUserId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + Should.Throw(() => material.Update("Fio 6mm", price, MaterialUnit.Unidade)) + .Message.ShouldBe(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + } + + [Fact] + public void Update_ShouldNotAffectIsActive() + { + var material = Material.Register(ValidUserId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + material.Deactivate(); + + material.Update("Fio 6mm", 8.00m, MaterialUnit.Unidade); + + material.IsActive.ShouldBeFalse(); + } } From 66845ff48ce4744da04f02c8d0e72b56771aff63 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Tue, 31 Mar 2026 20:02:47 -0300 Subject: [PATCH 2/5] feat(domain): add Update method to Material entity --- src/Voltiq.Domain/Entities/Material.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Voltiq.Domain/Entities/Material.cs b/src/Voltiq.Domain/Entities/Material.cs index e18e874..8858349 100644 --- a/src/Voltiq.Domain/Entities/Material.cs +++ b/src/Voltiq.Domain/Entities/Material.cs @@ -45,4 +45,20 @@ public static Material Register(Guid userId, string name, decimal defaultPrice, public void Deactivate() => IsActive = false; public void Activate() => IsActive = true; + + public void Update(string name, decimal defaultPrice, MaterialUnit unit) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + + if (!Enum.IsDefined(unit)) + throw new DomainException(ResourceErrorMessages.MATERIAL_UNIDADE_INVALIDA); + + if (defaultPrice <= 0) + throw new DomainException(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + + Name = name.Trim(); + DefaultPrice = defaultPrice; + Unit = unit; + } } From c7e07e4468873391e42e42b4a0bd655c4e1411b5 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Tue, 31 Mar 2026 20:04:42 -0300 Subject: [PATCH 3/5] test(application): add handler and validator tests for Materials CRUD --- .../DeleteMaterialCommandHandlerTests.cs | 62 +++++++++++++++++++ .../RegisterMaterialCommandHandlerTests.cs | 40 ++++++++++++ .../RegisterMaterialCommandValidatorTests.cs | 53 ++++++++++++++++ .../UpdateMaterialCommandHandlerTests.cs | 60 ++++++++++++++++++ .../UpdateMaterialCommandValidatorTests.cs | 53 ++++++++++++++++ .../GetMaterialByIdQueryHandlerTests.cs | 59 ++++++++++++++++++ .../Queries/GetMaterialsQueryHandlerTests.cs | 58 +++++++++++++++++ 7 files changed, 385 insertions(+) create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandValidatorTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandValidatorTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialByIdQueryHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialsQueryHandlerTests.cs diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs new file mode 100644 index 0000000..30b8199 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs @@ -0,0 +1,62 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Materials.Commands.DeleteMaterial; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Materials.Commands; + +public class DeleteMaterialCommandHandlerTests +{ + private readonly Mock _materialUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private DeleteMaterialCommandHandler CreateHandler() => + new(_materialUpdateRepoMock.Object, _unitOfWorkMock.Object); + + private static Material MakeMaterial(Guid userId) => + Material.Register(userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + [Fact] + public async Task Handle_WhenMaterialExists_ShouldDeleteAndReturnDeleted() + { + var material = MakeMaterial(_userId); + _materialUpdateRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(material.Id, _userId, It.IsAny())) + .ReturnsAsync(material); + + var command = new DeleteMaterialCommand(material.Id) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + _materialUpdateRepoMock.Verify(r => r.Remove(material), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() + { + _materialUpdateRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var command = new DeleteMaterialCommand(Guid.NewGuid()) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + _materialUpdateRepoMock.Verify(r => r.Remove(It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandHandlerTests.cs new file mode 100644 index 0000000..a6fb319 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandHandlerTests.cs @@ -0,0 +1,40 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Materials.Commands.RegisterMaterial; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; + +namespace Voltiq.Application.Tests.Features.Materials.Commands; + +public class RegisterMaterialCommandHandlerTests +{ + private readonly Mock _materialWriteRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private RegisterMaterialCommandHandler CreateHandler() => + new(_materialWriteRepoMock.Object, _unitOfWorkMock.Object); + + private RegisterMaterialCommand ValidCommand() => + new("Cabo 10mm", 15.50m, MaterialUnit.Metro) { UserId = _userId }; + + [Fact] + public async Task Handle_WithValidCommand_ShouldRegisterMaterialAndReturnResponse() + { + var handler = CreateHandler(); + var result = await handler.Handle(ValidCommand(), CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Id.ShouldNotBe(Guid.Empty); + result.Value.Name.ShouldBe("Cabo 10mm"); + result.Value.DefaultPrice.ShouldBe(15.50m); + result.Value.Unit.ShouldBe(MaterialUnit.Metro); + result.Value.IsActive.ShouldBeTrue(); + _materialWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandValidatorTests.cs new file mode 100644 index 0000000..5638820 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/RegisterMaterialCommandValidatorTests.cs @@ -0,0 +1,53 @@ +using FluentValidation.TestHelper; +using Voltiq.Application.Features.Materials.Commands.RegisterMaterial; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Materials.Commands; + +public class RegisterMaterialCommandValidatorTests +{ + private readonly RegisterMaterialCommandValidator _validator = new(); + + private static RegisterMaterialCommand ValidCommand() => + new("Cabo 10mm", 15.50m, MaterialUnit.Metro); + + [Fact] + public void Validate_WithValidData_ShouldHaveNoErrors() + { + _validator.TestValidate(ValidCommand()).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithEmptyName_ShouldHaveError(string? name) + { + var command = ValidCommand() with { Name = name! }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-0.01)] + public void Validate_WithInvalidPrice_ShouldHaveError(decimal price) + { + var command = ValidCommand() with { DefaultPrice = price }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.DefaultPrice) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + } + + [Fact] + public void Validate_WithInvalidUnit_ShouldHaveError() + { + var command = ValidCommand() with { Unit = (MaterialUnit)999 }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Unit) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_UNIDADE_INVALIDA); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs new file mode 100644 index 0000000..a193a0c --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Materials.Commands.UpdateMaterial; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Materials.Commands; + +public class UpdateMaterialCommandHandlerTests +{ + private readonly Mock _materialUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private UpdateMaterialCommandHandler CreateHandler() => + new(_materialUpdateRepoMock.Object, _unitOfWorkMock.Object); + + private static Material MakeMaterial(Guid userId) => + Material.Register(userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + [Fact] + public async Task Handle_WithValidCommand_ShouldUpdateMaterialAndReturnUpdated() + { + var material = MakeMaterial(_userId); + _materialUpdateRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(material.Id, _userId, It.IsAny())) + .ReturnsAsync(material); + + var command = new UpdateMaterialCommand(material.Id, "Fio 6mm", 8.00m, MaterialUnit.Unidade) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() + { + _materialUpdateRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var command = new UpdateMaterialCommand(Guid.NewGuid(), "Fio 6mm", 8.00m, MaterialUnit.Unidade) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandValidatorTests.cs new file mode 100644 index 0000000..65639fe --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandValidatorTests.cs @@ -0,0 +1,53 @@ +using FluentValidation.TestHelper; +using Voltiq.Application.Features.Materials.Commands.UpdateMaterial; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Materials.Commands; + +public class UpdateMaterialCommandValidatorTests +{ + private readonly UpdateMaterialCommandValidator _validator = new(); + + private static UpdateMaterialCommand ValidCommand() => + new(Guid.NewGuid(), "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + [Fact] + public void Validate_WithValidData_ShouldHaveNoErrors() + { + _validator.TestValidate(ValidCommand()).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithEmptyName_ShouldHaveError(string? name) + { + var command = ValidCommand() with { Name = name! }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-0.01)] + public void Validate_WithInvalidPrice_ShouldHaveError(decimal price) + { + var command = ValidCommand() with { DefaultPrice = price }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.DefaultPrice) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + } + + [Fact] + public void Validate_WithInvalidUnit_ShouldHaveError() + { + var command = ValidCommand() with { Unit = (MaterialUnit)999 }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Unit) + .WithErrorMessage(ResourceErrorMessages.MATERIAL_UNIDADE_INVALIDA); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialByIdQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialByIdQueryHandlerTests.cs new file mode 100644 index 0000000..caee90a --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialByIdQueryHandlerTests.cs @@ -0,0 +1,59 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Materials.Queries.GetMaterialById; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Materials.Queries; + +public class GetMaterialByIdQueryHandlerTests +{ + private readonly Mock _materialReadRepoMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private GetMaterialByIdQueryHandler CreateHandler() => + new(_materialReadRepoMock.Object); + + [Fact] + public async Task Handle_WhenMaterialExists_ShouldReturnResponse() + { + var material = Material.Register(_userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(material.Id, _userId, It.IsAny())) + .ReturnsAsync(material); + + var query = new GetMaterialByIdQuery(material.Id) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(query, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Id.ShouldBe(material.Id); + result.Value.Name.ShouldBe("Cabo 10mm"); + result.Value.DefaultPrice.ShouldBe(15.50m); + result.Value.Unit.ShouldBe(MaterialUnit.Metro); + result.Value.IsActive.ShouldBeTrue(); + } + + [Fact] + public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() + { + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var query = new GetMaterialByIdQuery(Guid.NewGuid()) { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(query, CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialsQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialsQueryHandlerTests.cs new file mode 100644 index 0000000..7be02ae --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Materials/Queries/GetMaterialsQueryHandlerTests.cs @@ -0,0 +1,58 @@ +using Moq; +using Shouldly; +using Voltiq.Application.Features.Materials.Queries.GetMaterials; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces.Repositories.Material; + +namespace Voltiq.Application.Tests.Features.Materials.Queries; + +public class GetMaterialsQueryHandlerTests +{ + private readonly Mock _materialReadRepoMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private GetMaterialsQueryHandler CreateHandler() => + new(_materialReadRepoMock.Object); + + [Fact] + public async Task Handle_ShouldReturnAllMaterialsForUser() + { + var materials = new List + { + Material.Register(_userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro), + Material.Register(_userId, "Fio 6mm", 8.00m, MaterialUnit.Unidade), + }; + + _materialReadRepoMock + .Setup(r => r.GetByUserIdAsync(_userId, It.IsAny())) + .ReturnsAsync(materials); + + var query = new GetMaterialsQuery { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(query, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Count.ShouldBe(2); + result.Value[0].Name.ShouldBe("Cabo 10mm"); + result.Value[1].Name.ShouldBe("Fio 6mm"); + } + + [Fact] + public async Task Handle_WhenNoMaterials_ShouldReturnEmptyList() + { + _materialReadRepoMock + .Setup(r => r.GetByUserIdAsync(_userId, It.IsAny())) + .ReturnsAsync(new List()); + + var query = new GetMaterialsQuery { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(query, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.ShouldBeEmpty(); + } +} From 3a8721614fc9714c91be64ce33525c3405475929 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Tue, 31 Mar 2026 20:07:36 -0300 Subject: [PATCH 4/5] feat(application): implement Materials CRUD handlers, validators, and mappings --- .../DeleteMaterial/DeleteMaterialCommand.cs | 9 ++++++ .../DeleteMaterialCommandHandler.cs | 28 ++++++++++++++++++ .../RegisterMaterialCommand.cs | 13 +++++++++ .../RegisterMaterialCommandHandler.cs | 25 ++++++++++++++++ .../RegisterMaterialCommandValidator.cs | 19 ++++++++++++ .../RegisterMaterialRequest.cs | 8 +++++ .../UpdateMaterial/UpdateMaterialCommand.cs | 14 +++++++++ .../UpdateMaterialCommandHandler.cs | 29 +++++++++++++++++++ .../UpdateMaterialCommandValidator.cs | 19 ++++++++++++ .../UpdateMaterial/UpdateMaterialRequest.cs | 8 +++++ .../Features/Materials/MaterialResponse.cs | 10 +++++++ .../GetMaterialById/GetMaterialByIdQuery.cs | 9 ++++++ .../GetMaterialByIdQueryHandler.cs | 23 +++++++++++++++ .../Queries/GetMaterials/GetMaterialsQuery.cs | 9 ++++++ .../GetMaterials/GetMaterialsQueryHandler.cs | 18 ++++++++++++ .../Materials/MaterialMappingExtensions.cs | 27 +++++++++++++++++ 16 files changed, 268 insertions(+) create mode 100644 src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommand.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommand.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandValidator.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialRequest.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommand.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandValidator.cs create mode 100644 src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialRequest.cs create mode 100644 src/Voltiq.Application/Features/Materials/MaterialResponse.cs create mode 100644 src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQuery.cs create mode 100644 src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQueryHandler.cs create mode 100644 src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQuery.cs create mode 100644 src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQueryHandler.cs create mode 100644 src/Voltiq.Application/Mappings/Materials/MaterialMappingExtensions.cs diff --git a/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommand.cs b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommand.cs new file mode 100644 index 0000000..3a260a0 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommand.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Materials.Commands.DeleteMaterial; + +public sealed record DeleteMaterialCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs new file mode 100644 index 0000000..7503f4c --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs @@ -0,0 +1,28 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Materials.Commands.DeleteMaterial; + +public sealed class DeleteMaterialCommandHandler( + IMaterialUpdateOnlyRepository materialUpdateOnlyRepository, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle(DeleteMaterialCommand request, + CancellationToken cancellationToken) + { + var material = await materialUpdateOnlyRepository.GetByIdAndUserIdAsync( + request.Id, request.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + + materialUpdateOnlyRepository.Remove(material); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Deleted; + } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommand.cs b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommand.cs new file mode 100644 index 0000000..533a036 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommand.cs @@ -0,0 +1,13 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Materials.Commands.RegisterMaterial; + +public sealed record RegisterMaterialCommand( + string Name, + decimal DefaultPrice, + MaterialUnit Unit) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandHandler.cs b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandHandler.cs new file mode 100644 index 0000000..d1d0bee --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandHandler.cs @@ -0,0 +1,25 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Materials; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; + +namespace Voltiq.Application.Features.Materials.Commands.RegisterMaterial; + +public sealed class RegisterMaterialCommandHandler( + IMaterialWriteOnlyRepository materialWriteOnlyRepository, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle(RegisterMaterialCommand request, + CancellationToken cancellationToken) + { + var material = Material.Register(request.UserId, request.Name, request.DefaultPrice, request.Unit); + + await materialWriteOnlyRepository.AddAsync(material, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return material.ToResponse(); + } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandValidator.cs b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandValidator.cs new file mode 100644 index 0000000..a6fc688 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Materials.Commands.RegisterMaterial; + +public sealed class RegisterMaterialCommandValidator : AbstractValidator +{ + public RegisterMaterialCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + + RuleFor(x => x.DefaultPrice) + .GreaterThan(0).WithMessage(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + + RuleFor(x => x.Unit) + .IsInEnum().WithMessage(ResourceErrorMessages.MATERIAL_UNIDADE_INVALIDA); + } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialRequest.cs b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialRequest.cs new file mode 100644 index 0000000..8c52a80 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/RegisterMaterial/RegisterMaterialRequest.cs @@ -0,0 +1,8 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Materials.Commands.RegisterMaterial; + +public sealed record RegisterMaterialRequest( + string Name, + decimal DefaultPrice, + MaterialUnit Unit); diff --git a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommand.cs b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommand.cs new file mode 100644 index 0000000..9c6bc28 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommand.cs @@ -0,0 +1,14 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Materials.Commands.UpdateMaterial; + +public sealed record UpdateMaterialCommand( + Guid Id, + string Name, + decimal DefaultPrice, + MaterialUnit Unit) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs new file mode 100644 index 0000000..eb9a4d2 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Materials.Commands.UpdateMaterial; + +public sealed class UpdateMaterialCommandHandler( + IMaterialUpdateOnlyRepository materialUpdateOnlyRepository, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle(UpdateMaterialCommand request, + CancellationToken cancellationToken) + { + var material = await materialUpdateOnlyRepository.GetByIdAndUserIdAsync( + request.Id, request.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + + material.Update(request.Name, request.DefaultPrice, request.Unit); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandValidator.cs b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandValidator.cs new file mode 100644 index 0000000..e5458f6 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Materials.Commands.UpdateMaterial; + +public sealed class UpdateMaterialCommandValidator : AbstractValidator +{ + public UpdateMaterialCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage(ResourceErrorMessages.MATERIAL_NOME_OBRIGATORIO); + + RuleFor(x => x.DefaultPrice) + .GreaterThan(0).WithMessage(ResourceErrorMessages.MATERIAL_PRECO_INVALIDO); + + RuleFor(x => x.Unit) + .IsInEnum().WithMessage(ResourceErrorMessages.MATERIAL_UNIDADE_INVALIDA); + } +} diff --git a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialRequest.cs b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialRequest.cs new file mode 100644 index 0000000..a38a9e3 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialRequest.cs @@ -0,0 +1,8 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Materials.Commands.UpdateMaterial; + +public sealed record UpdateMaterialRequest( + string Name, + decimal DefaultPrice, + MaterialUnit Unit); diff --git a/src/Voltiq.Application/Features/Materials/MaterialResponse.cs b/src/Voltiq.Application/Features/Materials/MaterialResponse.cs new file mode 100644 index 0000000..b6ba888 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/MaterialResponse.cs @@ -0,0 +1,10 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Materials; + +public sealed record MaterialResponse( + Guid Id, + string Name, + decimal DefaultPrice, + MaterialUnit Unit, + bool IsActive); diff --git a/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQuery.cs b/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQuery.cs new file mode 100644 index 0000000..de81710 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQuery.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Materials.Queries.GetMaterialById; + +public sealed record GetMaterialByIdQuery(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQueryHandler.cs b/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQueryHandler.cs new file mode 100644 index 0000000..eef1732 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Queries/GetMaterialById/GetMaterialByIdQueryHandler.cs @@ -0,0 +1,23 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Materials; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Materials.Queries.GetMaterialById; + +public sealed class GetMaterialByIdQueryHandler(IMaterialReadOnlyRepository materialReadOnlyRepository) + : IRequestHandler> +{ + public async Task> Handle(GetMaterialByIdQuery request, + CancellationToken cancellationToken) + { + var material = await materialReadOnlyRepository.GetByIdAndUserIdAsync( + request.Id, request.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + + return material.ToResponse(); + } +} diff --git a/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQuery.cs b/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQuery.cs new file mode 100644 index 0000000..a429d2e --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQuery.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Materials.Queries.GetMaterials; + +public sealed record GetMaterialsQuery : IAuthenticatedRequest>> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQueryHandler.cs b/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQueryHandler.cs new file mode 100644 index 0000000..1d58125 --- /dev/null +++ b/src/Voltiq.Application/Features/Materials/Queries/GetMaterials/GetMaterialsQueryHandler.cs @@ -0,0 +1,18 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Materials; +using Voltiq.Domain.Interfaces.Repositories.Material; + +namespace Voltiq.Application.Features.Materials.Queries.GetMaterials; + +public sealed class GetMaterialsQueryHandler(IMaterialReadOnlyRepository materialReadOnlyRepository) + : IRequestHandler>> +{ + public async Task>> Handle(GetMaterialsQuery request, + CancellationToken cancellationToken) + { + var materials = await materialReadOnlyRepository.GetByUserIdAsync(request.UserId, cancellationToken); + + return materials.Select(m => m.ToResponse()).ToList(); + } +} diff --git a/src/Voltiq.Application/Mappings/Materials/MaterialMappingExtensions.cs b/src/Voltiq.Application/Mappings/Materials/MaterialMappingExtensions.cs new file mode 100644 index 0000000..e3e8652 --- /dev/null +++ b/src/Voltiq.Application/Mappings/Materials/MaterialMappingExtensions.cs @@ -0,0 +1,27 @@ +using Voltiq.Application.Features.Materials; +using Voltiq.Application.Features.Materials.Commands.RegisterMaterial; +using Voltiq.Application.Features.Materials.Commands.UpdateMaterial; +using Voltiq.Domain.Entities; + +namespace Voltiq.Application.Mappings.Materials; + +public static class MaterialMappingExtensions +{ + extension(RegisterMaterialRequest request) + { + public RegisterMaterialCommand ToCommand() => + new(request.Name, request.DefaultPrice, request.Unit); + } + + extension(UpdateMaterialRequest request) + { + public UpdateMaterialCommand ToCommand(Guid id) => + new(id, request.Name, request.DefaultPrice, request.Unit); + } + + extension(Material material) + { + public MaterialResponse ToResponse() => + new(material.Id, material.Name, material.DefaultPrice, material.Unit, material.IsActive); + } +} From 25f25b14f94c0b6d639d45e4e4a3e5e5df8dcc13 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Tue, 31 Mar 2026 20:07:43 -0300 Subject: [PATCH 5/5] feat(api): implement MaterialsController --- .../Materials/MaterialsController.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/Voltiq.API/Controllers/Materials/MaterialsController.cs diff --git a/src/Voltiq.API/Controllers/Materials/MaterialsController.cs b/src/Voltiq.API/Controllers/Materials/MaterialsController.cs new file mode 100644 index 0000000..47ea358 --- /dev/null +++ b/src/Voltiq.API/Controllers/Materials/MaterialsController.cs @@ -0,0 +1,87 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Voltiq.Application.Features.Materials; +using Voltiq.Application.Features.Materials.Commands.DeleteMaterial; +using Voltiq.Application.Features.Materials.Commands.RegisterMaterial; +using Voltiq.Application.Features.Materials.Commands.UpdateMaterial; +using Voltiq.Application.Features.Materials.Queries.GetMaterialById; +using Voltiq.Application.Features.Materials.Queries.GetMaterials; +using Voltiq.Application.Mappings.Materials; + +namespace Voltiq.API.Controllers.Materials; + +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/materials")] +public sealed class MaterialsController : BaseApiController +{ + /// Registers a new material for the authenticated user. + [HttpPost] + [ProducesResponseType(typeof(MaterialResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Register( + [FromBody] RegisterMaterialRequest request, + CancellationToken cancellationToken) + { + var result = await Sender.Send(request.ToCommand(), cancellationToken); + + return result.Match( + material => CreatedAtAction(nameof(GetById), new { id = material.Id }, material), + ToErrorResult); + } + + /// Returns all materials belonging to the authenticated user. + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetAll(CancellationToken cancellationToken) + { + var result = await Sender.Send(new GetMaterialsQuery(), cancellationToken); + + return result.Match(Ok, ToErrorResult); + } + + /// Returns a specific material by ID (must belong to the authenticated user). + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(MaterialResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new GetMaterialByIdQuery(id), cancellationToken); + + return result.Match(Ok, ToErrorResult); + } + + /// Updates a material (must belong to the authenticated user). + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Update( + Guid id, + [FromBody] UpdateMaterialRequest request, + CancellationToken cancellationToken) + { + var result = await Sender.Send(request.ToCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Deletes a material (must belong to the authenticated user). + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new DeleteMaterialCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } +}