diff --git a/Voltiq.slnx b/Voltiq.slnx index 9b98f53..55f1a76 100644 --- a/Voltiq.slnx +++ b/Voltiq.slnx @@ -1,15 +1,16 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs index 2075d5f..5ce7a0c 100644 --- a/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs +++ b/src/Voltiq.API/Controllers/Budgets/BudgetsController.cs @@ -7,6 +7,7 @@ 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.Commands.GenerateBudgetPdf; using Voltiq.Application.Features.Budgets.Queries.GetBudgetById; using Voltiq.Application.Features.Budgets.Queries.GetBudgets; using Voltiq.Application.Mappings.Budgets; @@ -90,7 +91,7 @@ public async Task Update( /// Finalizes a budget, making it read-only. [HttpPut("{id:guid}/finalize")] - [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -100,7 +101,22 @@ public async Task Finalize( { var result = await Sender.Send(new FinalizeBudgetCommand(id), cancellationToken); - return result.Match(_ => NoContent(), ToErrorResult); + return result.Match(_ => Accepted(), ToErrorResult); + } + + /// Generates/Regenerates the PDF for a finalized budget. + [HttpPost("{id:guid}/generate-pdf")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GeneratePdf( + Guid id, + CancellationToken cancellationToken) + { + var result = await Sender.Send(new GenerateBudgetPdfCommand(id), cancellationToken); + + return result.Match(_ => Accepted(), ToErrorResult); } /// Approves a finalized budget. diff --git a/src/Voltiq.API/docker-compose.yml b/src/Voltiq.API/docker-compose.yml index 8c75b89..6cd2aaa 100644 --- a/src/Voltiq.API/docker-compose.yml +++ b/src/Voltiq.API/docker-compose.yml @@ -2,6 +2,7 @@ services: db: image: postgres:17-alpine container_name: voltiq-db + restart: unless-stopped environment: POSTGRES_DB: VoltiqDb POSTGRES_USER: postgres @@ -11,5 +12,18 @@ services: volumes: - voltiq-db-data:/var/lib/postgresql/data + azurite: + image: mcr.microsoft.com/azure-storage/azurite + container_name: voltiq-azurite + restart: unless-stopped + command: azurite --skipApiVersionCheck --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 + ports: + - "10000:10000" # Blob service + - "10001:10001" # Queue service + - "10002:10002" # Table service + volumes: + - voltiq-azurite-data:/data + volumes: voltiq-db-data: + voltiq-azurite-data: diff --git a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs index 8a38daf..07d0487 100644 --- a/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs +++ b/src/Voltiq.Application/Features/Budgets/BudgetResponse.cs @@ -5,6 +5,7 @@ namespace Voltiq.Application.Features.Budgets; public sealed record BudgetSummaryResponse( Guid Id, BudgetStatus Status, + PdfGenerationStatus? PdfGenerationStatus, decimal TotalAmount, DateTime CreatedAt, BudgetClientSummaryResponse Client); @@ -14,6 +15,7 @@ public sealed record BudgetClientSummaryResponse(Guid Id, string Name); public sealed record BudgetDetailResponse( Guid Id, BudgetStatus Status, + PdfGenerationStatus? PdfGenerationStatus, decimal TotalAmount, DateTime CreatedAt, BudgetClientDetailResponse Client, diff --git a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs index b5fded0..3e115b8 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommand.cs @@ -4,7 +4,7 @@ namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; -public record FinalizeBudgetCommand(Guid Id) : IAuthenticatedRequest> +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 index 545df09..57748e4 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/FinalizeBudget/FinalizeBudgetCommandHandler.cs @@ -1,5 +1,6 @@ using ErrorOr; using MediatR; +using Voltiq.Application.Common.Interfaces.Queue; using Voltiq.Domain.Interfaces; using Voltiq.Domain.Interfaces.Repositories.Budget; using Voltiq.Exceptions.Resources; @@ -8,10 +9,11 @@ namespace Voltiq.Application.Features.Budgets.Commands.FinalizeBudget; public sealed class FinalizeBudgetCommandHandler( IBudgetUpdateOnlyRepository budgetUpdateOnly, - IUnitOfWork unitOfWork) - : IRequestHandler> + IUnitOfWork unitOfWork, + IQueueService queueService) + : IRequestHandler> { - public async Task> Handle( + public async Task> Handle( FinalizeBudgetCommand command, CancellationToken cancellationToken) { var budget = await budgetUpdateOnly.GetTrackedByIdWithItemsAndUserIdAsync( @@ -24,6 +26,9 @@ public async Task> Handle( await unitOfWork.SaveChangesAsync(cancellationToken); - return Result.Updated; + var message = new { BudgetId = budget.Id }; + await queueService.SendMessageAsync("budget-reports", message, cancellationToken); + + return Result.Success; } } diff --git a/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs index d7e3a2c..57ea978 100644 --- a/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs +++ b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs @@ -1,33 +1,33 @@ using ErrorOr; using MediatR; +using Voltiq.Application.Common.Interfaces; using Voltiq.Application.Common.Interfaces.Queue; +using Voltiq.Domain.Enums; using Voltiq.Domain.Interfaces.Repositories.Budget; using Voltiq.Exceptions.Resources; namespace Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf; -public record GenerateBudgetPdfCommand(Guid BudgetId) : IRequest>; - -public class GenerateBudgetPdfCommandHandler : IRequestHandler> +public record GenerateBudgetPdfCommand(Guid BudgetId) : IAuthenticatedRequest> { - private readonly IQueueService _queueService; - private readonly IBudgetReadOnlyRepository _budgetRepository; - - public GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository) - { - _queueService = queueService; - _budgetRepository = budgetRepository; - } + public Guid UserId { get; set; } +} +public class GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository) + : IRequestHandler> +{ public async Task> Handle(GenerateBudgetPdfCommand request, CancellationToken cancellationToken) { - var budget = await _budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken); + var budget = await budgetRepository.GetByIdAndUserIdAsync(request.BudgetId, request.UserId, cancellationToken); if (budget == null) - return Error.NotFound(description: ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + return Error.NotFound(description: ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); + + if (budget.Status != BudgetStatus.Finalized) + return Error.Validation(description: ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); var message = new { request.BudgetId }; - await _queueService.SendMessageAsync("budget-reports", message, cancellationToken); + await queueService.SendMessageAsync("budget-reports", message, cancellationToken); return Result.Success; } diff --git a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs index 0fe15f6..1502785 100644 --- a/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs +++ b/src/Voltiq.Application/Mappings/Budgets/BudgetMappingExtensions.cs @@ -31,14 +31,14 @@ public UpdateBudgetCommand ToCommand(Guid id) => extension(Budget budget) { public BudgetSummaryResponse ToSummaryResponse() => - new(budget.Id, budget.Status, budget.TotalAmount, budget.CreatedAt, + new(budget.Id, budget.Status, budget.PdfGenerationStatus, 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(budget.Id, budget.Status, budget.PdfGenerationStatus, budget.TotalAmount, budget.CreatedAt, new BudgetClientDetailResponse( client.Id, client.Name, client.Phone, client.Email.Value), budget.Items.Select(i => new BudgetItemResponse( diff --git a/src/Voltiq.Domain/Entities/Budget.cs b/src/Voltiq.Domain/Entities/Budget.cs index 65108c5..2fedc8a 100644 --- a/src/Voltiq.Domain/Entities/Budget.cs +++ b/src/Voltiq.Domain/Entities/Budget.cs @@ -12,6 +12,7 @@ public sealed class Budget : AuditableEntity public decimal TotalAmount { get; private set; } public BudgetStatus Status { get; private set; } public string? PdfUrl { get; private set; } + public PdfGenerationStatus? PdfGenerationStatus { get; private set; } private readonly List _items = []; public IReadOnlyCollection Items => _items.AsReadOnly(); @@ -82,24 +83,62 @@ public void FinalizeBudget() throw new DomainException(ResourceErrorMessages.ORCAMENTO_ITEMS_OBRIGATORIOS); Status = BudgetStatus.Finalized; + PdfGenerationStatus = Enums.PdfGenerationStatus.Pending; AddDomainEvent(new BudgetFinalizedEvent(Id)); } public void Approve() { - if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + if (Status != BudgetStatus.Finalized) throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO); + if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL); + Status = BudgetStatus.Approved; AddDomainEvent(new BudgetApprovedEvent(Id)); } public void Reject() { - if (Status != BudgetStatus.Finalized && Status != BudgetStatus.PdfGenerated) + if (Status != BudgetStatus.Finalized) throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); + if (PdfGenerationStatus != Enums.PdfGenerationStatus.Success) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL); + Status = BudgetStatus.Rejected; AddDomainEvent(new BudgetRejectedEvent(Id)); } + + public void StartPdfProcessing() + { + if (Status != BudgetStatus.Finalized) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + + PdfGenerationStatus = Enums.PdfGenerationStatus.Processing; + AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Processing)); + } + + public void SetPdfGenerationSuccess(string pdfUrl) + { + if (Status != BudgetStatus.Finalized) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + + if (string.IsNullOrWhiteSpace(pdfUrl)) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_PDF_URL_OBRIGATORIA); + + PdfUrl = pdfUrl; + PdfGenerationStatus = Enums.PdfGenerationStatus.Success; + AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Success)); + } + + public void SetPdfGenerationFailed() + { + if (Status != BudgetStatus.Finalized) + throw new DomainException(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + + PdfGenerationStatus = Enums.PdfGenerationStatus.Failed; + AddDomainEvent(new BudgetPdfGenerationStatusChangedEvent(Id, Enums.PdfGenerationStatus.Failed)); + } } diff --git a/src/Voltiq.Domain/Enums/BudgetStatus.cs b/src/Voltiq.Domain/Enums/BudgetStatus.cs index 37e3ccc..35ce13e 100644 --- a/src/Voltiq.Domain/Enums/BudgetStatus.cs +++ b/src/Voltiq.Domain/Enums/BudgetStatus.cs @@ -4,7 +4,6 @@ public enum BudgetStatus { Draft = 1, Finalized = 2, - PdfGenerated = 3, - Approved = 4, - Rejected = 5, + Approved = 3, + Rejected = 4 } diff --git a/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs b/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs new file mode 100644 index 0000000..19f8eab --- /dev/null +++ b/src/Voltiq.Domain/Enums/PdfGenerationStatus.cs @@ -0,0 +1,9 @@ +namespace Voltiq.Domain.Enums; + +public enum PdfGenerationStatus +{ + Pending = 1, + Processing = 2, + Success = 3, + Failed = 4 +} diff --git a/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs b/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs new file mode 100644 index 0000000..713d7f0 --- /dev/null +++ b/src/Voltiq.Domain/Events/BudgetPdfGenerationStatusChangedEvent.cs @@ -0,0 +1,5 @@ +using Voltiq.Domain.Enums; + +namespace Voltiq.Domain.Events; + +public sealed record BudgetPdfGenerationStatusChangedEvent(Guid BudgetId, PdfGenerationStatus Status) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs index 87526a7..81688a6 100644 --- a/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs +++ b/src/Voltiq.Domain/Interfaces/Repositories/Budget/IBudgetUpdateOnlyRepository.cs @@ -8,5 +8,7 @@ public interface IBudgetUpdateOnlyRepository Task GetTrackedByIdWithItemsAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task GetTrackedByIdAsync(Guid id, CancellationToken cancellationToken = default); + void Remove(Entities.Budget entity); } diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index 934b31b..88b0c1e 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -447,7 +447,7 @@ public static string ORCAMENTO_APENAS_RASCUNHO_PODE_SER_FINALIZADO { } /// - /// 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.. + /// Looks up a localized string similar to O status atual do orçamento não permite aprovação. Ele deve estar finalizado.. /// public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO { get { @@ -456,7 +456,7 @@ public static string ORCAMENTO_STATUS_INVALIDO_PARA_APROVACAO { } /// - /// 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.. + /// Looks up a localized string similar to O status atual do orçamento não permite rejeição. Ele deve estar finalizado.. /// public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO { get { @@ -464,6 +464,33 @@ public static string ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO { } } + /// + /// Looks up a localized string similar to O status atual do orçamento não permite a geração do PDF. Ele deve estar finalizado.. + /// + public static string ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF { + get { + return ResourceManager.GetString("ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A URL do PDF do orçamento é obrigatória.. + /// + public static string ORCAMENTO_PDF_URL_OBRIGATORIA { + get { + return ResourceManager.GetString("ORCAMENTO_PDF_URL_OBRIGATORIA", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O PDF do orçamento ainda não foi gerado ou não está disponível para aprovação/rejeição.. + /// + public static string ORCAMENTO_PDF_NAO_DISPONIVEL { + get { + return ResourceManager.GetString("ORCAMENTO_PDF_NAO_DISPONIVEL", 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 d6e4fc8..0065767 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -242,10 +242,19 @@ 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 aprovação. Ele deve estar finalizado. - O status atual do orçamento não permite rejeição. Ele deve estar finalizado ou com PDF gerado. + O status atual do orçamento não permite rejeição. Ele deve estar finalizado. + + + O status atual do orçamento não permite a geração do PDF. Ele deve estar finalizado. + + + A URL do PDF do orçamento é obrigatória. + + + O PDF do orçamento ainda não foi gerado ou não está disponível para aprovação/rejeição. diff --git a/src/Voltiq.Functions/GenerateReportFunction.cs b/src/Voltiq.Functions/GenerateReportFunction.cs index cd828ae..00596bb 100644 --- a/src/Voltiq.Functions/GenerateReportFunction.cs +++ b/src/Voltiq.Functions/GenerateReportFunction.cs @@ -56,13 +56,16 @@ public async Task Run([QueueTrigger("budget-reports")] string message, Cancellat return; } - var budget = await _budgetRepository.GetByIdAsync(msg.BudgetId, cancellationToken); + var budget = await _budgetUpdateRepository.GetTrackedByIdAsync(msg.BudgetId, cancellationToken); if (budget == null) { _logger.LogWarning("Budget not found for ID: {BudgetId}", msg.BudgetId); return; } + budget.StartPdfProcessing(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + var client = await _clientRepository.GetByIdAndUserIdAsync(budget.ClientId, budget.UserId, cancellationToken); var reportData = new BudgetReportData @@ -74,19 +77,28 @@ public async Task Run([QueueTrigger("budget-reports")] string message, Cancellat CreatedAt = budget.CreatedAt }; - _logger.LogInformation("Generating PDF for Budget ID: {BudgetId}", budget.Id); - var pdfBytes = await _reportGenerator.GenerateAsync(reportData, cancellationToken); + try + { + _logger.LogInformation("Generating PDF for Budget ID: {BudgetId}", budget.Id); + var pdfBytes = await _reportGenerator.GenerateAsync(reportData, cancellationToken); - var fileName = $"budget-{budget.Id}.pdf"; - - _logger.LogInformation("Uploading PDF for Budget ID: {BudgetId} to Blob Storage", budget.Id); - var uri = await _storageService.UploadAsync(fileName, pdfBytes, "application/pdf", cancellationToken); + var fileName = $"budget-{budget.Id}.pdf"; - budget.MarkAsPdfGenerated(uri); - _budgetUpdateRepository.Update(budget); - await _unitOfWork.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Uploading PDF for Budget ID: {BudgetId} to Blob Storage", budget.Id); + var uri = await _storageService.UploadAsync(fileName, pdfBytes, "application/pdf", cancellationToken); - _logger.LogInformation("Report generated and budget status updated successfully. URI: {Uri}", uri); + budget.SetPdfGenerationSuccess(uri); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Report generated and budget status updated successfully. URI: {Uri}", uri); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating report for budget ID: {BudgetId}", budget.Id); + budget.SetPdfGenerationFailed(); + await _unitOfWork.SaveChangesAsync(CancellationToken.None); + throw; + } } catch (Exception ex) { diff --git a/src/Voltiq.Infrastructure/DependencyInjection.cs b/src/Voltiq.Infrastructure/DependencyInjection.cs index 84f5077..e203bb0 100644 --- a/src/Voltiq.Infrastructure/DependencyInjection.cs +++ b/src/Voltiq.Infrastructure/DependencyInjection.cs @@ -6,6 +6,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Voltiq.Application.Common.Interfaces; +using Voltiq.Application.Common.Interfaces.Storage; +using Voltiq.Application.Common.Interfaces.Queue; +using Voltiq.Application.Common.Interfaces.Reports; using Voltiq.Domain.Interfaces; using Voltiq.Domain.Interfaces.Repositories.Budget; using Voltiq.Domain.Interfaces.Repositories.Client; @@ -35,6 +38,14 @@ public static void AddInfrastructure(this IServiceCollection services, AddJwtAuthentication(services, configuration); AddAuthServices(services); AddCryptography(services); + AddExternalServices(services); + } + + private static void AddExternalServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddCryptography(IServiceCollection services) diff --git a/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs new file mode 100644 index 0000000..302d74f --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.Designer.cs @@ -0,0 +1,460 @@ +// +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("20260625030604_AddPdfGenerationStatusToBudget")] + partial class AddPdfGenerationStatusToBudget + { + /// + 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("PdfGenerationStatus") + .HasColumnType("integer"); + + 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/20260625030604_AddPdfGenerationStatusToBudget.cs b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.cs new file mode 100644 index 0000000..a9cafed --- /dev/null +++ b/src/Voltiq.Infrastructure/Migrations/20260625030604_AddPdfGenerationStatusToBudget.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Voltiq.Infrastructure.Migrations +{ + /// + public partial class AddPdfGenerationStatusToBudget : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PdfGenerationStatus", + table: "Budgets", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PdfGenerationStatus", + table: "Budgets"); + } + } +} diff --git a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 594bb09..9d5c7f4 100644 --- a/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Voltiq.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -46,6 +46,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("PdfGenerationStatus") + .HasColumnType("integer"); + b.Property("PdfUrl") .HasMaxLength(2048) .HasColumnType("character varying(2048)"); diff --git a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs index 92421d7..07b93eb 100644 --- a/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs +++ b/src/Voltiq.Infrastructure/Persistence/Configurations/BudgetConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; namespace Voltiq.Infrastructure.Persistence.Configurations; @@ -22,6 +23,10 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasConversion(); + builder.Property(b => b.PdfGenerationStatus) + .HasConversion() + .IsRequired(false); + builder.Property(b => b.PdfUrl) .HasMaxLength(2048); diff --git a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs index 73d946d..e63a2e8 100644 --- a/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs +++ b/src/Voltiq.Infrastructure/Persistence/Repositories/Budget/BudgetRepository.cs @@ -78,6 +78,13 @@ public void Remove(Domain.Entities.Budget entity) .FirstOrDefaultAsync(b => b.Id == id && b.UserId == userId, cancellationToken); } + public async Task GetTrackedByIdAsync( + Guid id, CancellationToken cancellationToken = default) + { + return await context.Budgets + .FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + } + public async Task AddAsync(Domain.Entities.Budget entity, CancellationToken cancellationToken = default) { diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs index c099781..dd4f107 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/ApproveBudgetCommandHandlerTests.cs @@ -34,6 +34,8 @@ public async Task Handle_WithFinalizedBudget_ShouldApproveAndSave() var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); budget.FinalizeBudget(); + budget.StartPdfProcessing(); + budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf"); _budgetUpdateRepoMock .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs index fb8ca6d..f6488c3 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/FinalizeBudgetCommandHandlerTests.cs @@ -14,6 +14,7 @@ public class FinalizeBudgetCommandHandlerTests { private readonly Mock _budgetUpdateRepoMock = new(); private readonly Mock _unitOfWorkMock = new(); + private readonly Mock _queueServiceMock = new(); private readonly Guid _userId = Guid.NewGuid(); private readonly Guid _budgetId = Guid.NewGuid(); @@ -23,11 +24,12 @@ private FinalizeBudgetCommandHandler CreateHandler() { return new FinalizeBudgetCommandHandler( _budgetUpdateRepoMock.Object, - _unitOfWorkMock.Object); + _unitOfWorkMock.Object, + _queueServiceMock.Object); } [Fact] - public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave() + public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSaveAndQueueMessage() { // Arrange var budget = Budget.Register(_userId, _clientId); @@ -46,11 +48,13 @@ public async Task Handle_WithValidDraftBudget_ShouldFinalizeAndSave() // Assert result.IsError.ShouldBeFalse(); - result.Value.ShouldBe(Result.Updated); + result.Value.ShouldBe(Result.Success); budget.Status.ShouldBe(BudgetStatus.Finalized); + budget.PdfGenerationStatus.ShouldBe(PdfGenerationStatus.Pending); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + _queueServiceMock.Verify(q => q.SendMessageAsync("budget-reports", It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -73,5 +77,6 @@ public async Task Handle_WhenBudgetNotFound_ShouldReturnNotFound() result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Never); + _queueServiceMock.Verify(q => q.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } } diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs index 840321b..d640f74 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs @@ -27,10 +27,14 @@ public async Task Handle_WhenBudgetExists_ShouldSendMessageToQueueAndReturnSucce { // Arrange var budget = Budget.Register(_userId, Guid.NewGuid()); - _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + _budgetRepoMock.Setup(r => r.GetByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync(budget); - var command = new GenerateBudgetPdfCommand(_budgetId); + var command = new GenerateBudgetPdfCommand(_budgetId) { UserId = _userId }; var handler = CreateHandler(); // Act @@ -47,10 +51,10 @@ public async Task Handle_WhenBudgetExists_ShouldSendMessageToQueueAndReturnSucce public async Task Handle_WhenBudgetDoesNotExist_ShouldReturnNotFoundError() { // Arrange - _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + _budgetRepoMock.Setup(r => r.GetByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) .ReturnsAsync((Budget?)null); - var command = new GenerateBudgetPdfCommand(_budgetId); + var command = new GenerateBudgetPdfCommand(_budgetId) { UserId = _userId }; var handler = CreateHandler(); // Act @@ -59,7 +63,7 @@ public async Task Handle_WhenBudgetDoesNotExist_ShouldReturnNotFoundError() // Assert result.IsError.ShouldBeTrue(); result.FirstError.Type.ShouldBe(ErrorType.NotFound); - result.FirstError.Description.ShouldBe(ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.ORCAMENTO_NAO_ENCONTRADO); _queueServiceMock.Verify(q => q.SendMessageAsync(It.IsAny(), It.IsAny(), 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 index f4f1bf7..c75379d 100644 --- a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/RejectBudgetCommandHandlerTests.cs @@ -34,6 +34,8 @@ public async Task Handle_WithFinalizedBudget_ShouldRejectAndSave() var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 15.50m, "Cabo 10mm"); budget.AddItem(item); budget.FinalizeBudget(); + budget.StartPdfProcessing(); + budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf"); _budgetUpdateRepoMock .Setup(r => r.GetTrackedByIdAndUserIdAsync(_budgetId, _userId, It.IsAny())) diff --git a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs index bfa76d1..6e3aae1 100644 --- a/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs +++ b/tests/Voltiq.Domain.Tests/Entities/BudgetTests.cs @@ -164,12 +164,14 @@ public void FinalizeBudget_WithEmptyItems_ShouldThrowDomainException() } [Fact] - public void Approve_WithFinalizedBudget_ShouldTransitionToApproved() + public void Approve_WithFinalizedBudgetAndGeneratedPdf_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.StartPdfProcessing(); + budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf"); budget.Approve(); @@ -177,6 +179,19 @@ public void Approve_WithFinalizedBudget_ShouldTransitionToApproved() budget.DomainEvents.ShouldContain(e => e is BudgetApprovedEvent); } + [Fact] + public void Approve_WithFinalizedBudgetButNoPdf_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.Approve()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL); + } + [Fact] public void Approve_WhenDraftBudget_ShouldThrowDomainException() { @@ -188,12 +203,14 @@ public void Approve_WhenDraftBudget_ShouldThrowDomainException() } [Fact] - public void Reject_WithFinalizedBudget_ShouldTransitionToRejected() + public void Reject_WithFinalizedBudgetAndGeneratedPdf_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.StartPdfProcessing(); + budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf"); budget.Reject(); @@ -201,6 +218,19 @@ public void Reject_WithFinalizedBudget_ShouldTransitionToRejected() budget.DomainEvents.ShouldContain(e => e is BudgetRejectedEvent); } + [Fact] + public void Reject_WithFinalizedBudgetButNoPdf_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.Reject()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_PDF_NAO_DISPONIVEL); + } + [Fact] public void Reject_WhenDraftBudget_ShouldThrowDomainException() { @@ -210,4 +240,91 @@ public void Reject_WhenDraftBudget_ShouldThrowDomainException() budget.Reject()) .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_REJEICAO); } + + [Fact] + public void StartPdfProcessing_WithValidFinalizedBudget_ShouldTransitionToProcessing() + { + 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.StartPdfProcessing(); + + budget.PdfGenerationStatus.ShouldBe(PdfGenerationStatus.Processing); + budget.DomainEvents.ShouldContain(e => e is BudgetPdfGenerationStatusChangedEvent && ((BudgetPdfGenerationStatusChangedEvent)e).Status == PdfGenerationStatus.Processing); + } + + [Fact] + public void StartPdfProcessing_WithDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.StartPdfProcessing()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + } + + [Fact] + public void SetPdfGenerationSuccess_WithValidFinalizedBudget_ShouldTransitionToSuccessAndSetUrl() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10m, "Cabo"); + budget.AddItem(item); + budget.FinalizeBudget(); + + const string pdfUrl = "https://storage.voltiq.com/budgets/budget-123.pdf"; + budget.SetPdfGenerationSuccess(pdfUrl); + + budget.PdfUrl.ShouldBe(pdfUrl); + budget.PdfGenerationStatus.ShouldBe(PdfGenerationStatus.Success); + budget.DomainEvents.ShouldContain(e => e is BudgetPdfGenerationStatusChangedEvent && ((BudgetPdfGenerationStatusChangedEvent)e).Status == PdfGenerationStatus.Success); + } + + [Fact] + public void SetPdfGenerationSuccess_WithDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.SetPdfGenerationSuccess("https://storage.voltiq.com/budgets/budget-123.pdf")) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + } + + [Fact] + public void SetPdfGenerationSuccess_WithEmptyPdfUrl_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.SetPdfGenerationSuccess("")) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_PDF_URL_OBRIGATORIA); + } + + [Fact] + public void SetPdfGenerationFailed_WithValidFinalizedBudget_ShouldTransitionToFailed() + { + 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.SetPdfGenerationFailed(); + + budget.PdfGenerationStatus.ShouldBe(PdfGenerationStatus.Failed); + budget.DomainEvents.ShouldContain(e => e is BudgetPdfGenerationStatusChangedEvent && ((BudgetPdfGenerationStatusChangedEvent)e).Status == PdfGenerationStatus.Failed); + } + + [Fact] + public void SetPdfGenerationFailed_WithDraftBudget_ShouldThrowDomainException() + { + var budget = Budget.Register(ValidUserId, ValidClientId); + + Should.Throw(() => + budget.SetPdfGenerationFailed()) + .Message.ShouldBe(ResourceErrorMessages.ORCAMENTO_STATUS_INVALIDO_PARA_GERAR_PDF); + } } diff --git a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs index 963b64e..7ee532c 100644 --- a/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs +++ b/tests/Voltiq.Infrastructure.Tests/Persistence/BudgetRepositoryTests.cs @@ -162,4 +162,135 @@ await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user1.Id, found.ShouldBeNull(); } + + [Fact] + public async Task GetByClientIdAsync_ShouldReturnOnlyBudgetsOfClient() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client1 = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var client2 = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id, email: "outro@example.com"); + + await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client1.Id); + await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client2.Id); + + var budgets = await _budgetRepository.GetByClientIdAsync(client1.Id, TestContext.Current.CancellationToken); + + budgets.Count.ShouldBe(1); + budgets.ShouldAllBe(b => b.ClientId == client1.Id); + } + + [Fact] + public async Task GetByIdWithItemsAsync_ShouldReturnBudgetWithItems_WhenBudgetExists() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = TestDataBuilder.MakeBudget(user.Id, client.Id); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10.0m, "Instalação"); + budget.AddItem(item); + await _budgetRepository.AddAsync(budget, TestContext.Current.CancellationToken); + await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken); + + var found = await _budgetRepository.GetByIdWithItemsAsync(budget.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + found.Items.Count.ShouldBe(1); + found.Items.First().MaterialName.ShouldBe("Instalação"); + } + + [Fact] + public async Task Remove_ShouldDeleteBudget() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client.Id); + + _budgetRepository.Remove(budget); + await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken); + + var found = await _budgetRepository.GetByIdAsync(budget.Id, TestContext.Current.CancellationToken); + found.ShouldBeNull(); + } + + [Fact] + public async Task GetTrackedByIdAndUserIdAsync_ShouldReturnTrackedBudget_WhenBelongsToUser() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client.Id); + + _dbContext.ChangeTracker.Clear(); + + var found = await _budgetRepository.GetTrackedByIdAndUserIdAsync(budget.Id, user.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + _dbContext.Entry(found).State.ShouldBe(EntityState.Unchanged); + } + + [Fact] + public async Task GetTrackedByIdWithItemsAndUserIdAsync_ShouldReturnTrackedBudgetWithItems_WhenBelongsToUser() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = TestDataBuilder.MakeBudget(user.Id, client.Id); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10.0m, "Instalação"); + budget.AddItem(item); + await _budgetRepository.AddAsync(budget, TestContext.Current.CancellationToken); + await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken); + + _dbContext.ChangeTracker.Clear(); + + var found = await _budgetRepository.GetTrackedByIdWithItemsAndUserIdAsync(budget.Id, user.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + found.Items.Count.ShouldBe(1); + _dbContext.Entry(found).State.ShouldBe(EntityState.Unchanged); + } + + [Fact] + public async Task GetTrackedByIdAsync_ShouldReturnTrackedBudget_WhenExists() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client.Id); + + _dbContext.ChangeTracker.Clear(); + + var found = await _budgetRepository.GetTrackedByIdAsync(budget.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + _dbContext.Entry(found).State.ShouldBe(EntityState.Unchanged); + } + + [Fact] + public async Task GetByUserIdWithClientAsync_ShouldReturnBudgetsWithClientLoaded() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + await TestDataBuilder.SeedBudgetAsync(_budgetRepository, _unitOfWork, user.Id, client.Id); + + var budgets = await _budgetRepository.GetByUserIdWithClientAsync(user.Id, TestContext.Current.CancellationToken); + + budgets.Count.ShouldBe(1); + budgets.First().Client.ShouldNotBeNull(); + budgets.First().Client.Name.ShouldBe(client.Name); + } + + [Fact] + public async Task GetByIdWithItemsAndClientAsync_ShouldReturnBudgetWithItemsAndClientLoaded() + { + var user = await TestDataBuilder.SeedUserAsync(_userRepository, _unitOfWork); + var client = await TestDataBuilder.SeedClientAsync(_clientRepository, _unitOfWork, user.Id); + var budget = TestDataBuilder.MakeBudget(user.Id, client.Id); + var item = BudgetItem.Create(budget.Id, null, BudgetItemType.MaoDeObra, null, 2, 10.0m, "Instalação"); + budget.AddItem(item); + await _budgetRepository.AddAsync(budget, TestContext.Current.CancellationToken); + await _unitOfWork.SaveChangesAsync(TestContext.Current.CancellationToken); + + var found = await _budgetRepository.GetByIdWithItemsAndClientAsync(budget.Id, user.Id, TestContext.Current.CancellationToken); + + found.ShouldNotBeNull(); + found.Items.Count.ShouldBe(1); + found.Client.ShouldNotBeNull(); + found.Client.Name.ShouldBe(client.Name); + } }