From cdea039c7e64434f2c9009b2bc7abdc12baf2639 Mon Sep 17 00:00:00 2001 From: Evandro Costa Date: Wed, 1 Apr 2026 19:47:00 -0300 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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; + } +}