diff --git a/.gitignore b/.gitignore index 17ef8ed..29de4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,9 @@ appsettings.Production.json # Logs logs/ +# Documentation +docs/ + # Copilot instructions .github/copilot-instructions.md diff --git a/src/Voltiq.Application/Common/Interfaces/Queue/IQueueService.cs b/src/Voltiq.Application/Common/Interfaces/Queue/IQueueService.cs new file mode 100644 index 0000000..dc22f72 --- /dev/null +++ b/src/Voltiq.Application/Common/Interfaces/Queue/IQueueService.cs @@ -0,0 +1,6 @@ +namespace Voltiq.Application.Common.Interfaces.Queue; + +public interface IQueueService +{ + Task SendMessageAsync(string queueName, T message, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Application/Common/Interfaces/Reports/BudgetReportData.cs b/src/Voltiq.Application/Common/Interfaces/Reports/BudgetReportData.cs new file mode 100644 index 0000000..29ac35e --- /dev/null +++ b/src/Voltiq.Application/Common/Interfaces/Reports/BudgetReportData.cs @@ -0,0 +1,10 @@ +namespace Voltiq.Application.Common.Interfaces.Reports; + +public class BudgetReportData +{ + public Guid BudgetId { get; set; } + public string ClientName { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/src/Voltiq.Application/Common/Interfaces/Reports/IReportGenerator.cs b/src/Voltiq.Application/Common/Interfaces/Reports/IReportGenerator.cs new file mode 100644 index 0000000..95ecadc --- /dev/null +++ b/src/Voltiq.Application/Common/Interfaces/Reports/IReportGenerator.cs @@ -0,0 +1,6 @@ +namespace Voltiq.Application.Common.Interfaces.Reports; + +public interface IReportGenerator +{ + Task GenerateAsync(TData data, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Application/Common/Interfaces/Reports/IReportStrategy.cs b/src/Voltiq.Application/Common/Interfaces/Reports/IReportStrategy.cs new file mode 100644 index 0000000..22b060b --- /dev/null +++ b/src/Voltiq.Application/Common/Interfaces/Reports/IReportStrategy.cs @@ -0,0 +1,6 @@ +namespace Voltiq.Application.Common.Interfaces.Reports; + +public interface IReportStrategy +{ + Task GenerateAsync(TData data, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Application/Common/Interfaces/Storage/IStorageService.cs b/src/Voltiq.Application/Common/Interfaces/Storage/IStorageService.cs new file mode 100644 index 0000000..7aa5927 --- /dev/null +++ b/src/Voltiq.Application/Common/Interfaces/Storage/IStorageService.cs @@ -0,0 +1,7 @@ +namespace Voltiq.Application.Common.Interfaces.Storage; + +public interface IStorageService +{ + Task UploadAsync(string fileName, byte[] data, string contentType, CancellationToken cancellationToken = default); + Task GetSasUrlAsync(string fileName, int expirationInHours = 1, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs new file mode 100644 index 0000000..d7e3a2c --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Commands/GenerateBudgetPdf/GenerateBudgetPdfCommand.cs @@ -0,0 +1,34 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces.Queue; +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> +{ + private readonly IQueueService _queueService; + private readonly IBudgetReadOnlyRepository _budgetRepository; + + public GenerateBudgetPdfCommandHandler(IQueueService queueService, IBudgetReadOnlyRepository budgetRepository) + { + _queueService = queueService; + _budgetRepository = budgetRepository; + } + + public async Task> Handle(GenerateBudgetPdfCommand request, CancellationToken cancellationToken) + { + var budget = await _budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken); + if (budget == null) + return Error.NotFound(description: ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + + var message = new { request.BudgetId }; + + await _queueService.SendMessageAsync("budget-reports", message, cancellationToken); + + return Result.Success; + } +} diff --git a/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetPdfUrl/GetBudgetPdfUrlQuery.cs b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetPdfUrl/GetBudgetPdfUrlQuery.cs new file mode 100644 index 0000000..1dd4517 --- /dev/null +++ b/src/Voltiq.Application/Features/Budgets/Queries/GetBudgetPdfUrl/GetBudgetPdfUrlQuery.cs @@ -0,0 +1,36 @@ +using ErrorOr; +using MediatR; +using Voltiq.Application.Common.Interfaces.Storage; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Features.Budgets.Queries.GetBudgetPdfUrl; + +public record GetBudgetPdfUrlQuery(Guid BudgetId) : IRequest>; + +public class GetBudgetPdfUrlQueryHandler : IRequestHandler> +{ + private readonly IStorageService _storageService; + private readonly IBudgetReadOnlyRepository _budgetRepository; + + public GetBudgetPdfUrlQueryHandler(IStorageService storageService, IBudgetReadOnlyRepository budgetRepository) + { + _storageService = storageService; + _budgetRepository = budgetRepository; + } + + public async Task> Handle(GetBudgetPdfUrlQuery request, CancellationToken cancellationToken) + { + var budget = await _budgetRepository.GetByIdAsync(request.BudgetId, cancellationToken); + if (budget == null) + return Error.NotFound(description: ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + + var fileName = $"budget-{request.BudgetId}.pdf"; + var url = await _storageService.GetSasUrlAsync(fileName, 1, cancellationToken); + + if (string.IsNullOrEmpty(url)) + return Error.NotFound(code: "Budget.PdfNotGenerated", description: "O PDF ainda não foi gerado ou não está disponível."); + + return url; + } +} diff --git a/src/Voltiq.Domain/Entities/Service.cs b/src/Voltiq.Domain/Entities/Service.cs new file mode 100644 index 0000000..7e3cbab --- /dev/null +++ b/src/Voltiq.Domain/Entities/Service.cs @@ -0,0 +1,54 @@ +using Voltiq.Domain.Events; +using Voltiq.Exceptions.Exceptions; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Domain.Entities; + +public sealed class Service : AuditableEntity +{ + public Guid UserId { get; private set; } + public string Name { get; private set; } = null!; + public decimal BasePrice { get; private set; } + public bool IsActive { get; private set; } + + private Service() { } + + private Service(Guid userId, string name, decimal basePrice) + { + UserId = userId; + Name = name; + BasePrice = basePrice; + IsActive = true; + AddDomainEvent(new ServiceRegisteredEvent(Id)); + } + + public static Service Register(Guid userId, string name, decimal basePrice) + { + if (userId == Guid.Empty) + throw new DomainException(ResourceErrorMessages.SERVICE_USUARIO_OBRIGATORIO); + + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException(ResourceErrorMessages.SERVICE_NOME_OBRIGATORIO); + + if (basePrice <= 0) + throw new DomainException(ResourceErrorMessages.SERVICE_PRECO_INVALIDO); + + return new Service(userId, name.Trim(), basePrice); + } + + public void Deactivate() => IsActive = false; + + public void Activate() => IsActive = true; + + public void Update(string name, decimal basePrice) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException(ResourceErrorMessages.SERVICE_NOME_OBRIGATORIO); + + if (basePrice <= 0) + throw new DomainException(ResourceErrorMessages.SERVICE_PRECO_INVALIDO); + + Name = name.Trim(); + BasePrice = basePrice; + } +} diff --git a/src/Voltiq.Domain/Events/ServiceRegisteredEvent.cs b/src/Voltiq.Domain/Events/ServiceRegisteredEvent.cs new file mode 100644 index 0000000..c5a6456 --- /dev/null +++ b/src/Voltiq.Domain/Events/ServiceRegisteredEvent.cs @@ -0,0 +1,3 @@ +namespace Voltiq.Domain.Events; + +public sealed record ServiceRegisteredEvent(Guid ServiceId) : BaseDomainEvent; diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceReadOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceReadOnlyRepository.cs new file mode 100644 index 0000000..312ea7f --- /dev/null +++ b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceReadOnlyRepository.cs @@ -0,0 +1,9 @@ +namespace Voltiq.Domain.Interfaces.Repositories.Service; + +public interface IServiceReadOnlyRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + Task GetByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + Task> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceUpdateOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceUpdateOnlyRepository.cs new file mode 100644 index 0000000..4eb8dad --- /dev/null +++ b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceUpdateOnlyRepository.cs @@ -0,0 +1,7 @@ +namespace Voltiq.Domain.Interfaces.Repositories.Service; + +public interface IServiceUpdateOnlyRepository +{ + Task GetTrackedByIdAndUserIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default); + void Remove(Entities.Service entity); +} diff --git a/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceWriteOnlyRepository.cs b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceWriteOnlyRepository.cs new file mode 100644 index 0000000..85f6307 --- /dev/null +++ b/src/Voltiq.Domain/Interfaces/Repositories/Service/IServiceWriteOnlyRepository.cs @@ -0,0 +1,6 @@ +namespace Voltiq.Domain.Interfaces.Repositories.Service; + +public interface IServiceWriteOnlyRepository +{ + Task AddAsync(Entities.Service entity, CancellationToken cancellationToken = default); +} diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs index dd5f7fa..934b31b 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.Designer.cs @@ -284,6 +284,42 @@ public static string MATERIAL_USUARIO_OBRIGATORIO { } } + /// + /// Looks up a localized string similar to Serviço não encontrado.. + /// + public static string SERVICE_NAO_ENCONTRADO { + get { + return ResourceManager.GetString("SERVICE_NAO_ENCONTRADO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O nome do serviço é obrigatório.. + /// + public static string SERVICE_NOME_OBRIGATORIO { + get { + return ResourceManager.GetString("SERVICE_NOME_OBRIGATORIO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O preço base do serviço deve ser maior que zero.. + /// + public static string SERVICE_PRECO_INVALIDO { + get { + return ResourceManager.GetString("SERVICE_PRECO_INVALIDO", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to O usuário do serviço é obrigatório.. + /// + public static string SERVICE_USUARIO_OBRIGATORIO { + get { + return ResourceManager.GetString("SERVICE_USUARIO_OBRIGATORIO", resourceCulture); + } + } + /// /// Looks up a localized string similar to O nome é obrigatório.. /// diff --git a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx index d341206..d6e4fc8 100644 --- a/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx +++ b/src/Voltiq.Exceptions/Resources/ResourceErrorMessages.resx @@ -182,6 +182,21 @@ Material não encontrado. + + + + O usuário do serviço é obrigatório. + + + O nome do serviço é obrigatório. + + + O preço base do serviço deve ser maior que zero. + + + Serviço não encontrado. + + diff --git a/src/Voltiq.Functions/.gitignore b/src/Voltiq.Functions/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/src/Voltiq.Functions/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/src/Voltiq.Functions/GenerateReportFunction.cs b/src/Voltiq.Functions/GenerateReportFunction.cs new file mode 100644 index 0000000..cd828ae --- /dev/null +++ b/src/Voltiq.Functions/GenerateReportFunction.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Voltiq.Application.Common.Interfaces.Reports; +using Voltiq.Application.Common.Interfaces.Storage; +using Voltiq.Domain.Interfaces; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Domain.Interfaces.Repositories.Client; + +namespace Voltiq.Functions; + +public class GenerateReportMessage +{ + public Guid BudgetId { get; set; } +} + +public class GenerateReportFunction +{ + private readonly ILogger _logger; + private readonly IBudgetReadOnlyRepository _budgetRepository; + private readonly IBudgetUpdateOnlyRepository _budgetUpdateRepository; + private readonly IClientReadOnlyRepository _clientRepository; + private readonly IReportGenerator _reportGenerator; + private readonly IStorageService _storageService; + private readonly IUnitOfWork _unitOfWork; + + public GenerateReportFunction( + ILogger logger, + IBudgetReadOnlyRepository budgetRepository, + IBudgetUpdateOnlyRepository budgetUpdateRepository, + IClientReadOnlyRepository clientRepository, + IReportGenerator reportGenerator, + IStorageService storageService, + IUnitOfWork unitOfWork) + { + _logger = logger; + _budgetRepository = budgetRepository; + _budgetUpdateRepository = budgetUpdateRepository; + _clientRepository = clientRepository; + _reportGenerator = reportGenerator; + _storageService = storageService; + _unitOfWork = unitOfWork; + } + + [Function(nameof(GenerateReportFunction))] + public async Task Run([QueueTrigger("budget-reports")] string message, CancellationToken cancellationToken) + { + _logger.LogInformation("Processing report generation message: {Message}", message); + + try + { + var msg = JsonSerializer.Deserialize(message); + if (msg == null || msg.BudgetId == Guid.Empty) + { + _logger.LogWarning("Invalid message format."); + return; + } + + var budget = await _budgetRepository.GetByIdAsync(msg.BudgetId, cancellationToken); + if (budget == null) + { + _logger.LogWarning("Budget not found for ID: {BudgetId}", msg.BudgetId); + return; + } + + var client = await _clientRepository.GetByIdAndUserIdAsync(budget.ClientId, budget.UserId, cancellationToken); + + var reportData = new BudgetReportData + { + BudgetId = budget.Id, + ProjectName = $"Orçamento #{budget.Id.ToString()[..8]}", + ClientName = client?.Name ?? "Cliente não informado", + TotalAmount = budget.TotalAmount, + CreatedAt = budget.CreatedAt + }; + + _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); + + budget.MarkAsPdfGenerated(uri); + _budgetUpdateRepository.Update(budget); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Report generated and budget status updated successfully. URI: {Uri}", uri); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing report generation for message: {Message}", message); + throw; + } + } +} diff --git a/src/Voltiq.Functions/Program.cs b/src/Voltiq.Functions/Program.cs new file mode 100644 index 0000000..855edbe --- /dev/null +++ b/src/Voltiq.Functions/Program.cs @@ -0,0 +1,22 @@ +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Azure.Functions.Worker.OpenTelemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Voltiq.Application; +using Voltiq.Infrastructure; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +builder.Services.AddApplication(builder.Configuration); +builder.Services.AddInfrastructure(builder.Configuration); + +if (!string.IsNullOrEmpty( + Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) + builder.Services.AddOpenTelemetry() + .UseFunctionsWorkerDefaults() + .UseAzureMonitorExporter(); + +builder.Build().Run(); diff --git a/src/Voltiq.Functions/Voltiq.Functions.csproj b/src/Voltiq.Functions/Voltiq.Functions.csproj new file mode 100644 index 0000000..2bbc763 --- /dev/null +++ b/src/Voltiq.Functions/Voltiq.Functions.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + V4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/Voltiq.Functions/host.json b/src/Voltiq.Functions/host.json new file mode 100644 index 0000000..bb804e0 --- /dev/null +++ b/src/Voltiq.Functions/host.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "telemetryMode": "OpenTelemetry" +} diff --git a/src/Voltiq.Infrastructure/Queue/AzureQueueService.cs b/src/Voltiq.Infrastructure/Queue/AzureQueueService.cs new file mode 100644 index 0000000..e344833 --- /dev/null +++ b/src/Voltiq.Infrastructure/Queue/AzureQueueService.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Azure.Storage.Queues; +using Microsoft.Extensions.Configuration; +using Voltiq.Application.Common.Interfaces.Queue; + +namespace Voltiq.Infrastructure.Queue; + +public class AzureQueueService : IQueueService +{ + private readonly QueueServiceClient _queueServiceClient; + + public AzureQueueService(IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("StorageAccount") ?? + configuration["AzureWebJobsStorage"] ?? "UseDevelopmentStorage=true"; + _queueServiceClient = new QueueServiceClient(connectionString); + } + + public async Task SendMessageAsync(string queueName, T message, + CancellationToken cancellationToken = default) + { + var queueClient = _queueServiceClient.GetQueueClient(queueName); + await queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + + var messageBody = JsonSerializer.Serialize(message); + await queueClient.SendMessageAsync(messageBody, cancellationToken); + } +} diff --git a/src/Voltiq.Infrastructure/Reports/QuestPdfReportGenerator.cs b/src/Voltiq.Infrastructure/Reports/QuestPdfReportGenerator.cs new file mode 100644 index 0000000..447fd0c --- /dev/null +++ b/src/Voltiq.Infrastructure/Reports/QuestPdfReportGenerator.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using QuestPDF; +using QuestPDF.Infrastructure; +using Voltiq.Application.Common.Interfaces.Reports; + +namespace Voltiq.Infrastructure.Reports; + +public class QuestPdfReportGenerator : IReportGenerator +{ + private readonly IServiceProvider _serviceProvider; + + public QuestPdfReportGenerator(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + + Settings.License = LicenseType.Community; + } + + public async Task GenerateAsync(TData data, + CancellationToken cancellationToken = default) + { + var strategy = _serviceProvider.GetRequiredService>(); + return await strategy.GenerateAsync(data, cancellationToken); + } +} diff --git a/src/Voltiq.Infrastructure/Reports/Strategies/BudgetReportStrategy.cs b/src/Voltiq.Infrastructure/Reports/Strategies/BudgetReportStrategy.cs new file mode 100644 index 0000000..84dd192 --- /dev/null +++ b/src/Voltiq.Infrastructure/Reports/Strategies/BudgetReportStrategy.cs @@ -0,0 +1,50 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Voltiq.Application.Common.Interfaces.Reports; + +namespace Voltiq.Infrastructure.Reports.Strategies; + +public class BudgetReportStrategy : IReportStrategy +{ + public Task GenerateAsync(BudgetReportData data, CancellationToken cancellationToken = default) + { + var document = Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(2, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(12)); + + page.Header() + .Text($"Orçamento: {data.ProjectName}") + .SemiBold().FontSize(20).FontColor(Colors.Blue.Darken2); + + page.Content() + .PaddingVertical(1, Unit.Centimetre) + .Column(x => + { + x.Spacing(20); + x.Item().Text($"Cliente: {data.ClientName}"); + x.Item().Text($"Data: {data.CreatedAt:dd/MM/yyyy}"); + x.Item().Text($"Valor Total: {data.TotalAmount:C}"); + }); + + page.Footer() + .AlignCenter() + .Text(x => + { + x.Span("Página "); + x.CurrentPageNumber(); + x.Span(" de "); + x.TotalPages(); + }); + }); + }); + + var pdfBytes = document.GeneratePdf(); + return Task.FromResult(pdfBytes); + } +} diff --git a/src/Voltiq.Infrastructure/Storage/AzureBlobStorageService.cs b/src/Voltiq.Infrastructure/Storage/AzureBlobStorageService.cs new file mode 100644 index 0000000..d580233 --- /dev/null +++ b/src/Voltiq.Infrastructure/Storage/AzureBlobStorageService.cs @@ -0,0 +1,60 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Sas; +using Microsoft.Extensions.Configuration; +using Voltiq.Application.Common.Interfaces.Storage; + +namespace Voltiq.Infrastructure.Storage; + +public class AzureBlobStorageService : IStorageService +{ + private const string CONTAINER_NAME = "reports"; + private readonly BlobServiceClient _blobServiceClient; + + public AzureBlobStorageService(IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("StorageAccount") ?? + configuration["AzureWebJobsStorage"] ?? "UseDevelopmentStorage=true"; + _blobServiceClient = new BlobServiceClient(connectionString); + } + + public async Task UploadAsync(string fileName, byte[] data, string contentType, + CancellationToken cancellationToken = default) + { + var containerClient = _blobServiceClient.GetBlobContainerClient(CONTAINER_NAME); + await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient(fileName); + + using var stream = new MemoryStream(data); + await blobClient.UploadAsync(stream, true, cancellationToken); + + return blobClient.Uri.ToString(); + } + + public async Task GetSasUrlAsync(string fileName, int expirationInHours = 1, + CancellationToken cancellationToken = default) + { + var containerClient = _blobServiceClient.GetBlobContainerClient(CONTAINER_NAME); + var blobClient = containerClient.GetBlobClient(fileName); + + if (!await blobClient.ExistsAsync(cancellationToken)) + return string.Empty; + + if (blobClient.CanGenerateSasUri) + { + var sasBuilder = new BlobSasBuilder + { + BlobContainerName = CONTAINER_NAME, + BlobName = fileName, + Resource = "b", + ExpiresOn = DateTimeOffset.UtcNow.AddHours(expirationInHours) + }; + sasBuilder.SetPermissions(BlobSasPermissions.Read); + + var sasUri = blobClient.GenerateSasUri(sasBuilder); + return sasUri.ToString(); + } + + return blobClient.Uri.ToString(); + } +} diff --git a/src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj b/src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj index 5a89ac2..2bfaaa2 100644 --- a/src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj +++ b/src/Voltiq.Infrastructure/Voltiq.Infrastructure.csproj @@ -6,6 +6,8 @@ + + @@ -16,6 +18,7 @@ + diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs new file mode 100644 index 0000000..840321b --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Commands/GenerateBudgetPdfCommandHandlerTests.cs @@ -0,0 +1,66 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Common.Interfaces.Queue; +using Voltiq.Application.Features.Budgets.Commands.GenerateBudgetPdf; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Enums; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Commands; + +public class GenerateBudgetPdfCommandHandlerTests +{ + private readonly Mock _budgetRepoMock = new(); + private readonly Mock _queueServiceMock = new(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + + private GenerateBudgetPdfCommandHandler CreateHandler() + { + return new GenerateBudgetPdfCommandHandler(_queueServiceMock.Object, _budgetRepoMock.Object); + } + + [Fact] + public async Task Handle_WhenBudgetExists_ShouldSendMessageToQueueAndReturnSuccess() + { + // Arrange + var budget = Budget.Register(_userId, Guid.NewGuid()); + _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + .ReturnsAsync(budget); + + var command = new GenerateBudgetPdfCommand(_budgetId); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(Result.Success); + + _queueServiceMock.Verify(q => q.SendMessageAsync("budget-reports", It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenBudgetDoesNotExist_ShouldReturnNotFoundError() + { + // Arrange + _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var command = new GenerateBudgetPdfCommand(_budgetId); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + + _queueServiceMock.Verify(q => q.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetPdfUrlQueryHandlerTests.cs b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetPdfUrlQueryHandlerTests.cs new file mode 100644 index 0000000..2cba115 --- /dev/null +++ b/tests/Voltiq.Application.Tests/Features/Budgets/Queries/GetBudgetPdfUrlQueryHandlerTests.cs @@ -0,0 +1,90 @@ +using ErrorOr; +using Moq; +using Shouldly; +using Voltiq.Application.Common.Interfaces.Storage; +using Voltiq.Application.Features.Budgets.Queries.GetBudgetPdfUrl; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Interfaces.Repositories.Budget; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Application.Tests.Features.Budgets.Queries; + +public class GetBudgetPdfUrlQueryHandlerTests +{ + private readonly Mock _budgetRepoMock = new(); + private readonly Mock _storageServiceMock = new(); + private readonly Guid _budgetId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + + private GetBudgetPdfUrlQueryHandler CreateHandler() + { + return new GetBudgetPdfUrlQueryHandler(_storageServiceMock.Object, _budgetRepoMock.Object); + } + + [Fact] + public async Task Handle_WhenPdfExists_ShouldReturnUrl() + { + // Arrange + var budget = Budget.Register(_userId, Guid.NewGuid()); + _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + .ReturnsAsync(budget); + + var expectedUrl = "https://azure.blob/reports/budget.pdf?sas=token"; + _storageServiceMock.Setup(s => s.GetSasUrlAsync($"budget-{_budgetId}.pdf", 1, It.IsAny())) + .ReturnsAsync(expectedUrl); + + var query = new GetBudgetPdfUrlQuery(_budgetId); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsError.ShouldBeFalse(); + result.Value.ShouldBe(expectedUrl); + } + + [Fact] + public async Task Handle_WhenPdfDoesNotExistInStorage_ShouldReturnNotFoundError() + { + // Arrange + var budget = Budget.Register(_userId, Guid.NewGuid()); + _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + .ReturnsAsync(budget); + + _storageServiceMock.Setup(s => s.GetSasUrlAsync($"budget-{_budgetId}.pdf", 1, It.IsAny())) + .ReturnsAsync(string.Empty); + + var query = new GetBudgetPdfUrlQuery(_budgetId); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Code.ShouldBe("Budget.PdfNotGenerated"); + } + + [Fact] + public async Task Handle_WhenBudgetDoesNotExist_ShouldReturnNotFoundError() + { + // Arrange + _budgetRepoMock.Setup(r => r.GetByIdAsync(_budgetId, It.IsAny())) + .ReturnsAsync((Budget?)null); + + var query = new GetBudgetPdfUrlQuery(_budgetId); + var handler = CreateHandler(); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsError.ShouldBeTrue(); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); + result.FirstError.Description.ShouldBe(ResourceErrorMessages.TITULO_NAO_ENCONTRADO); + + _storageServiceMock.Verify(s => s.GetSasUrlAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Voltiq.CommonTestUtilities/Fixtures/AzuriteContainerFixture.cs b/tests/Voltiq.CommonTestUtilities/Fixtures/AzuriteContainerFixture.cs new file mode 100644 index 0000000..e057ce6 --- /dev/null +++ b/tests/Voltiq.CommonTestUtilities/Fixtures/AzuriteContainerFixture.cs @@ -0,0 +1,13 @@ +using Testcontainers.Azurite; +using Testcontainers.Xunit; +using Xunit.Sdk; + +namespace Voltiq.CommonTestUtilities.Fixtures; + +public sealed class AzuriteContainerFixture(IMessageSink messageSink) + : ContainerFixture(messageSink) +{ + protected override AzuriteBuilder Configure() + => new AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite") + .WithCommand("--skipApiVersionCheck"); +} diff --git a/tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj b/tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj index e832521..043a35f 100644 --- a/tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj +++ b/tests/Voltiq.CommonTestUtilities/Voltiq.CommonTestUtilities.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/Voltiq.Domain.Tests/Entities/ServiceTests.cs b/tests/Voltiq.Domain.Tests/Entities/ServiceTests.cs new file mode 100644 index 0000000..6a934e5 --- /dev/null +++ b/tests/Voltiq.Domain.Tests/Entities/ServiceTests.cs @@ -0,0 +1,149 @@ +using Shouldly; +using Voltiq.Domain.Entities; +using Voltiq.Domain.Events; +using Voltiq.Exceptions.Exceptions; +using Voltiq.Exceptions.Resources; + +namespace Voltiq.Domain.Tests.Entities; + +public class ServiceTests +{ + private static readonly Guid ValidUserId = Guid.NewGuid(); + + [Fact] + public void Register_WithValidData_ShouldRegisterService() + { + var service = Service.Register(ValidUserId, "Instalação Elétrica Residencial", 150.00m); + + service.Id.ShouldNotBe(Guid.Empty); + service.UserId.ShouldBe(ValidUserId); + service.Name.ShouldBe("Instalação Elétrica Residencial"); + service.BasePrice.ShouldBe(150.00m); + service.IsActive.ShouldBeTrue(); + } + + [Fact] + public void Register_ShouldRaise_ServiceRegisteredEvent() + { + var service = Service.Register(ValidUserId, "Instalação Elétrica Residencial", 150.00m); + + service.DomainEvents.ShouldContain(e => e is ServiceRegisteredEvent); + var domainEvent = (ServiceRegisteredEvent)service.DomainEvents.First(e => e is ServiceRegisteredEvent); + domainEvent.ServiceId.ShouldBe(service.Id); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Register_WithNullOrEmptyName_ShouldThrowDomainException(string? name) + { + Should.Throw(() => + Service.Register(ValidUserId, name!, 100m)) + .Message.ShouldBe(ResourceErrorMessages.SERVICE_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-0.01)] + public void Register_WithInvalidPrice_ShouldThrowDomainException(decimal price) + { + Should.Throw(() => + Service.Register(ValidUserId, "Instalação", price)) + .Message.ShouldBe(ResourceErrorMessages.SERVICE_PRECO_INVALIDO); + } + + [Fact] + public void Register_TrimsName() + { + var service = Service.Register(ValidUserId, " Instalação Elétrica ", 100m); + + service.Name.ShouldBe("Instalação Elétrica"); + } + + [Fact] + public void Register_WithEmptyUserId_ShouldThrowDomainException() + { + Should.Throw(() => + Service.Register(Guid.Empty, "Instalação", 100m)) + .Message.ShouldBe(ResourceErrorMessages.SERVICE_USUARIO_OBRIGATORIO); + } + + [Fact] + public void Deactivate_ShouldSetIsActiveToFalse() + { + var service = Service.Register(ValidUserId, "Instalação", 100m); + + service.Deactivate(); + + service.IsActive.ShouldBeFalse(); + } + + [Fact] + public void Activate_ShouldSetIsActiveToTrue() + { + var service = Service.Register(ValidUserId, "Instalação", 100m); + service.Deactivate(); + + service.Activate(); + + service.IsActive.ShouldBeTrue(); + } + + [Fact] + public void Update_WithValidData_ShouldUpdateFields() + { + var service = Service.Register(ValidUserId, "Instalação Elétrica", 150.00m); + + service.Update("Manutenção Residencial", 120.00m); + + service.Name.ShouldBe("Manutenção Residencial"); + service.BasePrice.ShouldBe(120.00m); + } + + [Fact] + public void Update_ShouldTrimName() + { + var service = Service.Register(ValidUserId, "Instalação Elétrica", 150.00m); + + service.Update(" Manutenção Residencial ", 120.00m); + + service.Name.ShouldBe("Manutenção Residencial"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Update_WithNullOrEmptyName_ShouldThrowDomainException(string? name) + { + var service = Service.Register(ValidUserId, "Instalação Elétrica", 150.00m); + + Should.Throw(() => service.Update(name!, 120.00m)) + .Message.ShouldBe(ResourceErrorMessages.SERVICE_NOME_OBRIGATORIO); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-0.01)] + public void Update_WithInvalidPrice_ShouldThrowDomainException(decimal price) + { + var service = Service.Register(ValidUserId, "Instalação Elétrica", 150.00m); + + Should.Throw(() => service.Update("Manutenção Residencial", price)) + .Message.ShouldBe(ResourceErrorMessages.SERVICE_PRECO_INVALIDO); + } + + [Fact] + public void Update_ShouldNotAffectIsActive() + { + var service = Service.Register(ValidUserId, "Instalação Elétrica", 150.00m); + service.Deactivate(); + + service.Update("Manutenção Residencial", 120.00m); + + service.IsActive.ShouldBeFalse(); + } +} diff --git a/tests/Voltiq.Infrastructure.Tests/Queue/AzureQueueServiceTests.cs b/tests/Voltiq.Infrastructure.Tests/Queue/AzureQueueServiceTests.cs new file mode 100644 index 0000000..d30bf29 --- /dev/null +++ b/tests/Voltiq.Infrastructure.Tests/Queue/AzureQueueServiceTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Azure.Storage.Queues; +using Microsoft.Extensions.Configuration; +using Shouldly; +using Voltiq.CommonTestUtilities.Fixtures; +using Voltiq.Infrastructure.Queue; + +namespace Voltiq.Infrastructure.Tests.Queue; + +public class AzureQueueServiceTests(AzuriteContainerFixture fixture) + : IClassFixture, IAsyncLifetime +{ + private AzureQueueService _queueService = null!; + private QueueServiceClient _queueServiceClient = null!; + + public ValueTask InitializeAsync() + { + var connectionString = fixture.Container.GetConnectionString(); + _queueServiceClient = new QueueServiceClient(connectionString); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:StorageAccount", connectionString } + }) + .Build(); + + _queueService = new AzureQueueService(configuration); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task SendMessageAsync_ShouldCreateQueueAndSendMessage() + { + // Arrange + var queueName = $"test-queue-{Guid.NewGuid()}"; + var testMessage = new TestMessagePayload { MessageId = Guid.NewGuid(), Content = "Hello Queue!" }; + + // Act + await _queueService.SendMessageAsync(queueName, testMessage, CancellationToken.None); + + // Assert + var queueClient = _queueServiceClient.GetQueueClient(queueName); + var exists = await queueClient.ExistsAsync(TestContext.Current.CancellationToken); + exists.Value.ShouldBeTrue(); + + var messages = await queueClient.ReceiveMessagesAsync(1, cancellationToken: TestContext.Current.CancellationToken); + messages.Value.Length.ShouldBe(1); + + var receivedMessage = messages.Value[0]; + var payload = JsonSerializer.Deserialize(receivedMessage.Body.ToString()); + + payload.ShouldNotBeNull(); + payload.MessageId.ShouldBe(testMessage.MessageId); + payload.Content.ShouldBe(testMessage.Content); + } + + private class TestMessagePayload + { + public Guid MessageId { get; set; } + public string Content { get; set; } = string.Empty; + } +} diff --git a/tests/Voltiq.Infrastructure.Tests/Reports/BudgetReportStrategyTests.cs b/tests/Voltiq.Infrastructure.Tests/Reports/BudgetReportStrategyTests.cs new file mode 100644 index 0000000..2bde351 --- /dev/null +++ b/tests/Voltiq.Infrastructure.Tests/Reports/BudgetReportStrategyTests.cs @@ -0,0 +1,36 @@ +using QuestPDF.Infrastructure; +using Shouldly; +using Voltiq.Application.Common.Interfaces.Reports; +using Voltiq.Infrastructure.Reports.Strategies; + +namespace Voltiq.Infrastructure.Tests.Reports; + +public class BudgetReportStrategyTests +{ + public BudgetReportStrategyTests() + { + QuestPDF.Settings.License = LicenseType.Community; + } + + [Fact] + public async Task GenerateAsync_ShouldReturnPdfBytes_WhenGivenValidData() + { + // Arrange + var strategy = new BudgetReportStrategy(); + var data = new BudgetReportData + { + BudgetId = Guid.NewGuid(), + ProjectName = "Instalação Residencial", + ClientName = "José da Silva", + CreatedAt = new DateTime(2026, 6, 25), + TotalAmount = 1500.50m + }; + + // Act + var pdfBytes = await strategy.GenerateAsync(data, CancellationToken.None); + + // Assert + pdfBytes.ShouldNotBeNull(); + pdfBytes.Length.ShouldBeGreaterThan(0); + } +} diff --git a/tests/Voltiq.Infrastructure.Tests/Reports/QuestPdfReportGeneratorTests.cs b/tests/Voltiq.Infrastructure.Tests/Reports/QuestPdfReportGeneratorTests.cs new file mode 100644 index 0000000..499e819 --- /dev/null +++ b/tests/Voltiq.Infrastructure.Tests/Reports/QuestPdfReportGeneratorTests.cs @@ -0,0 +1,35 @@ +using Moq; +using Shouldly; +using Voltiq.Application.Common.Interfaces.Reports; +using Voltiq.Infrastructure.Reports; + +namespace Voltiq.Infrastructure.Tests.Reports; + +public class QuestPdfReportGeneratorTests +{ + private readonly Mock _serviceProviderMock = new(); + private readonly Mock> _reportStrategyMock = new(); + + [Fact] + public async Task GenerateAsync_ShouldCallStrategyAndReturnBytes() + { + // Arrange + var expectedBytes = "PDF-CONTENT"u8.ToArray(); + var data = new BudgetReportData { ProjectName = "Test" }; + + _reportStrategyMock.Setup(s => s.GenerateAsync(data, It.IsAny())) + .ReturnsAsync(expectedBytes); + + _serviceProviderMock.Setup(sp => sp.GetService(typeof(IReportStrategy))) + .Returns(_reportStrategyMock.Object); + + var generator = new QuestPdfReportGenerator(_serviceProviderMock.Object); + + // Act + var result = await generator.GenerateAsync(data, CancellationToken.None); + + // Assert + result.ShouldBe(expectedBytes); + _reportStrategyMock.Verify(s => s.GenerateAsync(data, It.IsAny()), Times.Once); + } +} diff --git a/tests/Voltiq.Infrastructure.Tests/Storage/AzureBlobStorageServiceTests.cs b/tests/Voltiq.Infrastructure.Tests/Storage/AzureBlobStorageServiceTests.cs new file mode 100644 index 0000000..0894071 --- /dev/null +++ b/tests/Voltiq.Infrastructure.Tests/Storage/AzureBlobStorageServiceTests.cs @@ -0,0 +1,88 @@ +using Azure.Storage.Blobs; +using Microsoft.Extensions.Configuration; +using Shouldly; +using Voltiq.CommonTestUtilities.Fixtures; +using Voltiq.Infrastructure.Storage; + +namespace Voltiq.Infrastructure.Tests.Storage; + +public class AzureBlobStorageServiceTests(AzuriteContainerFixture fixture) + : IClassFixture, IAsyncLifetime +{ + private AzureBlobStorageService _storageService = null!; + private BlobServiceClient _blobServiceClient = null!; + + public ValueTask InitializeAsync() + { + var connectionString = fixture.Container.GetConnectionString(); + _blobServiceClient = new BlobServiceClient(connectionString); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:StorageAccount", connectionString } + }) + .Build(); + + _storageService = new AzureBlobStorageService(configuration); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task UploadAsync_ShouldUploadFileAndReturnBlobUri() + { + // Arrange + var fileName = $"test-{Guid.NewGuid()}.txt"; + var fileData = "Hello Azurite!"u8.ToArray(); + var contentType = "text/plain"; + + // Act + var resultUri = await _storageService.UploadAsync(fileName, fileData, contentType, CancellationToken.None); + + // Assert + resultUri.ShouldNotBeNullOrEmpty(); + resultUri.ShouldContain(fileName); + resultUri.ShouldContain("reports"); + + // Verify the blob actually exists in the container + var containerClient = _blobServiceClient.GetBlobContainerClient("reports"); + var blobClient = containerClient.GetBlobClient(fileName); + var exists = await blobClient.ExistsAsync(TestContext.Current.CancellationToken); + exists.Value.ShouldBeTrue(); + } + + [Fact] + public async Task GetSasUrlAsync_WhenBlobExists_ShouldReturnSasUri() + { + // Arrange + var fileName = $"test-sas-{Guid.NewGuid()}.txt"; + var fileData = "SAS Test Data"u8.ToArray(); + var contentType = "text/plain"; + + // Upload first + await _storageService.UploadAsync(fileName, fileData, contentType, CancellationToken.None); + + // Act + var sasUrl = await _storageService.GetSasUrlAsync(fileName, 1, CancellationToken.None); + + // Assert + sasUrl.ShouldNotBeNullOrEmpty(); + sasUrl.ShouldContain(fileName); + sasUrl.ShouldContain("sig="); // SAS token signature query parameter + } + + [Fact] + public async Task GetSasUrlAsync_WhenBlobDoesNotExist_ShouldReturnEmptyString() + { + // Arrange + var fileName = $"non-existent-{Guid.NewGuid()}.txt"; + + // Act + var sasUrl = await _storageService.GetSasUrlAsync(fileName, 1, CancellationToken.None); + + // Assert + sasUrl.ShouldBeEmpty(); + } +}