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);
+ }
+}
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);
+ }
+}
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;
+ }
}
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();
+ }
+}
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();
+ }
}