From cdea039c7e64434f2c9009b2bc7abdc12baf2639 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:47:00 -0300 Subject: [PATCH 01/36] refactor(repositories): rename GetByIdAndUserIdAsync to GetTrackedByIdAndUserIdAsync in UpdateOnly interfaces --- .../Repositories/Budget/IBudgetUpdateOnlyRepository.cs | 8 ++++++-- .../Repositories/Client/IClientUpdateOnlyRepository.cs | 4 +++- .../Material/IMaterialUpdateOnlyRepository.cs | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs index ff74026..87526a7 100644 --- a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs +++ b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs @@ -2,7 +2,11 @@ namespace Voltiq.Domain.Interfaces.Repositories.Budget; public interface IBudgetUpdateOnlyRepository { - Task GetByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); - Task GetByIdWithItemsAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task GetTrackedByIdAndUserIdAsync(Guid id, Guid userId, + CancellationToken cancellationToken = default); + + Task GetTrackedByIdWithItemsAndUserIdAsync(Guid id, Guid userId, + CancellationToken cancellationToken = default); + void Remove(Entities.Budget entity); } diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Client/IClientUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Client/IClientUpdateOnlyRepository.cs index 08e8db5..1a3477f 100644 --- a/src/Voltiq.Domain/Interfaces/Repositories/Client/IClientUpdateOnlyRepository.cs +++ b/src/Voltiq.Domain/Interfaces/Repositories/Client/IClientUpdateOnlyRepository.cs @@ -2,6 +2,8 @@ namespace Voltiq.Domain.Interfaces.Repositories.Client; public interface IClientUpdateOnlyRepository { - Task GetByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task GetTrackedByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken + cancellationToken = default); + void Remove(Entities.Client entity); } diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Material/IMaterialUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Material/IMaterialUpdateOnlyRepository.cs index a49585d..317d3dd 100644 --- a/src/Voltiq.Domain/Interfaces/Repositories/Material/IMaterialUpdateOnlyRepository.cs +++ b/src/Voltiq.Domain/Interfaces/Repositories/Material/IMaterialUpdateOnlyRepository.cs @@ -2,6 +2,8 @@ namespace Voltiq.Domain.Interfaces.Repositories.Material; public interface IMaterialUpdateOnlyRepository { - Task GetByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task GetTrackedByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken + cancellationToken = default); + void Remove(Entities.Material entity); } From 73c5742f871ea3cedb2493528ab1df8660de556b Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:47:09 -0300 Subject: [PATCH 02/36] refactor(repositories): update UpdateOnly explicit implementations with GetTrackedByIdAndUserIdAsync --- .../Repositories/Budget/BudgetRepository.cs | 60 +++++++++++++------ .../Repositories/Client/ClientRepository.cs | 40 +++++++++---- .../Material/MaterialRepository.cs | 44 +++++++++----- 3 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs index 3e46501..045ada4 100644 --- a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs +++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs @@ -6,59 +6,81 @@ namespace Voltiq.Infrastructure.Persistence.Repositories.Budget; public sealed class BudgetRepository(ApplicationDbContext context) : IBudgetReadOnlyRepository, IBudgetWriteOnlyRepository, IBudgetUpdateOnlyRepository { - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - => await context.Budgets + public async Task GetByIdAsync(Guid id, + CancellationToken cancellationToken = default) + { + return await context.Budgets .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + } public async Task> GetByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) - => await context.Budgets + { + return await context.Budgets .AsNoTracking() .Where(b => b.UserId == userId) .ToListAsync(cancellationToken); + } public async Task> GetByClientIdAsync( Guid clientId, CancellationToken cancellationToken = default) - => await context.Budgets + { + return await context.Budgets .AsNoTracking() .Where(b => b.ClientId == clientId) .ToListAsync(cancellationToken); + } async Task IBudgetReadOnlyRepository.GetByIdAndUserIdAsync( Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Budgets + { + return await context.Budgets .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); - - async Task IBudgetUpdateOnlyRepository.GetByIdAndUserIdAsync( - Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Budgets - .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); + } public async Task GetByIdWithItemsAsync( Guid id, CancellationToken cancellationToken = default) - => await context.Budgets + { + return await context.Budgets .Include(b => b.Items) .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + } async Task IBudgetReadOnlyRepository.GetByIdWithItemsAndUserIdAsync( Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Budgets + { + return await context.Budgets .Include(b => b.Items) .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); + } + + public void Remove(Domain.Entities.Budget entity) + { + context.Budgets.Remove(entity); + } + + public async Task GetTrackedByIdAndUserIdAsync( + Guid id, Guid userId, CancellationToken cancellationToken) + { + return await context.Budgets + .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); + } - async Task IBudgetUpdateOnlyRepository.GetByIdWithItemsAndUserIdAsync( + public async Task GetTrackedByIdWithItemsAndUserIdAsync( Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Budgets + { + return await context.Budgets .Include(b => b.Items) .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); + } - public async Task AddAsync(Domain.Entities.Budget entity, CancellationToken cancellationToken = default) - => await context.Budgets.AddAsync(entity, cancellationToken); - - public void Remove(Domain.Entities.Budget entity) - => context.Budgets.Remove(entity); + public async Task AddAsync(Domain.Entities.Budget entity, + CancellationToken cancellationToken = default) + { + await context.Budgets.AddAsync(entity, cancellationToken); + } } diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Client/ClientRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Client/ClientRepository.cs index 3dfe426..a4db512 100644 --- a/src/Voltiq.Infrastructure/Persistence/Repositories/Client/ClientRepository.cs +++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Client/ClientRepository.cs @@ -9,34 +9,48 @@ public sealed class ClientRepository(ApplicationDbContext context) { public async Task> GetByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) - => await context.Clients + { + return await context.Clients .AsNoTracking() .Where(c => c.UserId == userId) .ToListAsync(cancellationToken); + } - async Task IClientReadOnlyRepository.GetByIdAndUserIdAsync( + public async Task GetByIdAndUserIdAsync( Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Clients + { + return await context.Clients .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, cancellationToken); - - async Task IClientUpdateOnlyRepository.GetByIdAndUserIdAsync( - Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Clients - .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, cancellationToken); + } public async Task ExistsWithEmailForUserAsync( - Email email, Guid userId, Guid? excludeId = null, CancellationToken cancellationToken = default) - => await context.Clients + Email email, Guid userId, Guid? excludeId = null, + CancellationToken cancellationToken = default) + { + return await context.Clients .AsNoTracking() .AnyAsync(c => c.UserId == userId && c.Email == email && (excludeId == null || c.Id != excludeId), cancellationToken); + } - public async Task AddAsync(Domain.Entities.Client entity, CancellationToken cancellationToken = default) - => await context.Clients.AddAsync(entity, cancellationToken); + public async Task GetTrackedByIdAndUserIdAsync( + Guid id, Guid userId, CancellationToken cancellationToken) + { + return await context.Clients + .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, cancellationToken); + } public void Remove(Domain.Entities.Client entity) - => context.Clients.Remove(entity); + { + context.Clients.Remove(entity); + } + + public async Task AddAsync(Domain.Entities.Client entity, + CancellationToken cancellationToken = default) + { + await context.Clients.AddAsync(entity, cancellationToken); + } } diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Material/MaterialRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Material/MaterialRepository.cs index 50c06a2..913ae8f 100644 --- a/src/Voltiq.Infrastructure/Persistence/Repositories/Material/MaterialRepository.cs +++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Material/MaterialRepository.cs @@ -6,39 +6,55 @@ namespace Voltiq.Infrastructure.Persistence.Repositories.Material; public sealed class MaterialRepository(ApplicationDbContext context) : IMaterialReadOnlyRepository, IMaterialWriteOnlyRepository, IMaterialUpdateOnlyRepository { - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) - => await context.Materials + public async Task GetByIdAsync(Guid id, + CancellationToken cancellationToken = default) + { + return await context.Materials .AsNoTracking() .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); + } public async Task> GetByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) - => await context.Materials + { + return await context.Materials .AsNoTracking() .Where(m => m.UserId == userId) .ToListAsync(cancellationToken); + } - async Task IMaterialReadOnlyRepository.GetByIdAndUserIdAsync( + public async Task GetByIdAndUserIdAsync( Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Materials + { + return await context.Materials .AsNoTracking() .FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, cancellationToken); - - async Task IMaterialUpdateOnlyRepository.GetByIdAndUserIdAsync( - Guid id, Guid userId, CancellationToken cancellationToken) - => await context.Materials - .FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, cancellationToken); + } public async Task> GetActiveByUserIdAsync( Guid userId, CancellationToken cancellationToken = default) - => await context.Materials + { + return await context.Materials .AsNoTracking() .Where(m => m.UserId == userId && m.IsActive) .ToListAsync(cancellationToken); + } - public async Task AddAsync(Domain.Entities.Material entity, CancellationToken cancellationToken = default) - => await context.Materials.AddAsync(entity, cancellationToken); + public async Task GetTrackedByIdAndUserIdAsync( + Guid id, Guid userId, CancellationToken cancellationToken) + { + return await context.Materials + .FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, cancellationToken); + } public void Remove(Domain.Entities.Material entity) - => context.Materials.Remove(entity); + { + context.Materials.Remove(entity); + } + + public async Task AddAsync(Domain.Entities.Material entity, + CancellationToken cancellationToken = default) + { + await context.Materials.AddAsync(entity, cancellationToken); + } } From 523822126d6e455fbffd86bfa88b103545f0b05f Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:50:32 -0300 Subject: [PATCH 03/36] refactor(handlers): update client handlers to call GetTrackedByIdAndUserIdAsync --- .../Commands/DeleteClient/DeleteClientCommandHandler.cs | 3 ++- .../Commands/UpdateClient/UpdateClientCommandHandler.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Voltiq.Application/Features/Clients/Commands/DeleteClient/DeleteClientCommandHandler.cs b/src/Voltiq.Application/Features/Clients/Commands/DeleteClient/DeleteClientCommandHandler.cs index a211580..38eca0c 100644 --- a/src/Voltiq.Application/Features/Clients/Commands/DeleteClient/DeleteClientCommandHandler.cs +++ b/src/Voltiq.Application/Features/Clients/Commands/DeleteClient/DeleteClientCommandHandler.cs @@ -15,7 +15,8 @@ public async Task> Handle(DeleteClientCommand request, CancellationToken cancellationToken) { var client = - await clientRepository.GetByIdAndUserIdAsync(request.Id, request.UserId, cancellationToken); + await clientRepository.GetTrackedByIdAndUserIdAsync(request.Id, request.UserId, + cancellationToken); if (client is null) return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); diff --git a/src/Voltiq.Application/Features/Clients/Commands/UpdateClient/UpdateClientCommandHandler.cs b/src/Voltiq.Application/Features/Clients/Commands/UpdateClient/UpdateClientCommandHandler.cs index ed46539..32cfea0 100644 --- a/src/Voltiq.Application/Features/Clients/Commands/UpdateClient/UpdateClientCommandHandler.cs +++ b/src/Voltiq.Application/Features/Clients/Commands/UpdateClient/UpdateClientCommandHandler.cs @@ -17,7 +17,8 @@ public async Task> Handle(UpdateClientCommand request, CancellationToken cancellationToken) { var client = - await clientUpdateOnlyRepository.GetByIdAndUserIdAsync(request.Id, request.UserId, cancellationToken); + await clientUpdateOnlyRepository.GetTrackedByIdAndUserIdAsync(request.Id, request + .UserId, cancellationToken); if (client is null) return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); From b5e60b938634ff05ac3f1f3ae536671739431cec Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:50:43 -0300 Subject: [PATCH 04/36] refactor(handlers): update material handlers to call GetTrackedByIdAndUserIdAsync --- .../Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs | 2 +- .../Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs index 7503f4c..99d8f4b 100644 --- a/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs +++ b/src/Voltiq.Application/Features/Materials/Commands/DeleteMaterial/DeleteMaterialCommandHandler.cs @@ -14,7 +14,7 @@ public sealed class DeleteMaterialCommandHandler( public async Task> Handle(DeleteMaterialCommand request, CancellationToken cancellationToken) { - var material = await materialUpdateOnlyRepository.GetByIdAndUserIdAsync( + var material = await materialUpdateOnlyRepository.GetTrackedByIdAndUserIdAsync( request.Id, request.UserId, cancellationToken); if (material is null) diff --git a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs index eb9a4d2..5a1e5d5 100644 --- a/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs +++ b/src/Voltiq.Application/Features/Materials/Commands/UpdateMaterial/UpdateMaterialCommandHandler.cs @@ -14,7 +14,7 @@ public sealed class UpdateMaterialCommandHandler( public async Task> Handle(UpdateMaterialCommand request, CancellationToken cancellationToken) { - var material = await materialUpdateOnlyRepository.GetByIdAndUserIdAsync( + var material = await materialUpdateOnlyRepository.GetTrackedByIdAndUserIdAsync( request.Id, request.UserId, cancellationToken); if (material is null) From 696afbedc14aa41971ade15afd62741c3f39aaa6 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:53:00 -0300 Subject: [PATCH 05/36] test(clients): update mocks to use GetTrackedByIdAndUserIdAsync --- .../DeleteClientCommandHandlerTests.cs | 18 ++++++---- .../UpdateClientCommandHandlerTests.cs | 33 +++++++++++-------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/tests/Voltiq.Application.Tests/Features/Clients/Commands/DeleteClientCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Clients/Commands/DeleteClientCommandHandlerTests.cs index 9b4d198..1976fc7 100644 --- a/tests/Voltiq.Application.Tests/Features/Clients/Commands/DeleteClientCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Clients/Commands/DeleteClientCommandHandlerTests.cs @@ -17,8 +17,10 @@ public class DeleteClientCommandHandlerTests private readonly Guid _userId = Guid.NewGuid(); - private DeleteClientCommandHandler CreateHandler() => - new(_clientRepoMock.Object, _unitOfWorkMock.Object); + private DeleteClientCommandHandler CreateHandler() + { + return new DeleteClientCommandHandler(_clientRepoMock.Object, _unitOfWorkMock.Object); + } private static Client MakeClient(Guid userId) { @@ -32,11 +34,13 @@ public async Task Handle_WhenClientExists_ShouldDeleteAndReturnDeleted() { var client = MakeClient(_userId); _clientRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(client.Id, _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(client.Id, _userId, It + .IsAny())) .ReturnsAsync(client); var handler = CreateHandler(); - var result = await handler.Handle(new DeleteClientCommand(client.Id) { UserId = _userId }, CancellationToken.None); + var result = await handler.Handle(new DeleteClientCommand(client.Id) { UserId = _userId }, + CancellationToken.None); result.IsError.ShouldBeFalse(); _clientRepoMock.Verify(r => r.Remove(client), Times.Once); @@ -47,11 +51,13 @@ public async Task Handle_WhenClientExists_ShouldDeleteAndReturnDeleted() public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() { _clientRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(It.IsAny(), _userId, It + .IsAny())) .ReturnsAsync((Client?)null); var handler = CreateHandler(); - var result = await handler.Handle(new DeleteClientCommand(Guid.NewGuid()) { UserId = _userId }, CancellationToken.None); + var result = await handler.Handle( + new DeleteClientCommand(Guid.NewGuid()) { UserId = _userId }, CancellationToken.None); result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); diff --git a/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs index 4587bc7..3afac3c 100644 --- a/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs @@ -20,7 +20,8 @@ public class UpdateClientCommandHandlerTests private UpdateClientCommandHandler CreateHandler() { - return new UpdateClientCommandHandler(_clientReadRepoMock.Object, _clientUpdateRepoMock.Object, _unitOfWorkMock.Object); + return new UpdateClientCommandHandler(_clientReadRepoMock.Object, + _clientUpdateRepoMock.Object, _unitOfWorkMock.Object); } private static Client MakeClient(Guid userId) @@ -35,7 +36,9 @@ public async Task Handle_WithValidCommand_ShouldUpdateClientAndReturnUpdated() { var client = MakeClient(_userId); _clientUpdateRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(client.Id, _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(client.Id, _userId, It + .IsAny + ())) .ReturnsAsync(client); _clientReadRepoMock .Setup(r => r.ExistsWithEmailForUserAsync(It.IsAny(), _userId, client.Id, It @@ -43,9 +46,9 @@ public async Task Handle_WithValidCommand_ShouldUpdateClientAndReturnUpdated() .ReturnsAsync(false); var command = new UpdateClientCommand( - client.Id, "Maria Souza", "(11) 88888-8888", "maria@example.com", - "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + client.Id, "Maria Souza", "(11) 88888-8888", "maria@example.com", + "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -59,7 +62,9 @@ public async Task Handle_WhenEmailAlreadyExistsForAnotherClient_ShouldReturnConf { var client = MakeClient(_userId); _clientUpdateRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(client.Id, _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(client.Id, _userId, It + .IsAny + ())) .ReturnsAsync(client); _clientReadRepoMock .Setup(r => r.ExistsWithEmailForUserAsync(It.IsAny(), _userId, client.Id, It @@ -67,9 +72,9 @@ public async Task Handle_WhenEmailAlreadyExistsForAnotherClient_ShouldReturnConf .ReturnsAsync(true); var command = new UpdateClientCommand( - client.Id, "Maria Souza", "(11) 88888-8888", "outro@example.com", - "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + client.Id, "Maria Souza", "(11) 88888-8888", "outro@example.com", + "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -85,13 +90,15 @@ public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() { _clientUpdateRepoMock .Setup(r => - r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + r.GetTrackedByIdAndUserIdAsync(It.IsAny(), _userId, It + .IsAny + ())) .ReturnsAsync((Client?)null); var command = new UpdateClientCommand( - Guid.NewGuid(), "Maria Souza", "(11) 88888-8888", "maria@example.com", - "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + Guid.NewGuid(), "Maria Souza", "(11) 88888-8888", "maria@example.com", + "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); From 603e540f1725af17ebcdc373e783c60d5c9346e5 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:53:09 -0300 Subject: [PATCH 06/36] test(materials): update mocks to use GetTrackedByIdAndUserIdAsync --- .../DeleteMaterialCommandHandlerTests.cs | 19 +++++++++----- .../UpdateMaterialCommandHandlerTests.cs | 26 +++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs index 30b8199..6374ca1 100644 --- a/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/DeleteMaterialCommandHandlerTests.cs @@ -17,18 +17,24 @@ public class DeleteMaterialCommandHandlerTests private readonly Guid _userId = Guid.NewGuid(); - private DeleteMaterialCommandHandler CreateHandler() => - new(_materialUpdateRepoMock.Object, _unitOfWorkMock.Object); + private DeleteMaterialCommandHandler CreateHandler() + { + return new DeleteMaterialCommandHandler(_materialUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } - private static Material MakeMaterial(Guid userId) => - Material.Register(userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + private static Material MakeMaterial(Guid userId) + { + return 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())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(material.Id, _userId, It + .IsAny())) .ReturnsAsync(material); var command = new DeleteMaterialCommand(material.Id) { UserId = _userId }; @@ -45,7 +51,8 @@ public async Task Handle_WhenMaterialExists_ShouldDeleteAndReturnDeleted() public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() { _materialUpdateRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(It.IsAny(), _userId, It + .IsAny())) .ReturnsAsync((Material?)null); var command = new DeleteMaterialCommand(Guid.NewGuid()) { UserId = _userId }; diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs index a193a0c..4481f57 100644 --- a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs @@ -17,21 +17,28 @@ public class UpdateMaterialCommandHandlerTests private readonly Guid _userId = Guid.NewGuid(); - private UpdateMaterialCommandHandler CreateHandler() => - new(_materialUpdateRepoMock.Object, _unitOfWorkMock.Object); + private UpdateMaterialCommandHandler CreateHandler() + { + return new UpdateMaterialCommandHandler(_materialUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } - private static Material MakeMaterial(Guid userId) => - Material.Register(userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + private static Material MakeMaterial(Guid userId) + { + return 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())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(material.Id, _userId, It + .IsAny())) .ReturnsAsync(material); - var command = new UpdateMaterialCommand(material.Id, "Fio 6mm", 8.00m, MaterialUnit.Unidade) { UserId = _userId }; + 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); @@ -44,10 +51,13 @@ public async Task Handle_WithValidCommand_ShouldUpdateMaterialAndReturnUpdated() public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() { _materialUpdateRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => r.GetTrackedByIdAndUserIdAsync(It.IsAny(), _userId, It + .IsAny())) .ReturnsAsync((Material?)null); - var command = new UpdateMaterialCommand(Guid.NewGuid(), "Fio 6mm", 8.00m, MaterialUnit.Unidade) { UserId = _userId }; + 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); From 6dfba7406d09bd7d879c2fcd13a2b93533da54d5 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:53:17 -0300 Subject: [PATCH 07/36] refactor(pipeline): fix behavior registration order (Authorization before Validation) --- src/Voltiq.Application/DependencyInjection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Voltiq.Application/DependencyInjection.cs b/src/Voltiq.Application/DependencyInjection.cs index 9b05df0..c3d6b71 100644 --- a/src/Voltiq.Application/DependencyInjection.cs +++ b/src/Voltiq.Application/DependencyInjection.cs @@ -18,8 +18,8 @@ public static void AddApplication(this IServiceCollection services, configuration["MediatR:LicenseKey"] ?? throw new InvalidOperationException("MediatR license key is not configured."); cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); }); services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); From 6455cbdaddca1c2ebfc8bac18608c6d9d5460ba6 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 20:08:11 -0300 Subject: [PATCH 08/36] test(budgets): add failing tests for Budget commands and queries --- .../DeleteBudgetCommandHandlerTests.cs | 55 ++++++++ .../RegisterBudgetCommandHandlerTests.cs | 131 ++++++++++++++++++ .../RegisterBudgetCommandValidatorTests.cs | 81 +++++++++++ .../Queries/GetBudgetByIdQueryHandlerTests.cs | 79 +++++++++++ .../Queries/GetBudgetsQueryHandlerTests.cs | 70 ++++++++++ 5 files changed, 416 insertions(+) create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/DeleteBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/DeleteBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/DeleteBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..3d25d53 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/DeleteBudgetCommandHandlerTests.cs @@ -0,0 +1,55 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.DeleteBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class DeleteBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + + private DeleteBudgetCommandHandler CreateHandler() => + new(_budgetUpdateRepoMock.Object, _unitOfWorkMock.Object); + + [Fact] + public async Task Handle_WhenBudgetExists_ShouldDeleteAndReturnDeleted() + { + var budget = Budget.Register(_userId, Guid.NewGuid()); + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var handler = CreateHandler(); + var result = await handler.Handle(new DeleteBudgetCommand(_budgetId) { UserId = _userId }, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + _budgetUpdateRepoMock.Verify(r => r.Remove(budget), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFoundError() + { + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var handler = CreateHandler(); + var result = await handler.Handle(new DeleteBudgetCommand(_budgetId) { UserId = _userId }, CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + _budgetUpdateRepoMock.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/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..4af50b2 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs @@ -0,0 +1,131 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets; +using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Domain.ValueObjects; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class RegisterBudgetCommandHandlerTests +{ + private readonly Mock _clientReadRepoMock = new(); + private readonly Mock _materialReadRepoMock = new(); + private readonly Mock _budgetWriteRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private RegisterBudgetCommandHandler CreateHandler() => + new(_clientReadRepoMock.Object, _materialReadRepoMock.Object, + _budgetWriteRepoMock.Object, _unitOfWorkMock.Object); + + private Client MakeClient() => + Client.Register(_userId, "João Silva", "(11) 99999-9999", + Email.Create("joao@example.com").Value, + Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + + private RegisterBudgetCommand CommandWithCustomItem() => + new(_clientId, [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]) + { UserId = _userId }; + + [Fact] + public async Task Handle_WithCustomItems_ShouldRegisterBudgetAndReturnDetailResponse() + { + var client = MakeClient(); + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync(client); + + var handler = CreateHandler(); + var result = await handler.Handle(CommandWithCustomItem(), CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Id.ShouldNotBe(Guid.Empty); + result.Value.Status.ShouldBe(BudgetStatus.Draft); + result.Value.TotalAmount.ShouldBe(31.00m); + result.Value.Client.Name.ShouldBe("João Silva"); + result.Value.Items.Count.ShouldBe(1); + result.Value.Items[0].MaterialName.ShouldBe("Cabo 10mm"); + result.Value.Items[0].TotalPrice.ShouldBe(31.00m); + + _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithMaterialId_ShouldValidateMaterialAndRegisterBudget() + { + var materialId = Guid.NewGuid(); + var client = MakeClient(); + var material = Material.Register(_userId, "Cabo 10mm", 15.50m, MaterialUnit.Metro); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync(client); + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny())) + .ReturnsAsync(material); + + var command = new RegisterBudgetCommand( + _clientId, + [new RegisterBudgetItemCommand(materialId, "Cabo 10mm", MaterialUnit.Metro, 3, 10.00m)]) + { UserId = _userId }; + + var handler = CreateHandler(); + var result = await handler.Handle(command, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Items[0].MaterialId.ShouldBe(materialId); + _materialReadRepoMock.Verify(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() + { + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .ReturnsAsync((Client?)null); + + var handler = CreateHandler(); + var result = await handler.Handle(CommandWithCustomItem(), CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenMaterialIdNotFound_ShouldReturnNotFoundError() + { + var client = MakeClient(); + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync(client); + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var command = new RegisterBudgetCommand( + _clientId, + [new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", MaterialUnit.Metro, 1, 10.00m)]) + { 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); + _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs new file mode 100644 index 0000000..eb399ab --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs @@ -0,0 +1,81 @@ +using FluentValidation.TestHelper; +using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class RegisterBudgetCommandValidatorTests +{ + private readonly RegisterBudgetCommandValidator _validator = new(); + + private static RegisterBudgetCommand ValidCommand() => + new(Guid.NewGuid(), [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]); + + [Fact] + public void Validate_WithValidData_ShouldHaveNoErrors() + { + _validator.TestValidate(ValidCommand()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_WithEmptyClientId_ShouldHaveError() + { + var command = ValidCommand() with { ClientId = Guid.Empty }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.ClientId) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + } + + [Fact] + public void Validate_WithEmptyItems_ShouldHaveError() + { + var command = ValidCommand() with { Items = [] }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Items) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithEmptyItemMaterialName_ShouldHaveError(string? name) + { + var command = ValidCommand() with + { + Items = [new RegisterBudgetItemCommand(null, name!, MaterialUnit.Metro, 1, 10.00m)] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].MaterialName") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Validate_WithInvalidItemQuantity_ShouldHaveError(int quantity) + { + var command = ValidCommand() with + { + Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, quantity, 15.50m)] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].Quantity") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + } + + [Theory] + [InlineData(0)] + [InlineData(-0.01)] + public void Validate_WithInvalidItemUnitPrice_ShouldHaveError(double price) + { + var command = ValidCommand() with + { + Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 1, (decimal)price)] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].UnitPrice") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs new file mode 100644 index 0000000..7abfa31 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs @@ -0,0 +1,79 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets; +using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.ValueObjects; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Queries; + +public class GetBudgetByIdQueryHandlerTests +{ + private readonly Mock _budgetReadRepoMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + + private GetBudgetByIdQueryHandler CreateHandler() => + new(_budgetReadRepoMock.Object); + + private static Budget MakeBudgetWithItemsAndClient(Guid userId, Guid budgetId) + { + var client = Client.Register(userId, "Maria Souza", "(21) 98888-7777", + Email.Create("maria@example.com").Value, + Address.Create("Av. Brasil", "500", "Rio de Janeiro", "RJ", "20040-020")); + + var budget = Budget.Register(userId, client.Id); + typeof(Budget).GetProperty("Id")!.SetValue(budget, budgetId); + budget.AddItem(BudgetItem.Create(budgetId, null, "Fio elétrico", MaterialUnit.Metro, 5, 8.00m)); + typeof(Budget).GetProperty("Client")!.SetValue(budget, client); + + return budget; + } + + [Fact] + public async Task Handle_WhenBudgetExists_ShouldReturnDetailResponse() + { + var budget = MakeBudgetWithItemsAndClient(_userId, _budgetId); + _budgetReadRepoMock + .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var handler = CreateHandler(); + var result = await handler.Handle( + new GetBudgetByIdQuery(_budgetId) { UserId = _userId }, + CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Id.ShouldBe(_budgetId); + result.Value.Status.ShouldBe(BudgetStatus.Draft); + result.Value.TotalAmount.ShouldBe(40.00m); + result.Value.Client.Name.ShouldBe("Maria Souza"); + result.Value.Client.Phone.ShouldBe("(21) 98888-7777"); + result.Value.Client.Email.ShouldBe("maria@example.com"); + result.Value.Items.Count.ShouldBe(1); + result.Value.Items[0].MaterialName.ShouldBe("Fio elétrico"); + result.Value.Items[0].TotalPrice.ShouldBe(40.00m); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFoundError() + { + _budgetReadRepoMock + .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var handler = CreateHandler(); + var result = await handler.Handle( + new GetBudgetByIdQuery(_budgetId) { UserId = _userId }, + CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs new file mode 100644 index 0000000..9e29e88 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs @@ -0,0 +1,70 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets; +using Voltiq.Application.Features.Budgets.Queries.GetBudgets; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.ValueObjects; + +namespace Voltiq.Application.Tests.Features.Budgets.Queries; + +public class GetBudgetsQueryHandlerTests +{ + private readonly Mock _budgetReadRepoMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + + private GetBudgetsQueryHandler CreateHandler() => + new(_budgetReadRepoMock.Object); + + private static Budget MakeBudgetWithClient(Guid userId) + { + var client = Client.Register(userId, "João Silva", "(11) 99999-9999", + Email.Create("joao@example.com").Value, + Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + + var budget = Budget.Register(userId, client.Id); + budget.AddItem(BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)); + + typeof(Budget).GetProperty("Client")!.SetValue(budget, client); + + return budget; + } + + [Fact] + public async Task Handle_ShouldReturnBudgetSummariesForUser() + { + var budgets = new List { MakeBudgetWithClient(_userId) }; + _budgetReadRepoMock + .Setup(r => r.GetByUserIdWithClientAsync(_userId, It.IsAny())) + .ReturnsAsync(budgets); + + var handler = CreateHandler(); + var result = await handler.Handle(new GetBudgetsQuery { UserId = _userId }, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.Count.ShouldBe(1); + + var summary = result.Value[0]; + summary.Id.ShouldBe(budgets[0].Id); + summary.Status.ShouldBe(BudgetStatus.Draft); + summary.TotalAmount.ShouldBe(31.00m); + summary.Client.Name.ShouldBe("João Silva"); + } + + [Fact] + public async Task Handle_WhenNoBudgets_ShouldReturnEmptyList() + { + _budgetReadRepoMock + .Setup(r => r.GetByUserIdWithClientAsync(_userId, It.IsAny())) + .ReturnsAsync([]); + + var handler = CreateHandler(); + var result = await handler.Handle(new GetBudgetsQuery { UserId = _userId }, CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Value.ShouldBeEmpty(); + } +} From ce421507322b73eddbb6437efb56acce3c65a62d Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 20:12:21 -0300 Subject: [PATCH 09/36] feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS resource message --- src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx index 3276843..69e1261 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -198,6 +198,9 @@ O preço unitário deve ser maior que zero. + + + O orçamento deve conter pelo menos um item. Orçamento não encontrado. From 0608e7f534541ec0a811248b1859b2041e7a6de4 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 20:12:49 -0300 Subject: [PATCH 10/36] feat(budgets): add Client nav property to Budget and update BudgetConfiguration --- src/Voltiq.Domain/Entities/Budget.cs | 2 ++ .../Persistence/Configurations/BudgetConfiguration.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Voltiq.Domain/Entities/Budget.cs b/src/Voltiq.Domain/Entities/Budget.cs index fff01fc..a262bc7 100644 --- a/src/Voltiq.Domain/Entities/Budget.cs +++ b/src/Voltiq.Domain/Entities/Budget.cs @@ -16,6 +16,8 @@ public sealed class Budget : AuditableEntity private readonly List _items = []; public IReadOnlyCollection Items => _items.AsReadOnly(); + public Client Client { get; private set; } = null!; + private Budget() { } private Budget(Guid userId, Guid clientId) diff --git a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs index 1349961..92421d7 100644 --- a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs +++ b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs @@ -30,7 +30,7 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(b => b.UserId) .OnDelete(DeleteBehavior.Restrict); - builder.HasOne() + builder.HasOne(b => b.Client) .WithMany() .HasForeignKey(b => b.ClientId) .OnDelete(DeleteBehavior.Restrict); From a07403b0d04e759717b60434388f33b881d91e29 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 20:19:14 -0300 Subject: [PATCH 11/36] feat(budgets): add GetByUserIdWithClientAsync and GetByIdWithItemsAndClientAsync to IBudgetReadOnlyRepository --- .../Interfaces/Repositories/Budget/IBudgetReadOnlyRepository.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetReadOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetReadOnlyRepository.cs index 2f10736..6ff26c5 100644 --- a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetReadOnlyRepository.cs +++ b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetReadOnlyRepository.cs @@ -8,4 +8,6 @@ public interface IBudgetReadOnlyRepository Task GetByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); Task GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default); Task GetByIdWithItemsAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task> GetByUserIdWithClientAsync(Guid userId, CancellationToken cancellationToken = default); + Task GetByIdWithItemsAndClientAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); } From c91ac955880bd3d0e1811c6b94c0e21d3af1e1d9 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 20:24:29 -0300 Subject: [PATCH 12/36] feat(budgets): implement GetByUserIdWithClientAsync and GetByIdWithItemsAndClientAsync in BudgetRepository --- .../Repositories/Budget/BudgetRepository.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs index 045ada4..73d946d 100644 --- a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs +++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs @@ -83,4 +83,24 @@ public async Task AddAsync(Domain.Entities.Budget entity, { await context.Budgets.AddAsync(entity, cancellationToken); } + + public async Task> GetByUserIdWithClientAsync( + Guid userId, CancellationToken cancellationToken = default) + { + return await context.Budgets + .Include(b => b.Client) + .AsNoTracking() + .Where(b => b.UserId == userId) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdWithItemsAndClientAsync( + Guid id, Guid userId, CancellationToken cancellationToken = default) + { + return await context.Budgets + .Include(b => b.Items) + .Include(b => b.Client) + .AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); + } } From 363afbcdfad4cd814df0d485fb2031fc76c41323 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:34:40 -0300 Subject: [PATCH 13/36] feat(budgets): add ORCAMENTO_ITEMS_OBRIGATORIOS to ResourceErrorMessages Designer --- .../Resources/ResourceErrorMessages.Designer.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index cfdd401..b5c14a6 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -329,6 +329,15 @@ public static string ORCAMENTO_ITEM_QUANTIDADE_INVALIDA { } } + /// + /// Looks up a localized string similar to O orçamento deve conter pelo menos um item.. + /// + public static string ORCAMENTO_ITEMS_OBRIGATORIOS { + get { + return ResourceManager.GetString("ORCAMENTO_ITEMS_OBRIGATORIOS", resourceCulture); + } + } + /// /// Looks up a localized string similar to Orçamento não encontrado.. /// From d08cf031a8c9b973a3860cf7704b0d01fb312090 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:34:51 -0300 Subject: [PATCH 14/36] feat(budgets): add budget response DTOs --- .../Features/Budgets/BudgetResponse.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/Voltiq.Application/Features/Budgets/BudgetResponse.cs diff --git a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs new file mode 100644 index 0000000..8b9fb86 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs @@ -0,0 +1,31 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets; + +public sealed record BudgetSummaryResponse( + Guid Id, + BudgetStatus Status, + decimal TotalAmount, + DateTime CreatedAt, + BudgetClientSummaryResponse Client); + +public sealed record BudgetClientSummaryResponse(Guid Id, string Name); + +public sealed record BudgetDetailResponse( + Guid Id, + BudgetStatus Status, + decimal TotalAmount, + DateTime CreatedAt, + BudgetClientDetailResponse Client, + IReadOnlyList Items); + +public sealed record BudgetClientDetailResponse(Guid Id, string Name, string Phone, string Email); + +public sealed record BudgetItemResponse( + Guid Id, + Guid? MaterialId, + string MaterialName, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice, + decimal TotalPrice); From cbd35920bf213f78c815988a3ba8352a963f7566 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:35:32 -0300 Subject: [PATCH 15/36] feat(budgets): add BudgetMappingExtensions --- .../Budgets/BudgetMappingExtensions.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs new file mode 100644 index 0000000..c7fbab9 --- /dev/null +++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs @@ -0,0 +1,36 @@ +using Voltiq.Application.Features.Budgets; +using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Domain.Entities; + +namespace Voltiq.Application.Mappings.Budgets; + +public static class BudgetMappingExtensions +{ + extension(RegisterBudgetRequest request) + { + public RegisterBudgetCommand ToCommand() => + new(request.ClientId, + request.Items + .Select(i => new RegisterBudgetItemCommand( + i.MaterialId, i.MaterialName, i.Unit, i.Quantity, i.UnitPrice)) + .ToList()); + } + + extension(Budget budget) + { + public BudgetSummaryResponse ToSummaryResponse() => + new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, + new BudgetClientSummaryResponse(budget.Client.Id, budget.Client.Name)); + + public BudgetDetailResponse ToDetailResponse() => + budget.ToDetailResponse(budget.Client); + + public BudgetDetailResponse ToDetailResponse(Client client) => + new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, + new BudgetClientDetailResponse( + client.Id, client.Name, client.Phone, client.Email.Value), + budget.Items.Select(i => new BudgetItemResponse( + i.Id, i.MaterialId, i.MaterialName, i.Unit, + i.Quantity, i.UnitPrice, i.TotalPrice)).ToList()); + } +} From 3f95be5b52c73a4331e0f653e31a808d371c5cc1 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:36:20 -0300 Subject: [PATCH 16/36] feat(budgets): implement RegisterBudgetCommand --- .../RegisterBudget/RegisterBudgetCommand.cs | 19 +++++++ .../RegisterBudgetCommandHandler.cs | 57 +++++++++++++++++++ .../RegisterBudgetCommandValidator.cs | 33 +++++++++++ .../RegisterBudget/RegisterBudgetRequest.cs | 14 +++++ 4 files changed, 123 insertions(+) create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs new file mode 100644 index 0000000..63299c2 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs @@ -0,0 +1,19 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; + +public sealed record RegisterBudgetCommand( + Guid ClientId, + IReadOnlyList Items) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} + +public sealed record RegisterBudgetItemCommand( + Guid? MaterialId, + string MaterialName, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs new file mode 100644 index 0000000..553f095 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs @@ -0,0 +1,57 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Budgets; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; + +public sealed class RegisterBudgetCommandHandler( + IClientReadOnlyRepository clientReadOnly, + IMaterialReadOnlyRepository materialReadOnly, + IBudgetWriteOnlyRepository budgetWriteOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + RegisterBudgetCommand command, CancellationToken cancellationToken) + { + var client = await clientReadOnly.GetByIdAndUserIdAsync( + command.ClientId, command.UserId, cancellationToken); + + if (client is null) + return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + + var materialLookup = new Dictionary(); + foreach (var item in command.Items.Where(i => i.MaterialId.HasValue)) + { + var material = await materialReadOnly.GetByIdAndUserIdAsync( + item.MaterialId!.Value, command.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + + materialLookup[item.MaterialId!.Value] = material; + } + + var budget = Budget.Register(command.UserId, command.ClientId); + + foreach (var item in command.Items) + { + var budgetItem = BudgetItem.Create( + budget.Id, item.MaterialId, item.MaterialName, + item.Unit, item.Quantity, item.UnitPrice); + + budget.AddItem(budgetItem); + } + + await budgetWriteOnly.AddAsync(budget, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return budget.ToDetailResponse(client); + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs new file mode 100644 index 0000000..e8a7f63 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; + +public sealed class RegisterBudgetCommandValidator : AbstractValidator +{ + public RegisterBudgetCommandValidator() + { + RuleFor(x => x.ClientId) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.MaterialName) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + + item.RuleFor(i => i.Quantity) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + + item.RuleFor(i => i.UnitPrice) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + }); + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs new file mode 100644 index 0000000..8fa9a2b --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs @@ -0,0 +1,14 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; + +public sealed record RegisterBudgetRequest( + Guid ClientId, + IReadOnlyList Items); + +public sealed record RegisterBudgetItemRequest( + Guid? MaterialId, + string MaterialName, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); From dd1e4334357f2119077e2dccc64bdeddf1bd1d97 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:36:29 -0300 Subject: [PATCH 17/36] feat(budgets): implement DeleteBudgetCommand --- .../DeleteBudget/DeleteBudgetCommand.cs | 9 ++++++ .../DeleteBudgetCommandHandler.cs | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommandHandler.cs diff --git a/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs new file mode 100644 index 0000000..8a520c0 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommand.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.DeleteBudget; + +public sealed record DeleteBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommandHandler.cs new file mode 100644 index 0000000..38da3d8 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/DeleteBudget/DeleteBudgetCommandHandler.cs @@ -0,0 +1,28 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.DeleteBudget; + +public sealed class DeleteBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + DeleteBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budgetUpdateOnly.Remove(budget); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Deleted; + } +} From 1b175008d318efd86e10342faa30530112d38da9 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:39:37 -0300 Subject: [PATCH 18/36] feat(budgets): implement GetBudgetsQuery --- .../Queries/GetBudgets/GetBudgetsQuery.cs | 9 +++++++++ .../GetBudgets/GetBudgetsQueryHandler.cs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQuery.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQueryHandler.cs diff --git a/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQuery.cs b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQuery.cs new file mode 100644 index 0000000..3914d52 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQuery.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Queries.GetBudgets; + +public sealed record GetBudgetsQuery : IAuthenticatedRequest>> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQueryHandler.cs b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQueryHandler.cs new file mode 100644 index 0000000..be727e4 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgets/GetBudgetsQueryHandler.cs @@ -0,0 +1,19 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Budgets; +using Voltiq.Domain.Interfaces.Repositories.Budget; + +namespace Voltiq.Application.Features.Budgets.Queries.GetBudgets; + +public sealed class GetBudgetsQueryHandler(IBudgetReadOnlyRepository budgetReadOnly) + : IRequestHandler>> +{ + public async Task>> Handle( + GetBudgetsQuery query, CancellationToken cancellationToken) + { + var budgets = await budgetReadOnly.GetByUserIdWithClientAsync( + query.UserId, cancellationToken); + + return budgets.Select(b => b.ToSummaryResponse()).ToList(); + } +} From 4b7350022cde7817b6cf9df3069bf48c0ee6602c Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:39:58 -0300 Subject: [PATCH 19/36] feat(budgets): implement GetBudgetByIdQuery --- .../GetBudgetById/GetBudgetByIdQuery.cs | 9 ++++++++ .../GetBudgetByIdQueryHandler.cs | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQuery.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQueryHandler.cs diff --git a/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQuery.cs b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQuery.cs new file mode 100644 index 0000000..69797e5 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQuery.cs @@ -0,0 +1,9 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Queries.GetBudgetById; + +public sealed record GetBudgetByIdQuery(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQueryHandler.cs b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQueryHandler.cs new file mode 100644 index 0000000..0f32b9e --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetById/GetBudgetByIdQueryHandler.cs @@ -0,0 +1,23 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Mappings.Budgets; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Queries.GetBudgetById; + +public sealed class GetBudgetByIdQueryHandler(IBudgetReadOnlyRepository budgetReadOnly) + : IRequestHandler> +{ + public async Task> Handle( + GetBudgetByIdQuery query, CancellationToken cancellationToken) + { + var budget = await budgetReadOnly.GetByIdWithItemsAndClientAsync( + query.Id, query.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + return budget.ToDetailResponse(); + } +} From ff38c97dc38023a3f32c45c8733208c67af81d03 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 21:40:16 -0300 Subject: [PATCH 20/36] feat(budgets): add BudgetsController --- .../Controllers/Budgets/BudgetsController.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/Voltiq.API/Controllers/Budgets/BudgetsController.cs diff --git a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs new file mode 100644 index 0000000..45e8cc0 --- /dev/null +++ b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs @@ -0,0 +1,70 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Voltiq.Application.Features.Budgets; +using Voltiq.Application.Features.Budgets.Commands.DeleteBudget; +using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; +using Voltiq.Application.Features.Budgets.Queries.GetBudgets; +using Voltiq.Application.Mappings.Budgets; + +namespace Voltiq.API.Controllers.Budgets; + +[ApiVersion("1.0")] +public sealed class BudgetsController : BaseApiController +{ + /// Creates a new budget with the specified client and items. + [HttpPost] + [ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Register( + [FromBody] RegisterBudgetRequest request, + CancellationToken cancellationToken) + { + var result = await Sender.Send(request.ToCommand(), cancellationToken); + + return result.Match( + budget => CreatedAtAction(nameof(GetById), new { id = budget.Id }, budget), + ToErrorResult); + } + + /// Returns all budgets 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 GetBudgetsQuery(), cancellationToken); + + return result.Match(Ok, ToErrorResult); + } + + /// Returns a specific budget by ID with full client and item details. + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(BudgetDetailResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new GetBudgetByIdQuery(id), cancellationToken); + + return result.Match(Ok, ToErrorResult); + } + + /// Deletes a budget (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 DeleteBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } +} From 621a9b0c6ca58a060704957627e0587eb9581aea Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Thu, 9 Apr 2026 21:54:46 -0300 Subject: [PATCH 21/36] feat(budgets): enhance security requirements and update README with seed script instructions --- .gitignore | 7 +++++ README.md | 27 +++++++++++++++++ .../SecurityRequirementsOperationFilter.cs | 29 +++++++++++++++++++ src/Voltiq.API/Program.cs | 10 ++----- 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs diff --git a/.gitignore b/.gitignore index 3952ab9..57ab0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,10 @@ logs/ # Copilot instructions .github/copilot-instructions.md +# Squad: ignore runtime state (logs, inbox, sessions) +.squad/orchestration-log/ +.squad/log/ +.squad/decisions/inbox/ +.squad/sessions/ +# Squad: SubSquad activation file (local to this machine) +.squad-workstream diff --git a/README.md b/README.md index cab4cec..d556cef 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,33 @@ dotnet run --project src/Voltiq.API --- +## Seed de Desenvolvimento + +O script `scripts/seed-dev.cs` popula o banco com dados de exemplo usando a feature de **file-based app** do .NET 10 — sem `.csproj`, sem dependências adicionais. + +```bash +dotnet run scripts/seed-dev.cs +``` + +Por padrão conecta em `Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres`. Para usar outra connection string: + +```bash +VOLTIQ_CONNECTION_STRING="Host=..." dotnet run scripts/seed-dev.cs +``` + +**Dados inseridos:** + +| Entidade | Qtd | Destaques | +|---|---|---| +| Usuário | 1 | `dev@voltiq.dev` / `senha@123` | +| Clientes | 3 | Construtora ABC Ltda, João da Silva, Empresa XYZ S.A. | +| Materiais | 4 | Cabo 2,5mm, Cabo 4mm, Tomada 2P+T, Disjuntor 20A | +| Orçamentos | 2 | Um com 3 itens (materiais + customizado), outro com 1 item | + +O script é **idempotente**: se o usuário `dev@voltiq.dev` já existir, encerra sem inserir nada. + +--- + ## Camadas ### Domain diff --git a/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs b/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs new file mode 100644 index 0000000..d461280 --- /dev/null +++ b/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Voltiq.API.Filters; + +public class SecurityRequirementsOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, + OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var isAnonymous = context.Description.ActionDescriptor + .EndpointMetadata + .OfType() + .Any(); + + if (isAnonymous) return Task.CompletedTask; + + operation.Security = + [ + new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference("Bearer", context.Document), [] } + } + ]; + + return Task.CompletedTask; + } +} diff --git a/src/Voltiq.API/Program.cs b/src/Voltiq.API/Program.cs index cef0d74..5aac374 100644 --- a/src/Voltiq.API/Program.cs +++ b/src/Voltiq.API/Program.cs @@ -3,6 +3,7 @@ using Microsoft.OpenApi; using Serilog; using Voltiq.API.ExceptionHandlers; +using Voltiq.API.Filters; using Voltiq.Application; using Voltiq.Infrastructure; using Voltiq.Infrastructure.Persistence; @@ -83,16 +84,11 @@ prática e eficiente de gerir seus serviços. }; document.Security ??= new List(); - document.Security.Add(new OpenApiSecurityRequirement - { - { - new OpenApiSecuritySchemeReference("Bearer", document), - [] - } - }); return Task.CompletedTask; }); + + o.AddOperationTransformer(); }); builder.Services.AddCors(options => From f9dfb1236b00a5601f39c94235b04758354e40d9 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Thu, 9 Apr 2026 21:58:39 -0300 Subject: [PATCH 22/36] style(budgets): fix formatting in UpdateClient and UpdateMaterial command handler tests --- .../Filters/SecurityRequirementsOperationFilter.cs | 2 +- .../Clients/Commands/UpdateClientCommandHandlerTests.cs | 6 +++--- .../Materials/Commands/UpdateMaterialCommandHandlerTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs b/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs index d461280..97beae4 100644 --- a/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs +++ b/src/Voltiq.API/Filters/SecurityRequirementsOperationFilter.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; diff --git a/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs index 3afac3c..2470c0f 100644 --- a/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Clients/Commands/UpdateClientCommandHandlerTests.cs @@ -48,7 +48,7 @@ public async Task Handle_WithValidCommand_ShouldUpdateClientAndReturnUpdated() var command = new UpdateClientCommand( client.Id, "Maria Souza", "(11) 88888-8888", "maria@example.com", "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -74,7 +74,7 @@ public async Task Handle_WhenEmailAlreadyExistsForAnotherClient_ShouldReturnConf var command = new UpdateClientCommand( client.Id, "Maria Souza", "(11) 88888-8888", "outro@example.com", "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -98,7 +98,7 @@ public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() var command = new UpdateClientCommand( Guid.NewGuid(), "Maria Souza", "(11) 88888-8888", "maria@example.com", "Av. Paulista", "1000", "São Paulo", "SP", "01311-100") - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs index 4481f57..e107991 100644 --- a/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Materials/Commands/UpdateMaterialCommandHandlerTests.cs @@ -38,7 +38,7 @@ public async Task Handle_WithValidCommand_ShouldUpdateMaterialAndReturnUpdated() .ReturnsAsync(material); var command = new UpdateMaterialCommand(material.Id, "Fio 6mm", 8.00m, MaterialUnit.Unidade) - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -57,7 +57,7 @@ public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFoundError() var command = new UpdateMaterialCommand(Guid.NewGuid(), "Fio 6mm", 8.00m, MaterialUnit.Unidade) - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); From 387c0b91d2326240ad3011abe871def7008723bc Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Thu, 9 Apr 2026 22:01:40 -0300 Subject: [PATCH 23/36] feat(budgets): add development seed script for initial data population --- scripts/seed-dev.cs | 157 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 scripts/seed-dev.cs diff --git a/scripts/seed-dev.cs b/scripts/seed-dev.cs new file mode 100644 index 0000000..15efd0d --- /dev/null +++ b/scripts/seed-dev.cs @@ -0,0 +1,157 @@ +#:sdk Microsoft.NET.Sdk +#:property TargetFramework=net10.0 +#:property Nullable=enable +#:property ImplicitUsings=enable +#:property PublishAot=false +#:project ../src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj +#:project ../src/Voltiq.Domain/Voltiq.Domain.csproj +#:project ../src/Voltiq.Application/Voltiq.Application.csproj + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.ValueObjects; +using Voltiq.Infrastructure.Auth; +using Voltiq.Infrastructure.Persistence; +using Voltiq.Infrastructure.Persistence.Interceptors; + +// ─── Configuração ──────────────────────────────────────────────────────────── + +const string SEED_EMAIL = "dev@voltiq.dev"; +const string SEED_PASSWORD = "senha@123"; + +var configuration = new ConfigurationBuilder() + .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "../src/Voltiq.Api")) + .AddJsonFile("appsettings.json", false) + .AddJsonFile("appsettings.Development.json", true) + .AddEnvironmentVariables() + .Build(); + +var connectionString = + configuration.GetConnectionString("DefaultConnection") + ?? "Host=localhost;Database=VoltiqDb;Port=5433;Username=postgres;Password=postgres"; + +// ─── Stub de serviços ──────────────────────────────────────────────────────── + +var currentUserService = new SeedCurrentUserService(); + +var interceptor = new SoftDeleteInterceptor(); + +var dbOptions = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .AddInterceptors(interceptor) + .Options; + +await using var db = new ApplicationDbContext(dbOptions, currentUserService); + +// ─── Idempotência ───────────────────────────────────────────────────────────── + +var email = Email.Create(SEED_EMAIL).Value; +var existingUser = await db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Email == email); + +if (existingUser is not null) +{ + Console.WriteLine( + $@"⚠️ Seed já aplicado (usuário '{SEED_EMAIL}' já existe). Nenhum dado foi inserido."); + return; +} + +Console.WriteLine(@"🌱 Iniciando seed de desenvolvimento..."); + +// ─── Usuário ───────────────────────────────────────────────────────────────── + +var hasher = new Argon2PasswordHasher(); +var passwordHash = hasher.Hash(SEED_PASSWORD); + +var userDocument = Document.Create("529.982.247-25").Value; +var user = User.Register("Dev Voltiq", email, userDocument, passwordHash); + +await db.Users.AddAsync(user); +await db.SaveChangesAsync(); + +currentUserService.SetUserId(user.Id); + +Console.WriteLine($@"✅ Usuário criado: {user.Name} <{SEED_EMAIL}>"); + +// ─── Clientes ───────────────────────────────────────────────────────────────── + +var clients = new[] +{ + Client.Register(user.Id, "Construtora ABC Ltda", "(11) 3333-4444", + Email.Create("contato@construtorabc.com.br").Value, + Address.Create("Av. Paulista", "1000", "São Paulo", "SP", "01310-100")), + + Client.Register(user.Id, "João da Silva", "(21) 99999-1234", + Email.Create("joao.silva@gmail.com").Value, + Address.Create("Rua das Flores", "45", "Rio de Janeiro", "RJ", "20040-020")), + + Client.Register(user.Id, "Empresa XYZ S.A.", "(51) 3200-5678", + Email.Create("financeiro@xyz.com.br").Value, + Address.Create("Rua dos Andradas", "800", "Porto Alegre", "RS", "90020-004")) +}; + +await db.Clients.AddRangeAsync(clients); +await db.SaveChangesAsync(); + +Console.WriteLine($@"✅ {clients.Length} clientes criados."); + +// ─── Materiais ──────────────────────────────────────────────────────────────── + +var materials = new[] +{ + Material.Register(user.Id, "Cabo Flexível 2,5mm", 4.80m, MaterialUnit.Metro), + Material.Register(user.Id, "Cabo Flexível 4mm", 7.20m, MaterialUnit.Metro), + Material.Register(user.Id, "Tomada 2P+T", 18.50m, MaterialUnit.Unidade), + Material.Register(user.Id, "Disjuntor 20A", 32.00m, MaterialUnit.Unidade) +}; + +await db.Materials.AddRangeAsync(materials); +await db.SaveChangesAsync(); + +Console.WriteLine($@"✅ {materials.Length} materiais criados."); + +// ─── Orçamentos ─────────────────────────────────────────────────────────────── + +// Orçamento 1: Construtora ABC — mix de material vinculado + item customizado +var budget1 = Budget.Register(user.Id, clients[0].Id); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm", + MaterialUnit.Metro, 50, 4.80m)); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade, + 8, 18.50m)); +budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m)); + +await db.Budgets.AddAsync(budget1); +await db.SaveChangesAsync(); + +// Orçamento 2: João da Silva — item customizado +var budget2 = Budget.Register(user.Id, clients[1].Id); +budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A", + MaterialUnit.Unidade, 2, 32.00m)); + +await db.Budgets.AddAsync(budget2); +await db.SaveChangesAsync(); + +Console.WriteLine(@"✅ 2 orçamentos criados."); +Console.WriteLine(); +Console.WriteLine(@"🎉 Seed concluído! Acesse com:"); +Console.WriteLine($@" E-mail: {SEED_EMAIL}"); +Console.WriteLine($@" Senha: {SEED_PASSWORD}"); + +// ─── Stub ───────────────────────────────────────────────────────────────────── + +internal sealed class SeedCurrentUserService : ICurrentUserService +{ + public Guid UserId { get; private set; } = Guid.Empty; + + public string UserName => "seed"; + public bool IsAuthenticated => true; + + public void SetUserId(Guid id) + { + UserId = id; + } +} From dfec73fdbd502586a4bbb729eb5a87d24ae01617 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Mon, 25 May 2026 21:26:44 -0300 Subject: [PATCH 24/36] chore(git): update .gitignore to ignore agent directories --- .gitignore | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 57ab0a2..c4eda6c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,13 +49,6 @@ nuget.config .vs/ *.rsuser -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - # OS .DS_Store Thumbs.db @@ -69,12 +62,7 @@ appsettings.Production.json # Logs logs/ + # Copilot instructions .github/copilot-instructions.md -# Squad: ignore runtime state (logs, inbox, sessions) -.squad/orchestration-log/ -.squad/log/ -.squad/decisions/inbox/ -.squad/sessions/ -# Squad: SubSquad activation file (local to this machine) -.squad-workstream +.agents From 5bb09ba69e93d97b0f54777d87575086f3451681 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Mon, 25 May 2026 21:26:57 -0300 Subject: [PATCH 25/36] feat(domain): add BudgetItemType enum and validations --- src/Voltiq.Domain/Entities/BudgetItem.cs | 33 ++++- src/Voltiq.Domain/Enums/BudgetItemType.cs | 8 ++ .../ResourceErrorMessages.Designer.cs | 45 +++++++ .../Resources/ResourceErrorMessages.resx | 15 +++ .../Entities/BudgetItemTests.cs | 127 ++++++++++++++++++ .../Entities/BudgetTests.cs | 6 +- 6 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 src/Voltiq.Domain/Enums/BudgetItemType.cs create mode 100644 tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs diff --git a/src/Voltiq.Domain/Entities/BudgetItem.cs b/src/Voltiq.Domain/Entities/BudgetItem.cs index f932d03..d2d467d 100644 --- a/src/Voltiq.Domain/Entities/BudgetItem.cs +++ b/src/Voltiq.Domain/Entities/BudgetItem.cs @@ -9,6 +9,7 @@ public sealed class BudgetItem : BaseEntity public Guid BudgetId { get; private set; } public Guid? MaterialId { get; private set; } public string MaterialName { get; private set; } = null!; + public BudgetItemType Type { get; private set; } public MaterialUnit? Unit { get; private set; } public int Quantity { get; private set; } public decimal UnitPrice { get; private set; } @@ -19,27 +20,30 @@ private BudgetItem() { } private BudgetItem( Guid budgetId, Guid? materialId, - string materialName, + BudgetItemType type, MaterialUnit? unit, int quantity, - decimal unitPrice) + decimal unitPrice, + string materialName) { BudgetId = budgetId; MaterialId = materialId; - MaterialName = materialName; + Type = type; Unit = unit; Quantity = quantity; UnitPrice = unitPrice; TotalPrice = quantity * unitPrice; + MaterialName = materialName; } public static BudgetItem Create( Guid budgetId, Guid? materialId, - string materialName, + BudgetItemType type, MaterialUnit? unit, int quantity, - decimal unitPrice) + decimal unitPrice, + string materialName) { if (string.IsNullOrWhiteSpace(materialName)) throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); @@ -50,6 +54,23 @@ public static BudgetItem Create( if (unitPrice <= 0) throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); - return new BudgetItem(budgetId, materialId, materialName.Trim(), unit, quantity, unitPrice); + if (type == BudgetItemType.Material) + { + if (materialId is null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + if (unit is null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + } + else + { + if (materialId is not null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + if (unit is not null) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + } + + return new BudgetItem(budgetId, materialId, type, unit, quantity, unitPrice, materialName.Trim()); } } diff --git a/src/Voltiq.Domain/Enums/BudgetItemType.cs b/src/Voltiq.Domain/Enums/BudgetItemType.cs new file mode 100644 index 0000000..82aa5f5 --- /dev/null +++ b/src/Voltiq.Domain/Enums/BudgetItemType.cs @@ -0,0 +1,8 @@ +namespace Voltiq.Domain.Enums; + +public enum BudgetItemType +{ + Material = 1, + MaoDeObra = 2, + Outros = 3, +} diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index b5c14a6..5d699e9 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -302,6 +302,24 @@ public static string ORCAMENTO_CLIENTE_OBRIGATORIO { } } + /// + /// Looks up a localized string similar to O material deve ser nulo para este tipo de item.. + /// + public static string ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O material é obrigatório para itens do tipo Material.. + /// + public static string ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL", resourceCulture); + } + } + /// /// Looks up a localized string similar to O nome do material no item é obrigatório.. /// @@ -329,6 +347,33 @@ public static string ORCAMENTO_ITEM_QUANTIDADE_INVALIDA { } } + /// + /// Looks up a localized string similar to Tipo de item do orçamento inválido.. + /// + public static string ORCAMENTO_ITEM_TIPO_INVALIDO { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_TIPO_INVALIDO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A unidade deve ser nula para este tipo de item.. + /// + public static string ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A unidade é obrigatória para itens do tipo Material.. + /// + public static string ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL { + get { + return ResourceManager.GetString("ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL", resourceCulture); + } + } + /// /// Looks up a localized string similar to O orçamento deve conter pelo menos um item.. /// diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx index 69e1261..61c3412 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -198,6 +198,21 @@ O preço unitário deve ser maior que zero. + + + Tipo de item do orçamento inválido. + + + O material é obrigatório para itens do tipo Material. + + + A unidade é obrigatória para itens do tipo Material. + + + O material deve ser nulo para este tipo de item. + + + A unidade deve ser nula para este tipo de item. O orçamento deve conter pelo menos um item. diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs new file mode 100644 index 0000000..2b85f8e --- /dev/null +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs @@ -0,0 +1,127 @@ +using Shouldly; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Exceptions; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Domain.Tests.Entities; + +public class BudgetItemTests +{ + private static readonly Guid ValidBudgetId = Guid.NewGuid(); + private static readonly Guid ValidMaterialId = Guid.NewGuid(); + private const string ValidName = "Cabo 10mm"; + + private static BudgetItem ValidMaterialItemCreate() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, ValidName); + + [Fact] + public void Create_MaterialTypeWithValidData_ShouldSucceed() + { + var item = ValidMaterialItemCreate(); + + item.Type.ShouldBe(BudgetItemType.Material); + item.MaterialId.ShouldNotBe(null); + item.MaterialId.ShouldBe(ValidMaterialId); + item.Unit.ShouldBe(MaterialUnit.Metro); + } + + [Fact] + public void Create_MaoDeObraTypeWithValidData_ShouldSucceed() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 8, 120m, "Mão de obra elétrica"); + + item.Type.ShouldBe(BudgetItemType.MaoDeObra); + item.MaterialId.ShouldBeNull(); + item.Unit.ShouldBeNull(); + } + + [Fact] + public void Create_OutrosTypeWithValidData_ShouldSucceed() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Outros, null, 1, 50m, "Deslocamento"); + + item.Type.ShouldBe(BudgetItemType.Outros); + item.MaterialId.ShouldBeNull(); + item.Unit.ShouldBeNull(); + } + + [Fact] + public void Create_MaterialTypeWithoutMaterialId_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, ValidName)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + } + + [Fact] + public void Create_MaterialTypeWithoutUnit_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, null, 2, 15.50m, ValidName)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + } + + [Fact] + public void Create_MaoDeObraTypeWithMaterialId_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.MaoDeObra, null, 8, 120m, "Mão de obra")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_MaoDeObraTypeWithUnit_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, MaterialUnit.Metro, 8, 120m, "Mão de obra")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_OutrosTypeWithMaterialId_ShouldThrowDomainException() + { + var ex = Should.Throw(() => + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Outros, null, 1, 50m, "Deslocamento")); + + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void Create_WithEmptyName_ShouldThrowDomainException() + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 2, 10m, "")) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithInvalidQuantity_ShouldThrowDomainException(int quantity) + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, quantity, 10m, ValidName)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithInvalidUnitPrice_ShouldThrowDomainException(decimal unitPrice) + { + Should.Throw(() => + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, unitPrice, ValidName)) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + } + + [Fact] + public void Create_MaoDeObraType_ShouldCalculateTotalPriceCorrectly() + { + var item = BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, 10m, "Instalação"); + + item.TotalPrice.ShouldBe(30m); + } +} diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs index bd3dd0f..f000e3a 100644 --- a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs @@ -52,7 +52,7 @@ public void Register_WithEmptyUserId_ShouldThrowDomainException() public void AddItem_ShouldRecalculateTotalAmount() { var budget = Budget.Register(ValidUserId, ValidClientId); - var item = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); @@ -64,8 +64,8 @@ public void AddItem_ShouldRecalculateTotalAmount() public void AddMultipleItems_ShouldSumAllTotals() { var budget = Budget.Register(ValidUserId, ValidClientId); - var item1 = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 10m); - var item2 = BudgetItem.Create(budget.Id, null, "Disjuntor", MaterialUnit.Unidade, 1, 50m); + var item1 = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo 10mm"); + var item2 = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); budget.AddItem(item1); budget.AddItem(item2); From 314a2353421b584baf3152a8da19786b55424fd6 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Mon, 25 May 2026 21:26:57 -0300 Subject: [PATCH 26/36] feat(application): update budget commands to support item type --- .../Features/Budgets/BudgetResponse.cs | 1 + .../RegisterBudget/RegisterBudgetCommand.cs | 1 + .../RegisterBudgetCommandHandler.cs | 3 +- .../RegisterBudgetCommandValidator.cs | 25 +++++++ .../RegisterBudget/RegisterBudgetRequest.cs | 1 + .../Budgets/BudgetMappingExtensions.cs | 8 +-- .../RegisterBudgetCommandHandlerTests.cs | 66 ++++++++++++------- .../RegisterBudgetCommandValidatorTests.cs | 27 ++++++-- .../Queries/GetBudgetByIdQueryHandlerTests.cs | 18 +++-- .../Queries/GetBudgetsQueryHandlerTests.cs | 2 +- 10 files changed, 111 insertions(+), 41 deletions(-) diff --git a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs index 8b9fb86..8a38daf 100644 --- a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs +++ b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs @@ -25,6 +25,7 @@ public sealed record BudgetItemResponse( Guid Id, Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice, diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs index 63299c2..abb0e8b 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommand.cs @@ -14,6 +14,7 @@ public sealed record RegisterBudgetCommand( public sealed record RegisterBudgetItemCommand( Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs index 553f095..a88a14e 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandHandler.cs @@ -43,8 +43,7 @@ public async Task> Handle( foreach (var item in command.Items) { var budgetItem = BudgetItem.Create( - budget.Id, item.MaterialId, item.MaterialName, - item.Unit, item.Quantity, item.UnitPrice); + budget.Id, item.MaterialId, item.Type, item.Unit, item.Quantity, item.UnitPrice, item.MaterialName); budget.AddItem(budgetItem); } diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs index e8a7f63..a6aa842 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Voltiq.Domain.Enums; using Voltiq.Exceptions.Resources; namespace Voltiq.Application.Features.Budgets.Commands.RegisterBudget; @@ -21,6 +22,30 @@ public RegisterBudgetCommandValidator() .NotEmpty() .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + item.RuleFor(i => i.Type) + .IsInEnum() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_TIPO_INVALIDO); + + item.RuleFor(i => i.MaterialId) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + item.RuleFor(i => i.MaterialId) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + item.RuleFor(i => i.Unit) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + + item.RuleFor(i => i.Unit) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + item.RuleFor(i => i.Quantity) .GreaterThan(0) .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs index 8fa9a2b..d993067 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/RegisterBudget/RegisterBudgetRequest.cs @@ -9,6 +9,7 @@ public sealed record RegisterBudgetRequest( public sealed record RegisterBudgetItemRequest( Guid? MaterialId, string MaterialName, + BudgetItemType Type, MaterialUnit? Unit, int Quantity, decimal UnitPrice); diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs index c7fbab9..1f726bc 100644 --- a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs +++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs @@ -12,7 +12,7 @@ public RegisterBudgetCommand ToCommand() => new(request.ClientId, request.Items .Select(i => new RegisterBudgetItemCommand( - i.MaterialId, i.MaterialName, i.Unit, i.Quantity, i.UnitPrice)) + i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice)) .ToList()); } @@ -20,17 +20,17 @@ public RegisterBudgetCommand ToCommand() => { public BudgetSummaryResponse ToSummaryResponse() => new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, - new BudgetClientSummaryResponse(budget.Client.Id, budget.Client.Name)); + new BudgetClientSummaryResponse(budget.Client!.Id, budget.Client!.Name)); public BudgetDetailResponse ToDetailResponse() => - budget.ToDetailResponse(budget.Client); + budget.ToDetailResponse(budget.Client!); public BudgetDetailResponse ToDetailResponse(Client client) => new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, new BudgetClientDetailResponse( client.Id, client.Name, client.Phone, client.Email.Value), budget.Items.Select(i => new BudgetItemResponse( - i.Id, i.MaterialId, i.MaterialName, i.Unit, + i.Id, i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice, i.TotalPrice)).ToList()); } } diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs index 4af50b2..1c67577 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs @@ -1,7 +1,6 @@ using ErrorOr; using Moq; using Shouldly; -using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; using Voltiq.Domain.Entities; using Voltiq.Domain.Enums; @@ -16,26 +15,36 @@ namespace Voltiq.Application.Tests.Features.Budgets.Commands; public class RegisterBudgetCommandHandlerTests { + private readonly Mock _budgetWriteRepoMock = new(); + private readonly Guid _clientId = Guid.NewGuid(); private readonly Mock _clientReadRepoMock = new(); private readonly Mock _materialReadRepoMock = new(); - private readonly Mock _budgetWriteRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); private readonly Guid _userId = Guid.NewGuid(); - private readonly Guid _clientId = Guid.NewGuid(); - private RegisterBudgetCommandHandler CreateHandler() => - new(_clientReadRepoMock.Object, _materialReadRepoMock.Object, + private RegisterBudgetCommandHandler CreateHandler() + { + return new RegisterBudgetCommandHandler(_clientReadRepoMock.Object, + _materialReadRepoMock.Object, _budgetWriteRepoMock.Object, _unitOfWorkMock.Object); + } - private Client MakeClient() => - Client.Register(_userId, "João Silva", "(11) 99999-9999", + private Client MakeClient() + { + return Client.Register(_userId, "João Silva", "(11) 99999-9999", Email.Create("joao@example.com").Value, Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + } - private RegisterBudgetCommand CommandWithCustomItem() => - new(_clientId, [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]) - { UserId = _userId }; + private RegisterBudgetCommand CommandWithCustomItem() + { + return new RegisterBudgetCommand(_clientId, [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + 2, 15.50m) + ]) + { UserId = _userId }; + } [Fact] public async Task Handle_WithCustomItems_ShouldRegisterBudgetAndReturnDetailResponse() @@ -57,7 +66,8 @@ public async Task Handle_WithCustomItems_ShouldRegisterBudgetAndReturnDetailResp result.Value.Items[0].MaterialName.ShouldBe("Cabo 10mm"); result.Value.Items[0].TotalPrice.ShouldBe(31.00m); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); } @@ -76,23 +86,29 @@ public async Task Handle_WithMaterialId_ShouldValidateMaterialAndRegisterBudget( .ReturnsAsync(material); var command = new RegisterBudgetCommand( - _clientId, - [new RegisterBudgetItemCommand(materialId, "Cabo 10mm", MaterialUnit.Metro, 3, 10.00m)]) - { UserId = _userId }; + _clientId, + [ + new RegisterBudgetItemCommand(materialId, "Cabo 10mm", BudgetItemType.Material, + MaterialUnit.Metro, 3, 10.00m) + ]) + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); result.IsError.ShouldBeFalse(); result.Value.Items[0].MaterialId.ShouldBe(materialId); - _materialReadRepoMock.Verify(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny()), Times.Once); + _materialReadRepoMock.Verify( + r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny()), + Times.Once); } [Fact] public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() { _clientReadRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => + r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) .ReturnsAsync((Client?)null); var handler = CreateHandler(); @@ -101,7 +117,8 @@ public async Task Handle_WhenClientNotFound_ShouldReturnNotFoundError() result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); result.FirstError.Description.ShouldBe(ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -112,13 +129,17 @@ public async Task Handle_WhenMaterialIdNotFound_ShouldReturnNotFoundError() .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) .ReturnsAsync(client); _materialReadRepoMock - .Setup(r => r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) + .Setup(r => + r.GetByIdAndUserIdAsync(It.IsAny(), _userId, It.IsAny())) .ReturnsAsync((Material?)null); var command = new RegisterBudgetCommand( - _clientId, - [new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", MaterialUnit.Metro, 1, 10.00m)]) - { UserId = _userId }; + _clientId, + [ + new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", + BudgetItemType.Material, MaterialUnit.Metro, 1, 10.00m) + ]) + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -126,6 +147,7 @@ [new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", MaterialUnit.Metro, result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); result.FirstError.Description.ShouldBe(ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); - _budgetWriteRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _budgetWriteRepoMock.Verify( + r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs index eb399ab..7ef9d1f 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandValidatorTests.cs @@ -9,8 +9,13 @@ public class RegisterBudgetCommandValidatorTests { private readonly RegisterBudgetCommandValidator _validator = new(); - private static RegisterBudgetCommand ValidCommand() => - new(Guid.NewGuid(), [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)]); + private static RegisterBudgetCommand ValidCommand() + { + return new RegisterBudgetCommand(Guid.NewGuid(), [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, + null, 2, 15.50m) + ]); + } [Fact] public void Validate_WithValidData_ShouldHaveNoErrors() @@ -44,7 +49,11 @@ public void Validate_WithEmptyItemMaterialName_ShouldHaveError(string? name) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, name!, MaterialUnit.Metro, 1, 10.00m)] + Items = + [ + new RegisterBudgetItemCommand(null, name!, BudgetItemType.MaoDeObra, null, 1, + 10.00m) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].MaterialName") @@ -58,7 +67,11 @@ public void Validate_WithInvalidItemQuantity_ShouldHaveError(int quantity) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, quantity, 15.50m)] + Items = + [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + quantity, 15.50m) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].Quantity") @@ -72,7 +85,11 @@ public void Validate_WithInvalidItemUnitPrice_ShouldHaveError(double price) { var command = ValidCommand() with { - Items = [new RegisterBudgetItemCommand(null, "Cabo 10mm", MaterialUnit.Metro, 1, (decimal)price)] + Items = + [ + new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, 1, + (decimal)price) + ] }; _validator.TestValidate(command) .ShouldHaveValidationErrorFor("Items[0].UnitPrice") diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs index 7abfa31..cdff9a8 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetByIdQueryHandlerTests.cs @@ -1,7 +1,6 @@ using ErrorOr; using Moq; using Shouldly; -using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; using Voltiq.Domain.Entities; using Voltiq.Domain.Enums; @@ -13,13 +12,15 @@ namespace Voltiq.Application.Tests.Features.Budgets.Queries; public class GetBudgetByIdQueryHandlerTests { + private readonly Guid _budgetId = Guid.NewGuid(); private readonly Mock _budgetReadRepoMock = new(); private readonly Guid _userId = Guid.NewGuid(); - private readonly Guid _budgetId = Guid.NewGuid(); - private GetBudgetByIdQueryHandler CreateHandler() => - new(_budgetReadRepoMock.Object); + private GetBudgetByIdQueryHandler CreateHandler() + { + return new GetBudgetByIdQueryHandler(_budgetReadRepoMock.Object); + } private static Budget MakeBudgetWithItemsAndClient(Guid userId, Guid budgetId) { @@ -29,7 +30,8 @@ private static Budget MakeBudgetWithItemsAndClient(Guid userId, Guid budgetId) var budget = Budget.Register(userId, client.Id); typeof(Budget).GetProperty("Id")!.SetValue(budget, budgetId); - budget.AddItem(BudgetItem.Create(budgetId, null, "Fio elétrico", MaterialUnit.Metro, 5, 8.00m)); + budget.AddItem(BudgetItem.Create(budgetId, null, BudgetItemType.MaoDeObra, null, 5, 8.00m, + "Fio elétrico")); typeof(Budget).GetProperty("Client")!.SetValue(budget, client); return budget; @@ -40,7 +42,8 @@ public async Task Handle_WhenBudgetExists_ShouldReturnDetailResponse() { var budget = MakeBudgetWithItemsAndClient(_userId, _budgetId); _budgetReadRepoMock - .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .Setup(r => + r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync(budget); var handler = CreateHandler(); @@ -64,7 +67,8 @@ public async Task Handle_WhenBudgetExists_ShouldReturnDetailResponse() public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFoundError() { _budgetReadRepoMock - .Setup(r => r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) + .Setup(r => + r.GetByIdWithItemsAndClientAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync((Budget?)null); var handler = CreateHandler(); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs index 9e29e88..663555a 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetsQueryHandlerTests.cs @@ -26,7 +26,7 @@ private static Budget MakeBudgetWithClient(Guid userId) Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); var budget = Budget.Register(userId, client.Id); - budget.AddItem(BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m)); + budget.AddItem(BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm")); typeof(Budget).GetProperty("Client")!.SetValue(budget, client); From b583aed2018d28a3040a48cefd25188a59ff3a82 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Mon, 25 May 2026 21:26:58 -0300 Subject: [PATCH 27/36] feat(infrastructure): map BudgetItemType and generate migration --- ...260526002045_AddBudgetItemType.Designer.cs | 457 ++++++++++++++++++ .../20260526002045_AddBudgetItemType.cs | 29 ++ .../ApplicationDbContextModelSnapshot.cs | 7 +- .../Configurations/BudgetItemConfiguration.cs | 4 + .../Persistence/BudgetRepositoryTests.cs | 2 +- 5 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs create mode 100644 src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs diff --git a/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs new file mode 100644 index 0000000..580dfd7 --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.Designer.cs @@ -0,0 +1,457 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Voltiq.Infrastructure.Persistence; + +#nullable disable + +namespace Voltiq.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260526002045_AddBudgetItemType")] + partial class AddBudgetItemType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PdfUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .HasDatabaseName("IX_Budgets_ClientId"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_Budgets_IsDeleted"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Budgets_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_Budgets_UserId"); + + b.ToTable("Budgets"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("MaterialId") + .HasColumnType("uuid"); + + b.Property("MaterialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("TotalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId") + .HasDatabaseName("IX_BudgetItems_BudgetId"); + + b.HasIndex("MaterialId") + .HasDatabaseName("IX_BudgetItems_MaterialId"); + + b.ToTable("BudgetItem"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("Email"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_Clients_IsDeleted"); + + b.HasIndex("UserId", "Email") + .IsUnique() + .HasDatabaseName("IX_Clients_UserId_Email"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Material", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DefaultPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Unit") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_Materials_IsActive"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_Materials_IsDeleted"); + + b.HasIndex("Name") + .HasDatabaseName("IX_Materials_Name"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_Materials_UserId"); + + b.ToTable("Materials"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Document") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("Document"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("Email"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Document") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_Users_IsDeleted"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b => + { + b.HasOne("Voltiq.Domain.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Voltiq.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b => + { + b.HasOne("Voltiq.Domain.Entities.Budget", null) + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Voltiq.Domain.Entities.Material", null) + .WithMany() + .HasForeignKey("MaterialId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Client", b => + { + b.HasOne("Voltiq.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Voltiq.Domain.ValueObjects.Address", "Address", b1 => + { + b1.Property("ClientId") + .HasColumnType("uuid"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("City"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Number"); + + b1.Property("State") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("State"); + + b1.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("Street"); + + b1.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("ZipCode"); + + b1.HasKey("ClientId"); + + b1.ToTable("Clients"); + + b1.WithOwner() + .HasForeignKey("ClientId"); + }); + + b.Navigation("Address") + .IsRequired(); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Material", b => + { + b.HasOne("Voltiq.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.RefreshToken", b => + { + b.HasOne("Voltiq.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs new file mode 100644 index 0000000..c5a44e0 --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Voltiq.Infrastructure.Migrations +{ + /// + public partial class AddBudgetItemType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Type", + table: "BudgetItem", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Type", + table: "BudgetItem"); + } + } +} diff --git a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index daa28e6..594bb09 100644 --- a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -104,6 +104,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasPrecision(18, 2) .HasColumnType("numeric(18,2)"); + b.Property("Type") + .HasColumnType("integer"); + b.Property("Unit") .HasColumnType("integer"); @@ -339,7 +342,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Voltiq.Domain.Entities.Budget", b => { - b.HasOne("Voltiq.Domain.Entities.Client", null) + b.HasOne("Voltiq.Domain.Entities.Client", "Client") .WithMany() .HasForeignKey("ClientId") .OnDelete(DeleteBehavior.Restrict) @@ -350,6 +353,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + + b.Navigation("Client"); }); modelBuilder.Entity("Voltiq.Domain.Entities.BudgetItem", b => diff --git a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs index d35c2af..6286cea 100644 --- a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs +++ b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetItemConfiguration.cs @@ -12,6 +12,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(i => i.BudgetId).IsRequired(); + builder.Property(i => i.Type) + .IsRequired() + .HasConversion(); + builder.Property(i => i.MaterialId); builder.Property(i => i.MaterialName) diff --git a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs index a1f95a3..963b64e 100644 --- a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs +++ b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs @@ -132,7 +132,7 @@ public async Task GetByIdWithItemsAndUserIdAsync_ShouldReturnBudgetWithItems() var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); var budget = TestDataBuilder.MakeBudget(user.Id, client.Id); - var item = BudgetItem.Create(budget.Id, null, "Cabo 10mm", MaterialUnit.Metro, 2, 15.50m); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); await _budgetRepository.AddAsync(budget, TestContext.Current.CancellationToken); await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken); From 06e1486400bd10d06f599a63ea8319cf30907fa2 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Mon, 25 May 2026 21:26:58 -0300 Subject: [PATCH 28/36] chore(scripts): update seed-dev.cs for BudgetItemType changes --- scripts/seed-dev.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/seed-dev.cs b/scripts/seed-dev.cs index 15efd0d..9224426 100644 --- a/scripts/seed-dev.cs +++ b/scripts/seed-dev.cs @@ -118,19 +118,19 @@ // Orçamento 1: Construtora ABC — mix de material vinculado + item customizado var budget1 = Budget.Register(user.Id, clients[0].Id); -budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, "Cabo Flexível 2,5mm", - MaterialUnit.Metro, 50, 4.80m)); -budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, "Tomada 2P+T", MaterialUnit.Unidade, - 8, 18.50m)); -budget1.AddItem(BudgetItem.Create(budget1.Id, null, "Mão de obra elétrica", null, 1, 350.00m)); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[0].Id, BudgetItemType.Material, + MaterialUnit.Metro, 50, 4.80m, "Cabo Flexível 2,5mm")); +budget1.AddItem(BudgetItem.Create(budget1.Id, materials[2].Id, BudgetItemType.Material, MaterialUnit.Unidade, + 8, 18.50m, "Tomada 2P+T")); +budget1.AddItem(BudgetItem.Create(budget1.Id, null, BudgetItemType.MaoDeObra, null, 1, 350.00m, "Mão de obra elétrica")); await db.Budgets.AddAsync(budget1); await db.SaveChangesAsync(); // Orçamento 2: João da Silva — item customizado var budget2 = Budget.Register(user.Id, clients[1].Id); -budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, "Disjuntor 20A", - MaterialUnit.Unidade, 2, 32.00m)); +budget2.AddItem(BudgetItem.Create(budget2.Id, materials[3].Id, BudgetItemType.Material, + MaterialUnit.Unidade, 2, 32.00m, "Disjuntor 20A")); await db.Budgets.AddAsync(budget2); await db.SaveChangesAsync(); From 3d64cdc60d9a6bd3aeb4e564d649ae623ff7ed6a Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:08:22 -0300 Subject: [PATCH 29/36] test(domain): add tests for budget state transitions and updates --- .../Entities/BudgetTests.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs index f000e3a..bfa76d1 100644 --- a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs @@ -72,4 +72,142 @@ public void AddMultipleItems_ShouldSumAllTotals() budget.TotalAmount.ShouldBe(70m); } + + [Fact] + public void Edit_WithValidData_ShouldUpdateBudgetAndRecalculateTotals() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var oldItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo 10mm"); + budget.AddItem(oldItem); + + var newClientId = Guid.NewGuid(); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + budget.Edit(newClientId, new[] { newItem }); + + budget.ClientId.ShouldBe(newClientId); + budget.TotalAmount.ShouldBe(50m); + budget.Items.Count.ShouldBe(1); + budget.Items.ShouldContain(newItem); + budget.Items.ShouldNotContain(oldItem); + } + + [Fact] + public void Edit_WhenNotDraft_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + var statusProp = typeof(Budget).GetProperty(nameof(Budget.Status)); + statusProp!.SetValue(budget, BudgetStatus.Approved); + + Should.Throw(() => + budget.Edit(ValidClientId, new[] { newItem })) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO); + } + + [Fact] + public void Edit_WithEmptyClientId_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var newItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 1, 50m, "Disjuntor"); + + Should.Throw(() => + budget.Edit(Guid.Empty, new[] { newItem })) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + } + + [Fact] + public void Edit_WithEmptyItems_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Edit(ValidClientId, Array.Empty())) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Fact] + public void FinalizeBudget_WithValidDraftBudget_ShouldTransitionToFinalized() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + + budget.FinalizeBudget(); + + budget.Status.ShouldBe(BudgetStatus.Finalized); + budget.DomainEvents.ShouldContain(e => e is BudgetFinalizedEvent); + } + + [Fact] + public void FinalizeBudget_WhenAlreadyFinalized_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + Should.Throw(() => + budget.FinalizeBudget()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO); + } + + [Fact] + public void FinalizeBudget_WithEmptyItems_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.FinalizeBudget()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Fact] + public void Approve_WithFinalizedBudget_ShouldTransitionToApproved() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + budget.Approve(); + + budget.Status.ShouldBe(BudgetStatus.Approved); + budget.DomainEvents.ShouldContain(e => e is BudgetApprovedEvent); + } + + [Fact] + public void Approve_WhenDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Approve()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO); + } + + [Fact] + public void Reject_WithFinalizedBudget_ShouldTransitionToRejected() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + budget.Reject(); + + budget.Status.ShouldBe(BudgetStatus.Rejected); + budget.DomainEvents.ShouldContain(e => e is BudgetRejectedEvent); + } + + [Fact] + public void Reject_WhenDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.Reject()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); + } } From fd787f19f2d23b64c29c6a1546518c9c9b858243 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:08:22 -0300 Subject: [PATCH 30/36] feat(domain): add state transitions, status enum, and events for budgets --- src/Voltiq.Domain/Entities/Budget.cs | 52 +++++++++++++++++++ src/Voltiq.Domain/Enums/BudgetStatus.cs | 1 + .../Events/BudgetApprovedEvent.cs | 3 ++ .../Events/BudgetFinalizedEvent.cs | 3 ++ .../Events/BudgetRejectedEvent.cs | 3 ++ 5 files changed, 62 insertions(+) create mode 100644 src/Voltiq.Domain/Events/BudgetApprovedEvent.cs create mode 100644 src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs create mode 100644 src/Voltiq.Domain/Events/BudgetRejectedEvent.cs diff --git a/src/Voltiq.Domain/Entities/Budget.cs b/src/Voltiq.Domain/Entities/Budget.cs index a262bc7..65108c5 100644 --- a/src/Voltiq.Domain/Entities/Budget.cs +++ b/src/Voltiq.Domain/Entities/Budget.cs @@ -46,8 +46,60 @@ public void AddItem(BudgetItem item) RecalculateTotals(); } + public void Edit(Guid clientId, IReadOnlyCollection items) + { + if (Status != BudgetStatus.Draft) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO); + + if (clientId == Guid.Empty) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + + if (items is null || items.Count == 0) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + ClientId = clientId; + + _items.Clear(); + foreach (var item in items) + { + _items.Add(item); + } + + RecalculateTotals(); + } + public void RecalculateTotals() { TotalAmount = _items.Sum(i => i.TotalPrice); } + + public void FinalizeBudget() + { + if (Status != BudgetStatus.Draft) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO); + + if (_items.Count == 0) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + Status = BudgetStatus.Finalized; + AddDomainEvent(new BudgetFinalizedEvent(Id)); + } + + public void Approve() + { + if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO); + + Status = BudgetStatus.Approved; + AddDomainEvent(new BudgetApprovedEvent(Id)); + } + + public void Reject() + { + if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); + + Status = BudgetStatus.Rejected; + AddDomainEvent(new BudgetRejectedEvent(Id)); + } } diff --git a/src/Voltiq.Domain/Enums/BudgetStatus.cs b/src/Voltiq.Domain/Enums/BudgetStatus.cs index 5ba5af6..37e3ccc 100644 --- a/src/Voltiq.Domain/Enums/BudgetStatus.cs +++ b/src/Voltiq.Domain/Enums/BudgetStatus.cs @@ -6,4 +6,5 @@ public enum BudgetStatus Finalized = 2, PdfGenerated = 3, Approved = 4, + Rejected = 5, } diff --git a/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs b/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs new file mode 100644 index 0000000..fbd8639 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetApprovedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetApprovedEvent(Guid BudgetId) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs b/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs new file mode 100644 index 0000000..a2ca6d6 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetFinalizedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetFinalizedEvent(Guid BudgetId) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs b/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs new file mode 100644 index 0000000..70c2417 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetRejectedEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record BudgetRejectedEvent(Guid BudgetId) : BaseDomainEvent; From b57d5edec44370e44e293aa07400c17b38a962a9 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:10:07 -0300 Subject: [PATCH 31/36] test(application): add tests for budget commands (update, approve, reject, finalize) --- .../ApproveBudgetCommandHandlerTests.cs | 78 ++++++++ .../FinalizeBudgetCommandHandlerTests.cs | 77 ++++++++ .../RejectBudgetCommandHandlerTests.cs | 78 ++++++++ .../UpdateBudgetCommandHandlerTests.cs | 171 ++++++++++++++++++ .../UpdateBudgetCommandValidatorTests.cs | 98 ++++++++++ 5 files changed, 502 insertions(+) create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs create mode 100644 tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..86e5ad8 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs @@ -0,0 +1,78 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.ApproveBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class ApproveBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private ApproveBudgetCommandHandler CreateHandler() + { + return new ApproveBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithFinalizedBudget_ShouldApproveAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + budget.FinalizeBudget(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new ApproveBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Approved); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new ApproveBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..2a8f87d --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs @@ -0,0 +1,77 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class FinalizeBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private FinalizeBudgetCommandHandler CreateHandler() + { + return new FinalizeBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new FinalizeBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Finalized); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new FinalizeBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..bda704d --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs @@ -0,0 +1,78 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.RejectBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class RejectBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private RejectBudgetCommandHandler CreateHandler() + { + return new RejectBudgetCommandHandler( + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithFinalizedBudget_ShouldRejectAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(item); + budget.FinalizeBudget(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new RejectBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.Status.ShouldBe(BudgetStatus.Rejected); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new RejectBudgetCommand(_budgetId) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs new file mode 100644 index 0000000..26fce53 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs @@ -0,0 +1,171 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Domain.ValueObjects; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class UpdateBudgetCommandHandlerTests +{ + private readonly Mock _budgetUpdateRepoMock = new(); + private readonly Mock _clientReadRepoMock = new(); + private readonly Mock _materialReadRepoMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _clientId = Guid.NewGuid(); + + private UpdateBudgetCommandHandler CreateHandler() + { + return new UpdateBudgetCommandHandler( + _clientReadRepoMock.Object, + _materialReadRepoMock.Object, + _budgetUpdateRepoMock.Object, + _unitOfWorkMock.Object); + } + + private Client MakeClient() + { + return Client.Register(_userId, "João Silva", "(11) 99999-9999", + Email.Create("joao@example.com").Value, + Address.Create("Rua das Flores", "123", "São Paulo", "SP", "01310-100")); + } + + [Fact] + public async Task Handle_WithValidData_ShouldUpdateBudgetAndSave() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var oldItem = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); + budget.AddItem(oldItem); + + var client = MakeClient(); + var newClientId = Guid.NewGuid(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(newClientId, _userId, It.IsAny())) + .ReturnsAsync(client); + + var command = new UpdateBudgetCommand( + _budgetId, + newClientId, + [ + new UpdateBudgetItemCommand(null, "Disjuntor", BudgetItemType.MaoDeObra, null, 1, 50m) + ]) + { UserId = _userId }; + + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Updated); + + budget.ClientId.ShouldBe(newClientId); + budget.TotalAmount.ShouldBe(50m); + budget.Items.Count.ShouldBe(1); + budget.Items.First().MaterialName.ShouldBe("Disjuntor"); + + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() + { + // Arrange + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new UpdateBudgetCommand(_budgetId, _clientId, []) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + } + + [Fact] + public async Task Handle_WhenClientNotFound_ShouldReturnNotFound() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync((Client?)null); + + var command = new UpdateBudgetCommand(_budgetId, _clientId, []) { UserId = _userId }; + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + } + + [Fact] + public async Task Handle_WhenMaterialNotFound_ShouldReturnNotFound() + { + // Arrange + var budget = Budget.Register(_userId, _clientId); + var client = MakeClient(); + var materialId = Guid.NewGuid(); + + _budgetUpdateRepoMock + .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) + .ReturnsAsync(budget); + + _clientReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(_clientId, _userId, It.IsAny())) + .ReturnsAsync(client); + + _materialReadRepoMock + .Setup(r => r.GetByIdAndUserIdAsync(materialId, _userId, It.IsAny())) + .ReturnsAsync((Material?)null); + + var command = new UpdateBudgetCommand( + _budgetId, + _clientId, + [ + new UpdateBudgetItemCommand(materialId, "Cabo 10mm", BudgetItemType.Material, MaterialUnit.Metro, 1, 10m) + ]) + { UserId = _userId }; + + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + 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/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs new file mode 100644 index 0000000..84bed4d --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandValidatorTests.cs @@ -0,0 +1,98 @@ +using FluentValidation.TestHelper; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class UpdateBudgetCommandValidatorTests +{ + private readonly UpdateBudgetCommandValidator _validator = new(); + + private static UpdateBudgetCommand ValidCommand() + { + return new UpdateBudgetCommand(Guid.NewGuid(), Guid.NewGuid(), [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, + null, 2, 15.50m) + ]); + } + + [Fact] + public void Validate_WithValidData_ShouldHaveNoErrors() + { + _validator.TestValidate(ValidCommand()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_WithEmptyClientId_ShouldHaveError() + { + var command = ValidCommand() with { ClientId = Guid.Empty }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.ClientId) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + } + + [Fact] + public void Validate_WithEmptyItems_ShouldHaveError() + { + var command = ValidCommand() with { Items = [] }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Items) + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithEmptyItemMaterialName_ShouldHaveError(string? name) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, name!, BudgetItemType.MaoDeObra, null, 1, + 10.00m) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].MaterialName") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Validate_WithInvalidItemQuantity_ShouldHaveError(int quantity) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, + quantity, 15.50m) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].Quantity") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + } + + [Theory] + [InlineData(0)] + [InlineData(-0.01)] + public void Validate_WithInvalidItemUnitPrice_ShouldHaveError(double price) + { + var command = ValidCommand() with + { + Items = + [ + new UpdateBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, 1, + (decimal)price) + ] + }; + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor("Items[0].UnitPrice") + .WithErrorMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + } +} From 8b93a15c2999ed41e5b80b5ac1c88cd69dbbc237 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:10:07 -0300 Subject: [PATCH 32/36] feat(application): implement commands to update, approve, reject and finalize budgets --- .../ApproveBudget/ApproveBudgetCommand.cs | 10 ++++ .../ApproveBudgetCommandHandler.cs | 29 ++++++++++ .../FinalizeBudget/FinalizeBudgetCommand.cs | 10 ++++ .../FinalizeBudgetCommandHandler.cs | 29 ++++++++++ .../RejectBudget/RejectBudgetCommand.cs | 10 ++++ .../RejectBudgetCommandHandler.cs | 29 ++++++++++ .../UpdateBudget/UpdateBudgetCommand.cs | 21 +++++++ .../UpdateBudgetCommandHandler.cs | 58 +++++++++++++++++++ .../UpdateBudgetCommandValidator.cs | 58 +++++++++++++++++++ .../UpdateBudget/UpdateBudgetRequest.cs | 15 +++++ .../Budgets/BudgetMappingExtensions.cs | 12 ++++ .../ResourceErrorMessages.Designer.cs | 38 +++++++++++- .../Resources/ResourceErrorMessages.resx | 12 ++++ 13 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs create mode 100644 src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs diff --git a/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs new file mode 100644 index 0000000..16e2cfa --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget; + +public record ApproveBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs new file mode 100644 index 0000000..af405f9 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/ApproveBudget/ApproveBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.ApproveBudget; + +public sealed class ApproveBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + ApproveBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.Approve(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs new file mode 100644 index 0000000..b5fded0 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; + +public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs new file mode 100644 index 0000000..545df09 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; + +public sealed class FinalizeBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + FinalizeBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.FinalizeBudget(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs new file mode 100644 index 0000000..b528f7b --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommand.cs @@ -0,0 +1,10 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces; + +namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget; + +public record RejectBudgetCommand(Guid Id) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs new file mode 100644 index 0000000..8bfa018 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/RejectBudget/RejectBudgetCommandHandler.cs @@ -0,0 +1,29 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.RejectBudget; + +public sealed class RejectBudgetCommandHandler( + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + RejectBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + budget.Reject(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs new file mode 100644 index 0000000..70e8cf3 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommand.cs @@ -0,0 +1,21 @@ +using ErrorOr; +using Voltiq.Application.Common.Interfaces; +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed record UpdateBudgetCommand( + Guid Id, + Guid ClientId, + IReadOnlyList Items) : IAuthenticatedRequest> +{ + public Guid UserId { get; set; } +} + +public sealed record UpdateBudgetItemCommand( + Guid? MaterialId, + string MaterialName, + BudgetItemType Type, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs new file mode 100644 index 0000000..bfd908b --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandHandler.cs @@ -0,0 +1,58 @@ +using ErrorOr; +using MediatR; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; +using Voltiq.Domain.Interfaces.Repositories.Material; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed class UpdateBudgetCommandHandler( + IClientReadOnlyRepository clientReadOnly, + IMaterialReadOnlyRepository materialReadOnly, + IBudgetUpdateOnlyRepository budgetUpdateOnly, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async Task> Handle( + UpdateBudgetCommand command, CancellationToken cancellationToken) + { + var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync( + command.Id, command.UserId, cancellationToken); + + if (budget is null) + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + var client = await clientReadOnly.GetByIdAndUserIdAsync( + command.ClientId, command.UserId, cancellationToken); + + if (client is null) + return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO); + + var budgetItems = new List(); + foreach (var item in command.Items) + { + if (item.MaterialId.HasValue) + { + var material = await materialReadOnly.GetByIdAndUserIdAsync( + item.MaterialId.Value, command.UserId, cancellationToken); + + if (material is null) + return Error.NotFound(description: ResourceErrorMessages.MATERIAL_NAO_ENCONTRADO); + } + + var budgetItem = BudgetItem.Create( + budget.Id, item.MaterialId, item.Type, item.Unit, item.Quantity, item.UnitPrice, item.MaterialName); + + budgetItems.Add(budgetItem); + } + + budget.Edit(command.ClientId, budgetItems); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Updated; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs new file mode 100644 index 0000000..ebd7ff9 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetCommandValidator.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Voltiq.Domain.Enums; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed class UpdateBudgetCommandValidator : AbstractValidator +{ + public UpdateBudgetCommandValidator() + { + RuleFor(x => x.ClientId) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_CLIENTE_OBRIGATORIO); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.MaterialName) + .NotEmpty() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_NOME_OBRIGATORIO); + + item.RuleFor(i => i.Type) + .IsInEnum() + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_TIPO_INVALIDO); + + item.RuleFor(i => i.MaterialId) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); + + item.RuleFor(i => i.MaterialId) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_DEVE_SER_NULO); + + item.RuleFor(i => i.Unit) + .NotNull() + .When(i => i.Type == BudgetItemType.Material) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); + + item.RuleFor(i => i.Unit) + .Null() + .When(i => i.Type != BudgetItemType.Material && Enum.IsDefined(i.Type)) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_DEVE_SER_NULA); + + item.RuleFor(i => i.Quantity) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); + + item.RuleFor(i => i.UnitPrice) + .GreaterThan(0) + .WithMessage(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); + }); + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs new file mode 100644 index 0000000..1600907 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/UpdateBudget/UpdateBudgetRequest.cs @@ -0,0 +1,15 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Application.Features.Budgets.Commands.UpdateBudget; + +public sealed record UpdateBudgetRequest( + Guid ClientId, + IReadOnlyList Items); + +public sealed record UpdateBudgetItemRequest( + Guid? MaterialId, + string MaterialName, + BudgetItemType Type, + MaterialUnit? Unit, + int Quantity, + decimal UnitPrice); diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs index 1f726bc..0fe15f6 100644 --- a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs +++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs @@ -1,5 +1,6 @@ using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; using Voltiq.Domain.Entities; namespace Voltiq.Application.Mappings.Budgets; @@ -16,6 +17,17 @@ public RegisterBudgetCommand ToCommand() => .ToList()); } + extension(UpdateBudgetRequest request) + { + public UpdateBudgetCommand ToCommand(Guid id) => + new(id, + request.ClientId, + request.Items + .Select(i => new UpdateBudgetItemCommand( + i.MaterialId, i.MaterialName, i.Type, i.Unit, i.Quantity, i.UnitPrice)) + .ToList()); + } + extension(Budget budget) { public BudgetSummaryResponse ToSummaryResponse() => diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index 5d699e9..dd5f7fa 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -392,6 +392,42 @@ public static string ORCAMENTO_NAO_ENCONTRADO { } } + /// + /// Looks up a localized string similar to Apenas orçamentos em rascunho podem ser editados.. + /// + public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO { + get { + return ResourceManager.GetString("ORCAMENTO_APENAS_RASCUNHO_PODE_SER_EDITADO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Apenas orçamentos em rascunho podem ser finalizados.. + /// + public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO { + get { + return ResourceManager.GetString("ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O status atual do orçamento não permite aprovação. Ele deve estar finalizado ou com PDF gerado.. + /// + public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO { + get { + return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O status atual do orçamento não permite rejeição. Ele deve estar finalizado ou com PDF gerado.. + /// + public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO { + get { + return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO", resourceCulture); + } + } + /// /// Looks up a localized string similar to O usuário do orçamento é obrigatório.. /// diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx index 61c3412..d341206 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -220,6 +220,18 @@ Orçamento não encontrado. + + Apenas orçamentos em rascunho podem ser editados. + + + Apenas orçamentos em rascunho podem ser finalizados. + + + O status atual do orçamento não permite aprovação. Ele deve estar finalizado ou com PDF gerado. + + + O status atual do orçamento não permite rejeição. Ele deve estar finalizado ou com PDF gerado. + From 56c7eb5c9ad4fb9feb12bc3ae14ac90b67c9200d Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:10:26 -0300 Subject: [PATCH 33/36] feat(api): expose endpoints for update, approve, reject and finalize budgets --- .../Controllers/Budgets/BudgetsController.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs index 45e8cc0..2075d5f 100644 --- a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs +++ b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs @@ -3,6 +3,10 @@ using Voltiq.Application.Features.Budgets; using Voltiq.Application.Features.Budgets.Commands.DeleteBudget; using Voltiq.Application.Features.Budgets.Commands.RegisterBudget; +using Voltiq.Application.Features.Budgets.Commands.UpdateBudget; +using Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; +using Voltiq.Application.Features.Budgets.Commands.ApproveBudget; +using Voltiq.Application.Features.Budgets.Commands.RejectBudget; using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; using Voltiq.Application.Features.Budgets.Queries.GetBudgets; using Voltiq.Application.Mappings.Budgets; @@ -67,4 +71,65 @@ public async Task Delete( return result.Match(_ => NoContent(), ToErrorResult); } + + /// Updates a budget (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] UpdateBudgetRequest request, + CancellationToken cancellationToken) + { + var result = await Sender.Send(request.ToCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Finalizes a budget, making it read-only. + [HttpPut("{id:guid}/finalize")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Finalize( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Approves a finalized budget. + [HttpPut("{id:guid}/approve")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Approve( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new ApproveBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } + + /// Rejects a finalized budget. + [HttpPut("{id:guid}/reject")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Reject( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new RejectBudgetCommand(id), cancellationToken); + + return result.Match(_ => NoContent(), ToErrorResult); + } } From d6727bd116ee53d74d7956a2c17349e9adeda31e Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:10:27 -0300 Subject: [PATCH 34/36] docs: document budget lifecycle endpoints in README.md --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/README.md b/README.md index d556cef..6e2c09b 100644 --- a/README.md +++ b/README.md @@ -460,8 +460,69 @@ Cria um novo usuário na plataforma. - Documento deve ser único no sistema. - Senha é armazenada como hash **Argon2id** — nunca em texto puro. +### Orçamentos + +#### `PUT /api/v1/budgets/{id}/finalize` — Finalizar orçamento + +Muda o status do orçamento de `Draft` para `Finalized`. O orçamento deve ter pelo menos um item e pertencer ao usuário autenticado. Uma vez finalizado, o orçamento torna-se somente leitura e não pode mais ser editado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento finalizado com sucesso | +| `400 Bad Request` | Orçamento não está em rascunho ou não contém itens (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + --- +#### `PUT /api/v1/budgets/{id}/approve` — Aprovar orçamento + +Aprova um orçamento que foi finalizado ou tem PDF gerado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento aprovado com sucesso | +| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + +--- + +#### `PUT /api/v1/budgets/{id}/reject` — Rejeitar orçamento + +Rejeita um orçamento que foi finalizado ou tem PDF gerado. + +**Headers obrigatórios:** +``` +Authorization: Bearer +``` + +**Respostas:** + +| Status | Descrição | +|---|---| +| `204 NoContent` | Orçamento rejeitado com sucesso | +| `400 Bad Request` | Orçamento não está nos estados `Finalized` ou `PdfGenerated` (lança DomainException) | +| `401 Unauthorized` | Token ausente ou inválido | +| `404 Not Found` | Orçamento não encontrado ou não pertence ao usuário | + +--- + + ### CQRS com MediatR From 63eb98436c4d102b0d06af3cc0484d8017c03a5b Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:10:27 -0300 Subject: [PATCH 35/36] chore(git): update .gitignore to ignore antigravity cli state --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c4eda6c..17ef8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ logs/ # Copilot instructions .github/copilot-instructions.md .agents +.antigravitycli From 3c8223ad0f0e8496fc3d22903e2575bb0d4eaaf1 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 3 Jun 2026 22:24:27 -0300 Subject: [PATCH 36/36] fix: correct lint and whitespaces errors --- .../Migrations/20260526002045_AddBudgetItemType.cs | 2 +- .../Commands/ApproveBudgetCommandHandlerTests.cs | 2 +- .../Commands/FinalizeBudgetCommandHandlerTests.cs | 2 +- .../Commands/RegisterBudgetCommandHandlerTests.cs | 6 +++--- .../Commands/RejectBudgetCommandHandlerTests.cs | 2 +- .../Commands/UpdateBudgetCommandHandlerTests.cs | 4 ++-- .../Voltiq.Domain.Tests/Entities/BudgetItemTests.cs | 12 ++++++------ 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs index c5a44e0..2c49e81 100644 --- a/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs +++ b/src/Voltiq.Infrastructure/Migrations/20260526002045_AddBudgetItemType.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs index 86e5ad8..c099781 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs @@ -14,7 +14,7 @@ public class ApproveBudgetCommandHandlerTests { private readonly Mock _budgetUpdateRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); - + private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _budgetId = Guid.NewGuid(); private readonly Guid _clientId = Guid.NewGuid(); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs index 2a8f87d..fb8ca6d 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs @@ -14,7 +14,7 @@ public class FinalizeBudgetCommandHandlerTests { private readonly Mock _budgetUpdateRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); - + private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _budgetId = Guid.NewGuid(); private readonly Guid _clientId = Guid.NewGuid(); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs index 1c67577..9f1b323 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RegisterBudgetCommandHandlerTests.cs @@ -43,7 +43,7 @@ private RegisterBudgetCommand CommandWithCustomItem() new RegisterBudgetItemCommand(null, "Cabo 10mm", BudgetItemType.MaoDeObra, null, 2, 15.50m) ]) - { UserId = _userId }; + { UserId = _userId }; } [Fact] @@ -91,7 +91,7 @@ public async Task Handle_WithMaterialId_ShouldValidateMaterialAndRegisterBudget( new RegisterBudgetItemCommand(materialId, "Cabo 10mm", BudgetItemType.Material, MaterialUnit.Metro, 3, 10.00m) ]) - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); @@ -139,7 +139,7 @@ public async Task Handle_WhenMaterialIdNotFound_ShouldReturnNotFoundError() new RegisterBudgetItemCommand(Guid.NewGuid(), "Cabo 10mm", BudgetItemType.Material, MaterialUnit.Metro, 1, 10.00m) ]) - { UserId = _userId }; + { UserId = _userId }; var handler = CreateHandler(); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs index bda704d..f4f1bf7 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs @@ -14,7 +14,7 @@ public class RejectBudgetCommandHandlerTests { private readonly Mock _budgetUpdateRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); - + private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _budgetId = Guid.NewGuid(); private readonly Guid _clientId = Guid.NewGuid(); diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs index 26fce53..ae72b8a 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/UpdateBudgetCommandHandlerTests.cs @@ -19,7 +19,7 @@ public class UpdateBudgetCommandHandlerTests private readonly Mock _clientReadRepoMock = new(); private readonly Mock _materialReadRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); - + private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _budgetId = Guid.NewGuid(); private readonly Guid _clientId = Guid.NewGuid(); @@ -109,7 +109,7 @@ public async Task Handle_WhenClientNotFound_ShouldReturnNotFound() { // Arrange var budget = Budget.Register(_userId, _clientId); - + _budgetUpdateRepoMock .Setup(r => r.GetTrackedByIdWithItemsAndUserIdAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync(budget); diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs index 2b85f8e..7c792ab 100644 --- a/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetItemTests.cs @@ -10,10 +10,10 @@ public class BudgetItemTests { private static readonly Guid ValidBudgetId = Guid.NewGuid(); private static readonly Guid ValidMaterialId = Guid.NewGuid(); - private const string ValidName = "Cabo 10mm"; + private const string VALID_NAME = "Cabo 10mm"; private static BudgetItem ValidMaterialItemCreate() => - BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, ValidName); + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, VALID_NAME); [Fact] public void Create_MaterialTypeWithValidData_ShouldSucceed() @@ -50,7 +50,7 @@ public void Create_OutrosTypeWithValidData_ShouldSucceed() public void Create_MaterialTypeWithoutMaterialId_ShouldThrowDomainException() { Should.Throw(() => - BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, ValidName)) + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.Material, MaterialUnit.Metro, 2, 15.50m, VALID_NAME)) .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_MATERIAL_ID_OBRIGATORIO_PARA_MATERIAL); } @@ -58,7 +58,7 @@ public void Create_MaterialTypeWithoutMaterialId_ShouldThrowDomainException() public void Create_MaterialTypeWithoutUnit_ShouldThrowDomainException() { Should.Throw(() => - BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, null, 2, 15.50m, ValidName)) + BudgetItem.Create(ValidBudgetId, ValidMaterialId, BudgetItemType.Material, null, 2, 15.50m, VALID_NAME)) .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_UNIDADE_OBRIGATORIA_PARA_MATERIAL); } @@ -103,7 +103,7 @@ public void Create_WithEmptyName_ShouldThrowDomainException() public void Create_WithInvalidQuantity_ShouldThrowDomainException(int quantity) { Should.Throw(() => - BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, quantity, 10m, ValidName)) + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, quantity, 10m, VALID_NAME)) .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_QUANTIDADE_INVALIDA); } @@ -113,7 +113,7 @@ public void Create_WithInvalidQuantity_ShouldThrowDomainException(int quantity) public void Create_WithInvalidUnitPrice_ShouldThrowDomainException(decimal unitPrice) { Should.Throw(() => - BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, unitPrice, ValidName)) + BudgetItem.Create(ValidBudgetId, null, BudgetItemType.MaoDeObra, null, 3, unitPrice, VALID_NAME)) .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_ITEM_PRECO_INVALIDO); }