diff --git a/Directory.Build.props b/Directory.Build.props index 7be86f068..0678a494d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,6 +6,7 @@ net9.0 enable enable + false false @@ -61,6 +62,10 @@ $(NoWarn);CA2213 $(NoWarn);CA1816 + + $(NoWarn);CA1016 + $(NoWarn);S3904 + $(NoWarn);CA1304 $(NoWarn);CA1305 diff --git a/README.md b/README.md index 408477efe..1a238d658 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,18 @@ MeAjudaAi/ │ ├── Bootstrapper/ # API service bootstrapper │ │ └── MeAjudaAi.ApiService/ # Ponto de entrada da API │ ├── Modules/ # Módulos de domínio -│ │ └── Users/ # Módulo de usuários +│ │ ├── Users/ # Módulo de usuários +│ │ │ ├── API/ # Endpoints e controllers +│ │ │ ├── Application/ # Use cases e handlers CQRS +│ │ │ ├── Domain/ # Entidades, value objects, eventos +│ │ │ ├── Infrastructure/ # Persistência e serviços externos +│ │ │ └── Tests/ # Testes do módulo +│ │ └── Providers/ # Módulo de prestadores │ │ ├── API/ # Endpoints e controllers │ │ ├── Application/ # Use cases e handlers CQRS │ │ ├── Domain/ # Entidades, value objects, eventos -│ │ ├── Infrastructure/ # Persistência e serviços externos -│ │ └── Tests/ # Testes do módulo +│ │ ├── Infrastructure/ # Persistência e event handlers +│ │ └── Tests/ # Testes unitários e integração │ └── Shared/ # Componentes compartilhados │ └── MeAjudaAi.Shared/ # Abstrações e utilities ├── tests/ # Testes de integração @@ -193,6 +199,12 @@ MeAjudaAi/ - **Features**: Registro, login, perfis, papéis (cliente, prestador, admin) - **Integração**: Keycloak para autenticação OAuth2/OIDC +### 🏢 Módulo Providers +- **Domain**: Gestão de prestadores de serviços e verificação +- **Features**: Cadastro, perfis empresariais, documentos, qualificações, status de verificação +- **Eventos**: Sistema completo de eventos de domínio e integração para comunicação inter-modular +- **Arquitetura**: Clean Architecture com CQRS, DDD e event-driven design + ### 🔮 Módulos Futuros - **Services**: Catálogo de serviços e categorias - **Bookings**: Agendamentos e reservas @@ -251,6 +263,21 @@ dotnet test src/Modules/Users/Tests/ - **Value Objects**: Para conceitos de domínio imutáveis - **Aggregates**: Para consistência transacional +#### Implementação de Eventos - Módulo Providers + +O módulo Providers implementa um sistema completo de eventos para comunicação inter-modular: + +**Domain Events:** +- `ProviderRegisteredDomainEvent` - Novo prestador cadastrado +- `ProviderDeletedDomainEvent` - Prestador removido do sistema +- `ProviderVerificationStatusUpdatedDomainEvent` - Status de verificação alterado +- `ProviderProfileUpdatedDomainEvent` - Perfil do prestador atualizado + +**Integration Events:** +- Conversão automática via Domain Event Handlers +- Publicação em message bus para outros módulos +- Suporte completo a event sourcing e auditoria + ### Estrutura de Commits ```bash diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs index 358767775..35aeb1dd3 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/RateLimitingMiddleware.cs @@ -30,11 +30,18 @@ private sealed class Counter } public async Task InvokeAsync(HttpContext context) { + var currentOptions = options.CurrentValue; + + // Bypass rate limiting if explicitly disabled + if (!currentOptions.General.Enabled) + { + await next(context); + return; + } + var clientIp = GetClientIpAddress(context); var isAuthenticated = context.User.Identity?.IsAuthenticated == true; - var currentOptions = options.CurrentValue; - // Check IP whitelist first - bypass rate limiting if IP is whitelisted if (currentOptions.General.EnableIpWhitelist && currentOptions.General.WhitelistedIps.Contains(clientIp)) diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs index 041c64834..f5552a51a 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Options/RateLimitOptions.cs @@ -67,6 +67,7 @@ public class RoleLimits public class GeneralSettings { + public bool Enabled { get; set; } = true; [Range(1, 86400)] public int WindowInSeconds { get; set; } = 60; public bool EnableIpWhitelist { get; set; } = false; public List WhitelistedIps { get; set; } = []; diff --git a/src/Modules/Providers/API/Extensions.cs b/src/Modules/Providers/API/Extensions.cs index 154193579..d6db04f33 100644 --- a/src/Modules/Providers/API/Extensions.cs +++ b/src/Modules/Providers/API/Extensions.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace MeAjudaAi.Modules.Providers.API; @@ -54,6 +55,13 @@ private static void EnsureDatabaseMigrations(WebApplication app) var context = scope.ServiceProvider.GetService(); if (context == null) return; + // Em ambiente de teste E2E, pular migrações automáticas - elas são gerenciadas pelo TestContainer + if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) + { + return; + } + + // Em produção, usar migrações normais context.Database.Migrate(); } catch (Exception ex) diff --git a/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs index 2be818a30..5317052a0 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class AddDocumentCommandHandler( +public sealed class AddDocumentCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/AddQualificationCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/AddQualificationCommandHandler.cs index a3353bc3b..6550e4a60 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/AddQualificationCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/AddQualificationCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class AddQualificationCommandHandler( +public sealed class AddQualificationCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/CreateProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/CreateProviderCommandHandler.cs index 53e1ce6c6..b38b70af2 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/CreateProviderCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/CreateProviderCommandHandler.cs @@ -18,7 +18,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para persistência de prestadores de serviços /// Logger estruturado para auditoria e debugging -internal sealed class CreateProviderCommandHandler( +public sealed class CreateProviderCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/DeleteProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/DeleteProviderCommandHandler.cs index 0797792d3..1c5455ee0 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/DeleteProviderCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/DeleteProviderCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// Repositório para acesso aos dados /// Provedor de data/hora para auditoria /// Logger estruturado -internal sealed class DeleteProviderCommandHandler( +public sealed class DeleteProviderCommandHandler( IProviderRepository providerRepository, IDateTimeProvider dateTimeProvider, ILogger logger diff --git a/src/Modules/Providers/Application/Handlers/Commands/RemoveDocumentCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RemoveDocumentCommandHandler.cs index fffc0c74d..be53aae62 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/RemoveDocumentCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/RemoveDocumentCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class RemoveDocumentCommandHandler( +public sealed class RemoveDocumentCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/RemoveQualificationCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RemoveQualificationCommandHandler.cs index 18483ad45..9db55d90b 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/RemoveQualificationCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/RemoveQualificationCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class RemoveQualificationCommandHandler( +public sealed class RemoveQualificationCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs index 725241fae..57bf9e0ec 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs @@ -12,7 +12,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Handler responsável por processar comandos de atualização de perfil do prestador de serviços. /// -internal sealed class UpdateProviderProfileCommandHandler( +public sealed class UpdateProviderProfileCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Commands/UpdateVerificationStatusCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/UpdateVerificationStatusCommandHandler.cs index 11dd7707d..fa1581b16 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/UpdateVerificationStatusCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/UpdateVerificationStatusCommandHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class UpdateVerificationStatusCommandHandler( +public sealed class UpdateVerificationStatusCommandHandler( IProviderRepository providerRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByDocumentQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByDocumentQueryHandler.cs index 33ea019a9..f2241bf73 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByDocumentQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByDocumentQueryHandler.cs @@ -15,7 +15,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// Implementa a lógica de negócio para buscar prestadores utilizando número de documento, /// integrando com o repositório de dados e aplicando as regras de mapeamento necessárias. /// -internal sealed class GetProviderByDocumentQueryHandler( +public sealed class GetProviderByDocumentQueryHandler( IProviderRepository providerRepository, ILogger logger) : IQueryHandler> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs index 2031d9a7b..a57fc344d 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class GetProviderByIdQueryHandler( +public sealed class GetProviderByIdQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByUserIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByUserIdQueryHandler.cs index de219d7e3..506364f5c 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByUserIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByUserIdQueryHandler.cs @@ -13,7 +13,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class GetProviderByUserIdQueryHandler( +public sealed class GetProviderByUserIdQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByCityQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByCityQueryHandler.cs index 990a701a9..2bf99da7e 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByCityQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByCityQueryHandler.cs @@ -13,7 +13,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// /// Repositório para acesso aos dados /// Logger estruturado -internal sealed class GetProvidersByCityQueryHandler( +public sealed class GetProvidersByCityQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler>> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByIdsQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByIdsQueryHandler.cs index 4253da956..8acace723 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByIdsQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByIdsQueryHandler.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -internal sealed class GetProvidersByIdsQueryHandler( +public sealed class GetProvidersByIdsQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler>> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByStateQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByStateQueryHandler.cs index 90a3660b5..16638e22d 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByStateQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByStateQueryHandler.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -internal sealed class GetProvidersByStateQueryHandler( +public sealed class GetProvidersByStateQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler>> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByTypeQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByTypeQueryHandler.cs index 306bb45b6..8da91e016 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByTypeQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByTypeQueryHandler.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -internal sealed class GetProvidersByTypeQueryHandler( +public sealed class GetProvidersByTypeQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler>> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByVerificationStatusQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByVerificationStatusQueryHandler.cs index bd540452b..afbacfabd 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByVerificationStatusQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersByVerificationStatusQueryHandler.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -internal sealed class GetProvidersByVerificationStatusQueryHandler( +public sealed class GetProvidersByVerificationStatusQueryHandler( IProviderRepository providerRepository, ILogger logger ) : IQueryHandler>> diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersQueryHandler.cs index 5372084ac..71892aa62 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProvidersQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProvidersQueryHandler.cs @@ -71,8 +71,7 @@ public async Task>> HandleAsync( "Erro ao buscar prestadores - Página: {Page}, Filtros: Nome='{Name}', Tipo={Type}, Status={Status}", query.Page, query.Name, query.Type, query.VerificationStatus); - return Result>.Failure(Error.Internal( - "Erro interno ao buscar prestadores")); + return Result>.Failure(Error.Internal("Erro interno ao buscar prestadores")); } } } diff --git a/src/Modules/Providers/Application/MeAjudaAi.Modules.Providers.Application.csproj b/src/Modules/Providers/Application/MeAjudaAi.Modules.Providers.Application.csproj index 91920d2c4..2efeb1e47 100644 --- a/src/Modules/Providers/Application/MeAjudaAi.Modules.Providers.Application.csproj +++ b/src/Modules/Providers/Application/MeAjudaAi.Modules.Providers.Application.csproj @@ -10,9 +10,6 @@ <_Parameter1>MeAjudaAi.Modules.Providers.Tests - - <_Parameter1>DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7 - diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 5407cb15f..9ab0f62d5 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -80,9 +80,9 @@ public sealed class Provider : AggregateRoot private Provider() { } /// - /// Construtor interno para testes que permite especificar o ID. + /// Construtor para testes que permite especificar o ID. /// - internal Provider( + public Provider( ProviderId id, Guid userId, string name, @@ -158,7 +158,26 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? if (IsDeleted) throw new ProviderDomainException("Cannot update deleted provider"); - Name = name.Trim(); + // Track which fields are being updated + var updatedFields = new List(); + + var newName = name.Trim(); + if (Name != newName) + updatedFields.Add("Name"); + + if (!BusinessProfile.ContactInfo.Email.Equals(businessProfile.ContactInfo.Email, StringComparison.OrdinalIgnoreCase)) + updatedFields.Add("Email"); + + if (BusinessProfile.LegalName != businessProfile.LegalName) + updatedFields.Add("LegalName"); + + if (BusinessProfile.FantasyName != businessProfile.FantasyName) + updatedFields.Add("FantasyName"); + + if (BusinessProfile.Description != businessProfile.Description) + updatedFields.Add("Description"); + + Name = newName; BusinessProfile = businessProfile; MarkAsUpdated(); @@ -167,7 +186,8 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? 1, Name, BusinessProfile.ContactInfo.Email, - updatedBy)); + updatedBy, + updatedFields.ToArray())); } /// diff --git a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs index e32f863e7..52c636930 100644 --- a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs +++ b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs @@ -10,10 +10,12 @@ namespace MeAjudaAi.Modules.Providers.Domain.Events; /// Novo nome do prestador de serviços /// Novo email de contato /// Quem fez a atualização +/// Lista dos campos que foram atualizados public record ProviderProfileUpdatedDomainEvent( Guid AggregateId, int Version, string Name, string Email, - string? UpdatedBy + string? UpdatedBy, + string[] UpdatedFields ) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderDeletedDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderDeletedDomainEventHandler.cs new file mode 100644 index 000000000..a4b6892c1 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderDeletedDomainEventHandler.cs @@ -0,0 +1,54 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderDeletedDomainEvent e publica eventos de integração. +/// +public sealed class ProviderDeletedDomainEventHandler( + IMessageBus messageBus, + ProvidersDbContext context, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de prestador excluído de forma assíncrona. + /// + public async Task HandleAsync(ProviderDeletedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderDeletedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Buscar apenas o UserId do provider, mesmo se soft-deleted ou removido + var userId = await context.Providers + .AsNoTracking() + .IgnoreQueryFilters() + .Where(p => p.Id.Value == domainEvent.AggregateId) + .Select(p => (Guid?)p.UserId) + .FirstOrDefaultAsync(cancellationToken); + + if (userId.HasValue) + { + var integrationEvent = domainEvent.ToIntegrationEvent(userId.Value); + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderDeleted integration event for provider {ProviderId}", domainEvent.AggregateId); + } + else + { + logger.LogWarning("Provider {ProviderId} not found when handling ProviderDeletedDomainEvent", domainEvent.AggregateId); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderDeletedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandler.cs new file mode 100644 index 000000000..95833ad33 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandler.cs @@ -0,0 +1,54 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderProfileUpdatedDomainEvent e publica eventos de integração. +/// +public sealed class ProviderProfileUpdatedDomainEventHandler( + IMessageBus messageBus, + ProvidersDbContext context, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de perfil de prestador atualizado de forma assíncrona. + /// + public async Task HandleAsync(ProviderProfileUpdatedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderProfileUpdatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Busca apenas o UserId do prestador + var userId = await context.Providers + .AsNoTracking() + .Where(p => p.Id == new Domain.ValueObjects.ProviderId(domainEvent.AggregateId)) + .Select(p => (Guid?)p.UserId) + .FirstOrDefaultAsync(cancellationToken); + + if (!userId.HasValue) + { + logger.LogWarning("Provider {ProviderId} not found when handling ProviderProfileUpdatedDomainEvent", domainEvent.AggregateId); + return; + } + + // Cria evento de integração para sistemas externos usando mapper + var integrationEvent = domainEvent.ToIntegrationEvent(userId.Value, domainEvent.UpdatedFields); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderProfileUpdated integration event for provider {ProviderId}", domainEvent.AggregateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderProfileUpdatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandler.cs new file mode 100644 index 000000000..7a0554302 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandler.cs @@ -0,0 +1,60 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderRegisteredDomainEvent e publica eventos de integração. +/// +/// +/// Responsável por converter eventos de domínio em eventos de integração para comunicação +/// entre módulos. Quando um prestador é registrado no domínio, este handler busca os dados +/// atualizados e publica um evento de integração para notificar outros sistemas. +/// +public sealed class ProviderRegisteredDomainEventHandler( + IMessageBus messageBus, + ProvidersDbContext context, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de prestador registrado de forma assíncrona. + /// + /// Evento de domínio contendo dados do prestador registrado + /// Token de cancelamento + /// Task representando a operação assíncrona + public async Task HandleAsync(ProviderRegisteredDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderRegisteredDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Busca o prestador para garantir que temos os dados mais recentes + var provider = await context.Providers + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == new Domain.ValueObjects.ProviderId(domainEvent.AggregateId), cancellationToken); + + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found when handling ProviderRegisteredDomainEvent", domainEvent.AggregateId); + return; + } + + // Cria evento de integração para sistemas externos usando mapper + var integrationEvent = domainEvent.ToIntegrationEvent(); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderRegistered integration event for provider {ProviderId}", domainEvent.AggregateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderRegisteredDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderVerificationStatusUpdatedDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderVerificationStatusUpdatedDomainEventHandler.cs new file mode 100644 index 000000000..c076113d0 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderVerificationStatusUpdatedDomainEventHandler.cs @@ -0,0 +1,51 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderVerificationStatusUpdatedDomainEvent e publica eventos de integração. +/// +public sealed class ProviderVerificationStatusUpdatedDomainEventHandler( + IMessageBus messageBus, + ProvidersDbContext context, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de status de verificação atualizado de forma assíncrona. + /// + public async Task HandleAsync(ProviderVerificationStatusUpdatedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderVerificationStatusUpdatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Busca o prestador para garantir que temos os dados mais recentes + var provider = await context.Providers + .FirstOrDefaultAsync(p => p.Id == new Domain.ValueObjects.ProviderId(domainEvent.AggregateId), cancellationToken); + + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found when handling ProviderVerificationStatusUpdatedDomainEvent", domainEvent.AggregateId); + return; + } + + // Cria evento de integração para sistemas externos usando mapper + var integrationEvent = domainEvent.ToIntegrationEvent(provider.UserId, provider.Name); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderVerificationStatusUpdated integration event for provider {ProviderId}", domainEvent.AggregateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderVerificationStatusUpdatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs new file mode 100644 index 000000000..72e29c2be --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs @@ -0,0 +1,76 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Shared.Messaging.Messages.Providers; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; + +/// +/// Mappers para converter Domain Events em Integration Events do módulo Providers. +/// +public static class ProviderEventMappers +{ + private const string ModuleName = "Providers"; + + /// + /// Converte ProviderRegisteredDomainEvent para ProviderRegisteredIntegrationEvent. + /// + public static ProviderRegisteredIntegrationEvent ToIntegrationEvent(this ProviderRegisteredDomainEvent domainEvent) + { + return new ProviderRegisteredIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: domainEvent.UserId, + Name: domainEvent.Name, + ProviderType: domainEvent.Type.ToString(), + Email: domainEvent.Email, + RegisteredAt: DateTime.UtcNow + ); + } + + /// + /// Converte ProviderDeletedDomainEvent para ProviderDeletedIntegrationEvent. + /// + public static ProviderDeletedIntegrationEvent ToIntegrationEvent(this ProviderDeletedDomainEvent domainEvent, Guid userId) + { + return new ProviderDeletedIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: userId, + Name: domainEvent.Name, + Reason: "Provider deleted", + DeletedAt: DateTime.UtcNow, + DeletedBy: domainEvent.DeletedBy + ); + } + + /// + /// Converte ProviderVerificationStatusUpdatedDomainEvent para ProviderVerificationStatusUpdatedIntegrationEvent. + /// + public static ProviderVerificationStatusUpdatedIntegrationEvent ToIntegrationEvent(this ProviderVerificationStatusUpdatedDomainEvent domainEvent, Guid userId, string providerName) + { + return new ProviderVerificationStatusUpdatedIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: userId, + Name: providerName, + PreviousStatus: domainEvent.PreviousStatus.ToString(), + NewStatus: domainEvent.NewStatus.ToString(), + UpdatedBy: domainEvent.UpdatedBy + ); + } + + /// + /// Converte ProviderProfileUpdatedDomainEvent para ProviderProfileUpdatedIntegrationEvent. + /// + public static ProviderProfileUpdatedIntegrationEvent ToIntegrationEvent(this ProviderProfileUpdatedDomainEvent domainEvent, Guid userId, string[] updatedFields) + { + return new ProviderProfileUpdatedIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: userId, + Name: domainEvent.Name, + UpdatedFields: updatedFields, + UpdatedBy: domainEvent.UpdatedBy, + NewEmail: domainEvent.Email + ); + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Extensions.cs b/src/Modules/Providers/Infrastructure/Extensions.cs index 5e0b965b0..98d11c90a 100644 --- a/src/Modules/Providers/Infrastructure/Extensions.cs +++ b/src/Modules/Providers/Infrastructure/Extensions.cs @@ -1,8 +1,11 @@ using MeAjudaAi.Modules.Providers.Application.Services.Interfaces; +using MeAjudaAi.Modules.Providers.Domain.Events; using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Repositories; using MeAjudaAi.Modules.Providers.Infrastructure.Queries; +using MeAjudaAi.Shared.Events; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -78,6 +81,23 @@ public static IServiceCollection AddInfrastructure( // Registro do serviço de consultas services.AddScoped(); + // Registro dos Event Handlers + services.AddEventHandlers(); + + return services; + } + + /// + /// Adiciona os Event Handlers do módulo Providers. + /// + private static IServiceCollection AddEventHandlers(this IServiceCollection services) + { + // Event Handlers específicos do módulo Providers + services.AddScoped, ProviderRegisteredDomainEventHandler>(); + services.AddScoped, ProviderDeletedDomainEventHandler>(); + services.AddScoped, ProviderVerificationStatusUpdatedDomainEventHandler>(); + services.AddScoped, ProviderProfileUpdatedDomainEventHandler>(); + return services; } } diff --git a/src/Modules/Providers/Infrastructure/MeAjudaAi.Modules.Providers.Infrastructure.csproj b/src/Modules/Providers/Infrastructure/MeAjudaAi.Modules.Providers.Infrastructure.csproj index 9cfde0545..06c4adede 100644 --- a/src/Modules/Providers/Infrastructure/MeAjudaAi.Modules.Providers.Infrastructure.csproj +++ b/src/Modules/Providers/Infrastructure/MeAjudaAi.Modules.Providers.Infrastructure.csproj @@ -6,6 +6,12 @@ enable + + + <_Parameter1>MeAjudaAi.Modules.Providers.Tests + + + diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index c13149790..7302dd38c 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -49,6 +49,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(p => p.DeletedAt) .HasColumnName("deleted_at"); + // Configuração das propriedades de auditoria da BaseEntity + builder.Property(p => p.CreatedAt) + .IsRequired() + .HasColumnName("created_at"); + + builder.Property(p => p.UpdatedAt) + .HasColumnName("updated_at"); + // Configuração do BusinessProfile como owned builder builder.OwnsOne(p => p.BusinessProfile, bp => { @@ -150,6 +158,8 @@ public void Configure(EntityTypeBuilder builder) doc.HasKey("ProviderId", "Id"); doc.ToTable("document", "providers"); doc.WithOwner().HasForeignKey("ProviderId"); + doc.Property("ProviderId").HasColumnName("provider_id"); + doc.Property("Id").HasColumnName("id"); doc.HasIndex("ProviderId", "DocumentType").IsUnique(); }); @@ -182,6 +192,8 @@ public void Configure(EntityTypeBuilder builder) qual.HasKey("ProviderId", "Id"); qual.ToTable("qualification", "providers"); qual.WithOwner().HasForeignKey("ProviderId"); + qual.Property("ProviderId").HasColumnName("provider_id"); + qual.Property("Id").HasColumnName("id"); }); // Índices diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.Designer.cs new file mode 100644 index 000000000..b300d597f --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.Designer.cs @@ -0,0 +1,322 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20251111020609_FixColumnNamingToSnakeCase")] + partial class FixColumnNamingToSnakeCase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique(); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.cs new file mode 100644 index 000000000..9d0cf544a --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251111020609_FixColumnNamingToSnakeCase.cs @@ -0,0 +1,162 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class FixColumnNamingToSnakeCase : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_document_providers_ProviderId", + schema: "providers", + table: "document"); + + migrationBuilder.DropForeignKey( + name: "FK_qualification_providers_ProviderId", + schema: "providers", + table: "qualification"); + + migrationBuilder.RenameColumn( + name: "Id", + schema: "providers", + table: "qualification", + newName: "id"); + + migrationBuilder.RenameColumn( + name: "ProviderId", + schema: "providers", + table: "qualification", + newName: "provider_id"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + schema: "providers", + table: "providers", + newName: "updated_at"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + schema: "providers", + table: "providers", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "Id", + schema: "providers", + table: "document", + newName: "id"); + + migrationBuilder.RenameColumn( + name: "ProviderId", + schema: "providers", + table: "document", + newName: "provider_id"); + + migrationBuilder.RenameIndex( + name: "IX_document_ProviderId_document_type", + schema: "providers", + table: "document", + newName: "IX_document_provider_id_document_type"); + + migrationBuilder.AddForeignKey( + name: "FK_document_providers_provider_id", + schema: "providers", + table: "document", + column: "provider_id", + principalSchema: "providers", + principalTable: "providers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_qualification_providers_provider_id", + schema: "providers", + table: "qualification", + column: "provider_id", + principalSchema: "providers", + principalTable: "providers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_document_providers_provider_id", + schema: "providers", + table: "document"); + + migrationBuilder.DropForeignKey( + name: "FK_qualification_providers_provider_id", + schema: "providers", + table: "qualification"); + + migrationBuilder.RenameColumn( + name: "id", + schema: "providers", + table: "qualification", + newName: "Id"); + + migrationBuilder.RenameColumn( + name: "provider_id", + schema: "providers", + table: "qualification", + newName: "ProviderId"); + + migrationBuilder.RenameColumn( + name: "updated_at", + schema: "providers", + table: "providers", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "created_at", + schema: "providers", + table: "providers", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "id", + schema: "providers", + table: "document", + newName: "Id"); + + migrationBuilder.RenameColumn( + name: "provider_id", + schema: "providers", + table: "document", + newName: "ProviderId"); + + migrationBuilder.RenameIndex( + name: "IX_document_provider_id_document_type", + schema: "providers", + table: "document", + newName: "IX_document_ProviderId_document_type"); + + migrationBuilder.AddForeignKey( + name: "FK_document_providers_ProviderId", + schema: "providers", + table: "document", + column: "ProviderId", + principalSchema: "providers", + principalTable: "providers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_qualification_providers_ProviderId", + schema: "providers", + table: "qualification", + column: "ProviderId", + principalSchema: "providers", + principalTable: "providers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs index abea3494f..fbd9eb083 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs @@ -30,7 +30,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("id"); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); b.Property("DeletedAt") .HasColumnType("timestamp with time zone") @@ -53,7 +54,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("type"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); b.Property("UserId") .HasColumnType("uuid") @@ -215,11 +217,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => { b1.Property("ProviderId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("provider_id"); b1.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .HasColumnType("integer") + .HasColumnName("id"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); @@ -255,11 +259,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => { b1.Property("ProviderId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("provider_id"); b1.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .HasColumnType("integer") + .HasColumnName("id"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); diff --git a/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContext.cs b/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContext.cs index 8d992fb60..fd2781577 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContext.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContext.cs @@ -11,12 +11,15 @@ namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence; /// Implementa o padrão DbContext do Entity Framework Core para persistência /// das entidades do domínio de prestadores de serviços. /// -/// -/// Inicializa uma nova instância do contexto. -/// -/// Opções de configuração do contexto -public class ProvidersDbContext(DbContextOptions options) : DbContext(options) +public class ProvidersDbContext : DbContext { + /// + /// Inicializa uma nova instância do contexto. + /// + /// Opções de configuração do contexto + public ProvidersDbContext(DbContextOptions options) : base(options) + { + } /// /// Conjunto de dados para prestadores de serviços. /// diff --git a/src/Modules/Providers/Infrastructure/Queries/ProviderQueryService.cs b/src/Modules/Providers/Infrastructure/Queries/ProviderQueryService.cs index ed44c3521..146f3739e 100644 --- a/src/Modules/Providers/Infrastructure/Queries/ProviderQueryService.cs +++ b/src/Modules/Providers/Infrastructure/Queries/ProviderQueryService.cs @@ -14,8 +14,14 @@ namespace MeAjudaAi.Modules.Providers.Infrastructure.Queries; /// Implementa consultas complexas e paginadas específicas da infraestrutura /// que não fazem parte do domínio principal. /// -public sealed class ProviderQueryService(ProvidersDbContext context) : IProviderQueryService +public sealed class ProviderQueryService : IProviderQueryService { + private readonly ProvidersDbContext _context; + + public ProviderQueryService(ProvidersDbContext context) + { + _context = context; + } /// /// Busca prestadores de serviços com paginação e filtros opcionais. @@ -35,7 +41,7 @@ public async Task> GetProvidersAsync( if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "PageSize must be greater than 0"); - var query = context.Providers + var query = _context.Providers .Include(p => p.Documents) .Include(p => p.Qualifications) .Where(p => !p.IsDeleted); @@ -72,8 +78,8 @@ public async Task> GetProvidersAsync( return new PagedResult( providers, - totalCount, page, - pageSize); + pageSize, + totalCount); } } diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index fe76dbe79..6edd1bb20 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -26,9 +26,9 @@ public ProviderBuilder() { Provider provider; + // Se um ID específico foi fornecido, usa o construtor interno para testes if (_providerId != null) { - // Usa o construtor interno para testes quando um ID específico é fornecido provider = new Provider( _providerId, _userId ?? f.Random.Guid(), @@ -39,7 +39,7 @@ public ProviderBuilder() } else { - // Usa o construtor público normal + // Usa o construtor público que gera um novo ID provider = new Provider( _userId ?? f.Random.Guid(), _name ?? f.Company.CompanyName(), diff --git a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs index 09cc1a892..df553b6a5 100644 --- a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs +++ b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs @@ -39,12 +39,7 @@ public static IServiceCollection AddProvidersTestInfrastructure( // Para testes, usar implementação simples sem dependências complexas services.AddSingleton(); - // Configurar banco de dados específico do módulo Providers - services.AddTestDatabase( - options.Database, - "MeAjudaAi.Modules.Providers.Infrastructure"); - - // Configurar DbContext específico + // Configurar DbContext específico para PostgreSQL com TestContainers (isolado por teste) services.AddDbContext((serviceProvider, dbOptions) => { var container = serviceProvider.GetRequiredService(); @@ -61,7 +56,7 @@ public static IServiceCollection AddProvidersTestInfrastructure( // Suprimir warnings de pending model changes em testes warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning); }); - }); + }, ServiceLifetime.Scoped); // Garantir que seja Scoped // Adicionar repositórios específicos do Providers services.AddScoped(); diff --git a/src/Modules/Providers/Tests/Infrastructure/TestProviderQueryService.cs b/src/Modules/Providers/Tests/Infrastructure/TestProviderQueryService.cs new file mode 100644 index 000000000..ea200bf5b --- /dev/null +++ b/src/Modules/Providers/Tests/Infrastructure/TestProviderQueryService.cs @@ -0,0 +1,82 @@ +using MeAjudaAi.Modules.Providers.Application.Services.Interfaces; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using MeAjudaAi.Shared.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Providers.Tests.Infrastructure; + +/// +/// Versão de teste do ProviderQueryService usando construtor tradicional +/// +public sealed class TestProviderQueryService : IProviderQueryService +{ + private readonly ProvidersDbContext _context; + + public TestProviderQueryService(ProvidersDbContext context) + { + _context = context; + } + + /// + /// Busca prestadores de serviços com paginação e filtros opcionais. + /// + public async Task> GetProvidersAsync( + int page = 1, + int pageSize = 20, + string? nameFilter = null, + EProviderType? typeFilter = null, + EVerificationStatus? verificationStatusFilter = null, + CancellationToken cancellationToken = default) + { + // Valida parâmetros de paginação + if (page < 1) + throw new ArgumentOutOfRangeException(nameof(page), "Page must be greater than 0"); + + if (pageSize < 1) + throw new ArgumentOutOfRangeException(nameof(pageSize), "PageSize must be greater than 0"); + + var query = _context.Providers + .AsNoTracking() + .Include(p => p.Documents) + .Include(p => p.Qualifications) + .Where(p => !p.IsDeleted); + + // Aplica filtro por nome (busca parcial, case-insensitive) + if (!string.IsNullOrWhiteSpace(nameFilter)) + { + query = query.Where(p => EF.Functions.ILike(p.Name, $"%{nameFilter}%")); + } + + // Aplica filtro por tipo + if (typeFilter.HasValue) + { + query = query.Where(p => p.Type == typeFilter.Value); + } + + // Aplica filtro por status de verificação + if (verificationStatusFilter.HasValue) + { + query = query.Where(p => p.VerificationStatus == verificationStatusFilter.Value); + } + + // Ordena por data de criação (mais recentes primeiro) com ID como tiebreaker para determinismo + query = query.OrderByDescending(p => p.CreatedAt).ThenByDescending(p => p.Id); + + // Conta total de registros + var totalCount = await query.CountAsync(cancellationToken); + + // Aplica paginação + var providers = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResult( + providers, + totalCount, + page, + pageSize); + } +} \ No newline at end of file diff --git a/src/Modules/Providers/Tests/Integration/ProviderQueryServiceIntegrationTests.cs b/src/Modules/Providers/Tests/Integration/ProviderQueryServiceIntegrationTests.cs index 8494fee41..71b8417ab 100644 --- a/src/Modules/Providers/Tests/Integration/ProviderQueryServiceIntegrationTests.cs +++ b/src/Modules/Providers/Tests/Integration/ProviderQueryServiceIntegrationTests.cs @@ -107,11 +107,25 @@ await CreateProviderAsync( public async Task GetProvidersAsync_EmptyDatabase_ShouldReturnEmptyResult() { // Arrange - await CleanupDatabase(); // Garantir isolamento + await ForceCleanDatabase(); + var queryService = GetService(); - - // Act - var result = await queryService.GetProvidersAsync(page: 1, pageSize: 10); + + // Act - teste com banco vazio usando container-backed services + var resultWithoutFilter = await queryService.GetProvidersAsync(page: 1, pageSize: 10); + + // Assert - o banco deve estar vazio + resultWithoutFilter.Should().NotBeNull(); + resultWithoutFilter.Items.Should().BeEmpty(); + resultWithoutFilter.TotalCount.Should().Be(0); + resultWithoutFilter.TotalPages.Should().Be(0); + + // Act - Agora com filtro único que não existe + var uniqueFilter = $"VERY_UNIQUE_TEST_FILTER__{Guid.NewGuid():N}"; + var result = await queryService.GetProvidersAsync( + page: 1, + pageSize: 10, + nameFilter: uniqueFilter); // Assert result.Should().NotBeNull(); @@ -120,9 +134,4 @@ public async Task GetProvidersAsync_EmptyDatabase_ShouldReturnEmptyResult() result.TotalPages.Should().Be(0); } - protected override async Task OnDisposeAsync() - { - await CleanupDatabase(); - await base.OnDisposeAsync(); - } } diff --git a/src/Modules/Providers/Tests/Integration/ProviderRepositoryIntegrationTests.cs b/src/Modules/Providers/Tests/Integration/ProviderRepositoryIntegrationTests.cs index 35f5a2f39..045eb66aa 100644 --- a/src/Modules/Providers/Tests/Integration/ProviderRepositoryIntegrationTests.cs +++ b/src/Modules/Providers/Tests/Integration/ProviderRepositoryIntegrationTests.cs @@ -115,8 +115,19 @@ public async Task UpdateAsync_WithValidChanges_ShouldPersistChanges() // Act var updatedBusinessProfile = new BusinessProfile( "New Name Legal", - provider.BusinessProfile.ContactInfo, - provider.BusinessProfile.PrimaryAddress, + new ContactInfo( + provider.BusinessProfile.ContactInfo.Email, + provider.BusinessProfile.ContactInfo.PhoneNumber, + provider.BusinessProfile.ContactInfo.Website), + new Address( + provider.BusinessProfile.PrimaryAddress.Street, + provider.BusinessProfile.PrimaryAddress.Number, + provider.BusinessProfile.PrimaryAddress.Neighborhood, + provider.BusinessProfile.PrimaryAddress.City, + provider.BusinessProfile.PrimaryAddress.State, + provider.BusinessProfile.PrimaryAddress.ZipCode, + provider.BusinessProfile.PrimaryAddress.Country, + provider.BusinessProfile.PrimaryAddress.Complement), description: "Updated description"); provider.UpdateProfile("New Name", updatedBusinessProfile); await repository.UpdateAsync(provider); diff --git a/src/Modules/Providers/Tests/Integration/ProvidersIntegrationTestBase.cs b/src/Modules/Providers/Tests/Integration/ProvidersIntegrationTestBase.cs index 9e890d9c8..1d20cbcb3 100644 --- a/src/Modules/Providers/Tests/Integration/ProvidersIntegrationTestBase.cs +++ b/src/Modules/Providers/Tests/Integration/ProvidersIntegrationTestBase.cs @@ -4,33 +4,48 @@ using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; using MeAjudaAi.Modules.Providers.Tests.Infrastructure; using MeAjudaAi.Shared.Tests.Infrastructure; +using MeAjudaAi.Shared.Tests.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Testcontainers.PostgreSql; namespace MeAjudaAi.Modules.Providers.Tests.Integration; /// /// Classe base para testes de integração específicos do módulo Providers. +/// Usa isolamento completo com database único por classe de teste. /// -public abstract class ProvidersIntegrationTestBase : IntegrationTestBase +public abstract class ProvidersIntegrationTestBase : IAsyncLifetime { + private PostgreSqlContainer? _container; + private ServiceProvider? _serviceProvider; + private readonly string _testClassId; + + protected ProvidersIntegrationTestBase() + { + _testClassId = $"{GetType().Name}_{Guid.NewGuid():N}"; + } + /// /// Configurações padrão para testes do módulo Providers /// - protected override TestInfrastructureOptions GetTestOptions() + protected TestInfrastructureOptions GetTestOptions() { return new TestInfrastructureOptions { Database = new TestDatabaseOptions { - DatabaseName = $"MeAjudaAi", // Usar banco de desenvolvimento - Username = "postgres", - Password = "development123", - Schema = "providers" + DatabaseName = $"providers_test_{_testClassId}", + Username = "test_user", + Password = "test_password", + Schema = "providers", + UseInMemoryDatabase = false }, Cache = new TestCacheOptions { - Enabled = false // Não usa cache por padrão + Enabled = false }, ExternalServices = new TestExternalServicesOptions { @@ -40,22 +55,69 @@ protected override TestInfrastructureOptions GetTestOptions() }; } + /// + /// Inicialização executada antes de cada classe de teste + /// + public async ValueTask InitializeAsync() + { + // Criar container PostgreSQL isolado para esta classe de teste + var options = GetTestOptions(); + + _container = new PostgreSqlBuilder() + .WithImage(options.Database.PostgresImage) + .WithDatabase(options.Database.DatabaseName) + .WithUsername(options.Database.Username) + .WithPassword(options.Database.Password) + .WithPortBinding(0, true) // Porta aleatória + .Build(); + + await _container.StartAsync(); + + // Configurar serviços com container isolado + var services = new ServiceCollection(); + + // Registrar o container específico + services.AddSingleton(_container); + + // Configurar logging otimizado + services.AddLogging(builder => + { + builder.ConfigureTestLogging(); + }); + + // Configurar serviços específicos do módulo + ConfigureModuleServices(services, options); + + _serviceProvider = services.BuildServiceProvider(); + + // Inicializar banco de dados + await InitializeDatabaseAsync(); + } + /// /// Configura serviços específicos do módulo Providers /// - protected override void ConfigureModuleServices(IServiceCollection services, TestInfrastructureOptions options) + private void ConfigureModuleServices(IServiceCollection services, TestInfrastructureOptions options) { services.AddProvidersTestInfrastructure(options); } - + /// - /// Setup específico do módulo Providers (configurações adicionais se necessário) + /// Inicializa o banco de dados isolado /// - protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider) + private async Task InitializeDatabaseAsync() { - // Qualquer setup específico adicional do módulo Providers pode ser feito aqui - // As migrações são aplicadas automaticamente pelo sistema de auto-descoberta - await Task.CompletedTask; + var dbContext = _serviceProvider!.GetRequiredService(); + + // Criar banco e esquema sem executar migrations + await dbContext.Database.EnsureCreatedAsync(); + + // Verificar isolamento + var count = await dbContext.Providers.CountAsync(); + if (count > 0) + { + throw new InvalidOperationException($"Database isolation failed for '{GetTestOptions().Database.DatabaseName}': found {count} existing providers in new database"); + } } /// @@ -92,6 +154,7 @@ protected static BusinessProfile CreateTestBusinessProfile(string email = "test@ /// /// Limpa dados das tabelas para isolamento entre testes + /// Usando banco isolado, cleanup é mais simples e confiável /// protected async Task CleanupDatabase() { @@ -99,49 +162,109 @@ protected async Task CleanupDatabase() try { - // Use EF Core change tracking for safer cleanup - // Only providers table is exposed as DbSet, child entities are accessed through navigation properties - var providers = await dbContext.Providers - .Include(p => p.Documents) - .Include(p => p.Qualifications) - .ToListAsync(); - - if (providers.Any()) - { - dbContext.Providers.RemoveRange(providers); - await dbContext.SaveChangesAsync(); - } + // Com banco isolado, podemos usar TRUNCATE com segurança + await dbContext.Database.ExecuteSqlRawAsync("TRUNCATE TABLE providers.providers CASCADE;"); } catch (Exception ex) { - Console.WriteLine($"EF Core cleanup failed: {ex.Message}. Trying raw SQL..."); + // Fallback para DELETE se TRUNCATE falhar + var logger = GetService>(); + logger.LogWarning(ex, "TRUNCATE failed: {Message}. Using DELETE fallback...", ex.Message); + + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.qualification;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.document;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.providers;"); + } + + // Verificar se limpeza foi bem-sucedida + var remainingCount = await dbContext.Providers.CountAsync(); + if (remainingCount > 0) + { + throw new InvalidOperationException($"Database cleanup failed: {remainingCount} providers remain"); + } + } - // Fallback to raw SQL if EF Core fails - try - { - // Use correct table names (lowercase without quotes for most cases) - await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.qualification;"); - await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.document;"); - await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.providers;"); - } - catch (Exception ex2) - { - Console.WriteLine($"Raw SQL cleanup failed: {ex2.Message}. Trying TRUNCATE..."); - - // Se DELETE falhar, tentar TRUNCATE com cascata - try - { - await dbContext.Database.ExecuteSqlRawAsync("TRUNCATE TABLE providers.qualification, providers.document, providers.providers RESTART IDENTITY CASCADE;"); - } - catch (Exception ex3) - { - Console.WriteLine($"TRUNCATE failed: {ex3.Message}. Recreating database..."); - - // Se ainda falhar, recriar o schema - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - } - } + /// + /// Força limpeza mais agressiva do banco de dados + /// Com isolamento completo, é mais simples e confiável + /// + protected async Task ForceCleanDatabase() + { + var dbContext = GetService(); + + try + { + // Estratégia 1: TRUNCATE CASCADE + await dbContext.Database.ExecuteSqlRawAsync("TRUNCATE TABLE providers.providers CASCADE;"); + return; + } + catch (Exception ex) + { + var logger = GetService>(); + logger.LogWarning(ex, "TRUNCATE failed: {Message}. Trying DELETE...", ex.Message); + } + + try + { + // Estratégia 2: DELETE em ordem reversa + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.qualification;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.document;"); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM providers.providers;"); + return; + } + catch (Exception ex) + { + var logger = GetService>(); + logger.LogError(ex, "DELETE failed: {Message}. Recreating database...", ex.Message); + } + + // Estratégia 3: Recriar database + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } + + /// + /// Limpeza executada após cada classe de teste + /// + public async ValueTask DisposeAsync() + { + if (_serviceProvider != null) + { + await _serviceProvider.DisposeAsync(); + } + + if (_container != null) + { + await _container.StopAsync(); + await _container.DisposeAsync(); } } + + /// + /// Obtém um serviço do provider isolado + /// + protected T GetService() where T : notnull + { + if (_serviceProvider == null) + throw new InvalidOperationException("Service provider not initialized"); + return _serviceProvider.GetRequiredService(); + } + + /// + /// Obtém um serviço de um escopo específico + /// + protected T GetScopedService(IServiceScope scope) where T : notnull + { + return scope.ServiceProvider.GetRequiredService(); + } + + /// + /// Cria um escopo de serviços para o teste + /// + protected IServiceScope CreateScope() + { + if (_serviceProvider == null) + throw new InvalidOperationException("Service provider not initialized"); + return _serviceProvider.CreateScope(); + } } diff --git a/src/Modules/Providers/Tests/Unit/Application/Queries/GetProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Queries/GetProviderByIdQueryHandlerTests.cs index edf5001c1..b91f21574 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Queries/GetProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Queries/GetProviderByIdQueryHandlerTests.cs @@ -128,13 +128,14 @@ private static Provider CreateValidProvider(ProviderId? providerId = null) contactInfo: contactInfo, primaryAddress: address); - // Se um ProviderId específico foi fornecido, usa o construtor interno para testes + // Se um ProviderId específico foi fornecido, usa o construtor que aceita ProviderId explícito + // e não emite eventos de domínio if (providerId != null) { return new Provider(providerId, userId, name, type, businessProfile); } - // Caso contrário, usa o construtor público normal + // Caso contrário, usa o construtor público normal que gera ProviderId automaticamente return new Provider(userId, name, type, businessProfile); } } diff --git a/src/Modules/Users/API/Extensions.cs b/src/Modules/Users/API/Extensions.cs index aa0afa6e3..b933730f2 100644 --- a/src/Modules/Users/API/Extensions.cs +++ b/src/Modules/Users/API/Extensions.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace MeAjudaAi.Modules.Users.API; @@ -57,6 +58,12 @@ private static void EnsureDatabaseMigrations(WebApplication app) // Só aplica migrações se não estivermos em ambiente de testes unitários if (app?.Services == null) return; + // Em ambiente de teste E2E, pular migrações automáticas - elas são gerenciadas pelo TestContainer + if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) + { + return; + } + try { // Criar um escopo para obter o context e aplicar migrações diff --git a/src/Modules/Users/Application/Handlers/Commands/ChangeUserEmailCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/ChangeUserEmailCommandHandler.cs index 170e14ae4..ae1ae5419 100644 --- a/src/Modules/Users/Application/Handlers/Commands/ChangeUserEmailCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/ChangeUserEmailCommandHandler.cs @@ -31,7 +31,7 @@ namespace MeAjudaAi.Modules.Users.Application.Handlers.Commands; /// /// Repositório para operações de usuário /// Logger estruturado para auditoria detalhada -internal sealed class ChangeUserEmailCommandHandler( +public sealed class ChangeUserEmailCommandHandler( IUserRepository userRepository, ILogger logger ) : ICommandHandler> diff --git a/src/Modules/Users/Application/Handlers/Commands/ChangeUserUsernameCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/ChangeUserUsernameCommandHandler.cs index 904f85cf6..41ebf1b72 100644 --- a/src/Modules/Users/Application/Handlers/Commands/ChangeUserUsernameCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/ChangeUserUsernameCommandHandler.cs @@ -35,7 +35,7 @@ namespace MeAjudaAi.Modules.Users.Application.Handlers.Commands; /// Repositório para operações de usuário /// Provedor de data/hora para testabilidade /// Logger estruturado para auditoria detalhada -internal sealed class ChangeUserUsernameCommandHandler( +public sealed class ChangeUserUsernameCommandHandler( IUserRepository userRepository, IDateTimeProvider dateTimeProvider, ILogger logger diff --git a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs index bb999f737..743ad7c9b 100644 --- a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs @@ -88,7 +88,7 @@ public async Task> HandleAsync( stopwatch.Stop(); logger.LogError(ex, "Unexpected error creating user for email {Email} after {ElapsedMs}ms", command.Email, stopwatch.ElapsedMilliseconds); - return Result.Failure($"Failed to create user: {ex.Message}"); + return Result.Failure("Failed to create user due to an unexpected error"); } } diff --git a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs index a2c330f30..a329b1178 100644 --- a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs @@ -23,7 +23,7 @@ namespace MeAjudaAi.Modules.Users.Application.Handlers.Commands; /// Repositório para persistência de usuários /// Serviço de cache para invalidação /// Logger estruturado para auditoria e debugging -internal sealed class UpdateUserProfileCommandHandler( +public sealed class UpdateUserProfileCommandHandler( IUserRepository userRepository, IUsersCacheService usersCacheService, ILogger logger diff --git a/src/Modules/Users/Application/Handlers/Queries/GetUserByEmailQueryHandler.cs b/src/Modules/Users/Application/Handlers/Queries/GetUserByEmailQueryHandler.cs index 7e62c7649..b78acd747 100644 --- a/src/Modules/Users/Application/Handlers/Queries/GetUserByEmailQueryHandler.cs +++ b/src/Modules/Users/Application/Handlers/Queries/GetUserByEmailQueryHandler.cs @@ -54,9 +54,23 @@ public async Task> HandleAsync( try { + // Validate and create email value object + Email email; + if (!Email.IsValid(query.Email)) + { + logger.LogWarning( + "Invalid email format. CorrelationId: {CorrelationId}, Email: {Email}", + correlationId, query.Email); + return Result.Failure(Error.BadRequest("Invalid email format")); + } + else + { + email = new Email(query.Email); + } + // Busca o usuário pelo email utilizando value object var user = await userRepository.GetByEmailAsync( - new Email(query.Email), cancellationToken); + email, cancellationToken); if (user == null) { @@ -79,7 +93,7 @@ public async Task> HandleAsync( "Failed to retrieve user by email. CorrelationId: {CorrelationId}, Email: {Email}", correlationId, query.Email); - return Result.Failure($"Failed to retrieve user: {ex.Message}"); + return Result.Failure(Error.Internal("Failed to retrieve user")); } } } diff --git a/src/Modules/Users/Application/Handlers/Queries/GetUserByIdQueryHandler.cs b/src/Modules/Users/Application/Handlers/Queries/GetUserByIdQueryHandler.cs index 106e1c796..ed4b0bb72 100644 --- a/src/Modules/Users/Application/Handlers/Queries/GetUserByIdQueryHandler.cs +++ b/src/Modules/Users/Application/Handlers/Queries/GetUserByIdQueryHandler.cs @@ -79,8 +79,8 @@ public async Task> HandleAsync( } logger.LogInformation( - "User found successfully (cache hit/miss handled). CorrelationId: {CorrelationId}, UserId: {UserId}, Email: {Email}", - correlationId, query.UserId, userDto.Email); + "User found successfully (cache hit/miss handled). CorrelationId: {CorrelationId}, UserId: {UserId}", + correlationId, query.UserId); return Result.Success(userDto); } @@ -90,7 +90,7 @@ public async Task> HandleAsync( "Failed to retrieve user by ID. CorrelationId: {CorrelationId}, UserId: {UserId}", correlationId, query.UserId); - return Result.Failure($"Failed to retrieve user: {ex.Message}"); + return Result.Failure(Error.Internal("Failed to retrieve user")); } } } diff --git a/src/Modules/Users/Application/Handlers/Queries/GetUserByUsernameQueryHandler.cs b/src/Modules/Users/Application/Handlers/Queries/GetUserByUsernameQueryHandler.cs index 07a3746c9..93f800a54 100644 --- a/src/Modules/Users/Application/Handlers/Queries/GetUserByUsernameQueryHandler.cs +++ b/src/Modules/Users/Application/Handlers/Queries/GetUserByUsernameQueryHandler.cs @@ -20,7 +20,7 @@ namespace MeAjudaAi.Modules.Users.Application.Handlers.Queries; /// /// Repositório para consultas de usuários /// Logger para auditoria e rastreamento das operações -internal sealed class GetUserByUsernameQueryHandler( +public sealed class GetUserByUsernameQueryHandler( IUserRepository userRepository, ILogger logger ) : IQueryHandler> diff --git a/src/Modules/Users/Application/Properties/AssemblyInfo.cs b/src/Modules/Users/Application/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..9c791b4a8 --- /dev/null +++ b/src/Modules/Users/Application/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MeAjudaAi.Modules.Users.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/src/Modules/Users/Domain/ValueObjects/Email.cs b/src/Modules/Users/Domain/ValueObjects/Email.cs index fda82c7be..27710fc11 100644 --- a/src/Modules/Users/Domain/ValueObjects/Email.cs +++ b/src/Modules/Users/Domain/ValueObjects/Email.cs @@ -25,6 +25,20 @@ public Email(string value) public static implicit operator string(Email email) => email.Value; public static implicit operator Email(string email) => new(email); + /// + /// Validates if the email format is valid without creating an instance. + /// + /// Email string to validate + /// True if email format is valid, false otherwise + public static bool IsValid(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + if (email.Length > ValidationConstants.UserLimits.EmailMaxLength) + return false; + return EmailRegex.IsMatch(email); + } + [GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] private static partial Regex EmailGeneratedRegex(); } diff --git a/src/Modules/Users/Infrastructure/Extensions.cs b/src/Modules/Users/Infrastructure/Extensions.cs index 361032151..8f73c7a16 100644 --- a/src/Modules/Users/Infrastructure/Extensions.cs +++ b/src/Modules/Users/Infrastructure/Extensions.cs @@ -6,7 +6,6 @@ using MeAjudaAi.Modules.Users.Infrastructure.Persistence; using MeAjudaAi.Modules.Users.Infrastructure.Persistence.Repositories; using MeAjudaAi.Modules.Users.Infrastructure.Services; -using MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; using MeAjudaAi.Shared.Database; using MeAjudaAi.Shared.Events; using Microsoft.EntityFrameworkCore; @@ -101,16 +100,12 @@ private static IServiceCollection AddKeycloak(this IServiceCollection services, // Se Keycloak está explicitamente desabilitado OU não há configuração válida, usa mock var shouldUseMock = !keycloakEnabled || !hasValidKeycloakConfig; - if (shouldUseMock) - { - // Registra implementação mock quando Keycloak está desabilitado ou sem configuração - services.AddScoped(); - } - else + if (!shouldUseMock) { // Registra serviço real quando Keycloak está habilitado e configurado services.AddHttpClient(); } + // Quando shouldUseMock é true, não registra nada (será necessário configurar manualmente para testes) return services; } @@ -131,18 +126,18 @@ private static IServiceCollection AddDomainServices(this IServiceCollection serv // Se Keycloak está explicitamente desabilitado OU não há configuração válida, usa mock var shouldUseMock = !keycloakEnabled || !hasValidKeycloakConfig; - if (shouldUseMock) - { - // Registra implementações mock quando Keycloak está desabilitado ou sem configuração - services.AddScoped(); - services.AddScoped(); - } - else + if (!shouldUseMock) { // Registra serviços reais quando Keycloak está habilitado e configurado services.AddScoped(); services.AddScoped(); } + else + { + // Registra implementações mock quando Keycloak não está disponível ou configurado + services.AddScoped(); + services.AddScoped(); + } return services; } diff --git a/src/Modules/Users/Infrastructure/Properties/AssemblyInfo.cs b/src/Modules/Users/Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..9c791b4a8 --- /dev/null +++ b/src/Modules/Users/Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MeAjudaAi.Modules.Users.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationDomainService.cs b/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationDomainService.cs deleted file mode 100644 index 147691cfc..000000000 --- a/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationDomainService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MeAjudaAi.Modules.Users.Domain.Services; -using MeAjudaAi.Modules.Users.Domain.Services.Models; -using MeAjudaAi.Shared.Functional; - -namespace MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; - -/// -/// 🧪 MOCK DO SERVIÇO DE AUTENTICAÇÃO PARA TESTES -/// -/// Implementação mock simples para uso quando Keycloak está desabilitado. -/// Retorna respostas válidas e determinísticas usando MockAuthenticationHelper. -/// -internal sealed class MockAuthenticationDomainService : IAuthenticationDomainService -{ - public Task> AuthenticateAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default) - { - var result = MockAuthenticationHelper.CreateMockAuthenticationResult(); - return Task.FromResult(Result.Success(result)); - } - - public Task> ValidateTokenAsync(string token, CancellationToken cancellationToken = default) - { - var result = MockAuthenticationHelper.CreateMockTokenValidationResult(); - return Task.FromResult(Result.Success(result)); - } -} diff --git a/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationHelper.cs b/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationHelper.cs deleted file mode 100644 index 7991ad86f..000000000 --- a/src/Modules/Users/Infrastructure/Services/Mock/MockAuthenticationHelper.cs +++ /dev/null @@ -1,55 +0,0 @@ -using MeAjudaAi.Modules.Users.Domain.Services.Models; -using MeAjudaAi.Shared.Constants; - -namespace MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; - -/// -/// 🧪 HELPER PARA CRIAÇÃO DE DADOS MOCK DE AUTENTICAÇÃO DETERMINÍSTICOS -/// -/// Centraliza a lógica de criação de dados mock para testes, garantindo -/// valores determinísticos e evitando duplicação entre os serviços mock. -/// -internal static class MockAuthenticationHelper -{ - // Fixed deterministic values for consistent testing - private static readonly Guid FixedUserId = Guid.Parse("550e8400-e29b-41d4-a716-446655440000"); - private static readonly DateTime FixedExpirationTime = new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc); - private static int _keycloakIdCounter = 0; - - public static AuthenticationResult CreateMockAuthenticationResult(string[]? roles = null) - { - return new AuthenticationResult( - UserId: FixedUserId, - AccessToken: "mock-access-token", - RefreshToken: "mock-refresh-token", - ExpiresAt: FixedExpirationTime, - Roles: roles ?? new[] { "user" } - ); - } - - public static TokenValidationResult CreateMockTokenValidationResult(string[]? roles = null) - { - return new TokenValidationResult( - UserId: FixedUserId, - Roles: roles ?? new[] { "user" }, - Claims: new Dictionary - { - { AuthConstants.Claims.Subject, FixedUserId.ToString() }, - { AuthConstants.Claims.PreferredUsername, "mock-user" }, - { AuthConstants.Claims.Email, "mock@example.com" } - } - ); - } - - public static string CreateMockKeycloakId(string? userSpecificValue = null) - { - if (!string.IsNullOrEmpty(userSpecificValue)) - { - return $"keycloak-{userSpecificValue}"; - } - - // Generate unique ID using counter for thread safety - var uniqueId = Interlocked.Increment(ref _keycloakIdCounter); - return $"keycloak-{FixedUserId}-{uniqueId}"; - } -} diff --git a/src/Modules/Users/Infrastructure/Services/Mock/MockKeycloakService.cs b/src/Modules/Users/Infrastructure/Services/Mock/MockKeycloakService.cs deleted file mode 100644 index 2b50563b5..000000000 --- a/src/Modules/Users/Infrastructure/Services/Mock/MockKeycloakService.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Concurrent; -using MeAjudaAi.Modules.Users.Domain.Services.Models; -using MeAjudaAi.Modules.Users.Infrastructure.Identity.Keycloak; -using MeAjudaAi.Shared.Functional; - -namespace MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; - -/// -/// 🧪 MOCK DO SERVIÇO KEYCLOAK PARA TESTES -/// -/// Implementação mock simples para uso quando Keycloak está desabilitado. -/// Retorna respostas válidas e determinísticas usando MockAuthenticationHelper. -/// -internal sealed class MockKeycloakService : IKeycloakService -{ - // Thread-safe in-memory storage for roles per keycloak user (for testing purposes) - private static readonly ConcurrentDictionary _userRoles = new(); - - /// - /// Resets the internal state of the mock service for test isolation - /// - public void Reset() - { - _userRoles.Clear(); - } - - public Task> CreateUserAsync(string username, string email, string firstName, string lastName, string password, IEnumerable roles, CancellationToken cancellationToken = default) - { - var keycloakId = MockAuthenticationHelper.CreateMockKeycloakId(); - - // Store roles for this mock user for potential future validation - if (roles != null) - { - _userRoles.TryAdd(keycloakId, roles.ToArray()); - } - - return Task.FromResult(Result.Success(keycloakId)); - } - - public Task> AuthenticateAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default) - { - var result = MockAuthenticationHelper.CreateMockAuthenticationResult(); - return Task.FromResult(Result.Success(result)); - } - - public Task> ValidateTokenAsync(string token, CancellationToken cancellationToken = default) - { - var result = MockAuthenticationHelper.CreateMockTokenValidationResult(); - return Task.FromResult(Result.Success(result)); - } - - public Task DeactivateUserAsync(string keycloakId, CancellationToken cancellationToken = default) - { - // Remove from our mock storage when deactivated using thread-safe method - _userRoles.TryRemove(keycloakId, out _); - return Task.FromResult(Result.Success()); - } -} diff --git a/src/Modules/Users/Infrastructure/Services/Mock/MockUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/Mock/MockUserDomainService.cs deleted file mode 100644 index d17dd6491..000000000 --- a/src/Modules/Users/Infrastructure/Services/Mock/MockUserDomainService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MeAjudaAi.Modules.Users.Domain.Entities; -using MeAjudaAi.Modules.Users.Domain.Services; -using MeAjudaAi.Modules.Users.Domain.ValueObjects; -using MeAjudaAi.Shared.Functional; - -namespace MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; - -/// -/// 🧪 MOCK DO SERVIÇO DE DOMÍNIO DE USUÁRIOS PARA TESTES -/// -/// Implementação mock simples para uso quando Keycloak está desabilitado. -/// Cria usuários mock válidos sem fazer chamadas reais para o Keycloak. -/// -internal sealed class MockUserDomainService : IUserDomainService -{ - public Task> CreateUserAsync(Username username, Email email, string firstName, string lastName, string password, IEnumerable roles, CancellationToken cancellationToken = default) - { - var user = new User( - username, - email, - firstName, - lastName, - $"mock-keycloak-{Guid.NewGuid()}" - ); - - return Task.FromResult(Result.Success(user)); - } - - public Task SyncUserWithKeycloakAsync(UserId userId, CancellationToken cancellationToken = default) - { - // Mock sempre sincroniza com sucesso - return Task.FromResult(Result.Success()); - } -} diff --git a/src/Modules/Users/Infrastructure/Services/MockAuthenticationDomainService.cs b/src/Modules/Users/Infrastructure/Services/MockAuthenticationDomainService.cs new file mode 100644 index 000000000..870a66456 --- /dev/null +++ b/src/Modules/Users/Infrastructure/Services/MockAuthenticationDomainService.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using System.Text; +using MeAjudaAi.Modules.Users.Domain.Services; +using MeAjudaAi.Modules.Users.Domain.Services.Models; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Users.Infrastructure.Services; + +/// +/// Mock implementation of IAuthenticationDomainService for environments where Keycloak is not available. +/// Provides basic authentication logic for testing and development scenarios. +/// +internal class MockAuthenticationDomainService : IAuthenticationDomainService +{ + /// + /// Authenticates users with mock credentials for testing purposes. + /// + public Task> AuthenticateAsync( + string usernameOrEmail, + string password, + CancellationToken cancellationToken = default) + { + // Para ambientes de teste/desenvolvimento, aceitar credenciais específicas + if ((usernameOrEmail == "testuser" || usernameOrEmail == "test@example.com") && password == "testpassword") + { + var deterministicUserId = GenerateDeterministicGuid(usernameOrEmail); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + var result = new AuthenticationResult( + UserId: deterministicUserId, + AccessToken: $"mock_token_{deterministicUserId}_{timestamp}", + RefreshToken: $"mock_refresh_{deterministicUserId}_{timestamp}", + ExpiresAt: DateTime.UtcNow.AddHours(1), + Roles: ["customer"] + ); + return Task.FromResult(Result.Success(result)); + } + + return Task.FromResult(Result.Failure("Invalid credentials")); + } + + /// + /// Validates mock tokens for testing purposes. + /// + public Task> ValidateTokenAsync( + string token, + CancellationToken cancellationToken = default) + { + // Para ambientes de teste, validar tokens que começam com "mock_token_" + if (token.StartsWith("mock_token_")) + { + // Extrair o userId do token: "mock_token_{userId}_{timestamp}" + var parts = token.Split('_'); + Guid userId; + + if (parts.Length >= 3 && Guid.TryParse(parts[2], out userId)) + { + // Use o userId extraído do token + var result = new TokenValidationResult( + UserId: userId, + Roles: ["customer"], + Claims: new Dictionary { ["sub"] = userId.ToString() } + ); + return Task.FromResult(Result.Success(result)); + } + else + { + // Fallback determinístico se não conseguir extrair o userId + var fallbackUserId = GenerateDeterministicGuid("fallback"); + var result = new TokenValidationResult( + UserId: fallbackUserId, + Roles: ["customer"], + Claims: new Dictionary { ["sub"] = fallbackUserId.ToString() } + ); + return Task.FromResult(Result.Success(result)); + } + } + + return Task.FromResult(Result.Failure("Invalid token")); + } + + /// + /// Generates a deterministic GUID based on the input string. + /// Same input will always produce the same GUID. + /// + private static Guid GenerateDeterministicGuid(string input) + { + // Normalize the input to lowercase for consistency + var normalizedInput = input.ToLowerInvariant(); + + // Generate MD5 hash of the normalized input + var hash = MD5.HashData(Encoding.UTF8.GetBytes(normalizedInput)); + + // Use the first 16 bytes of the hash to create a GUID + return new Guid(hash); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Infrastructure/Services/MockUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/MockUserDomainService.cs new file mode 100644 index 000000000..94b881f51 --- /dev/null +++ b/src/Modules/Users/Infrastructure/Services/MockUserDomainService.cs @@ -0,0 +1,41 @@ +using MeAjudaAi.Modules.Users.Domain.Entities; +using MeAjudaAi.Modules.Users.Domain.Services; +using MeAjudaAi.Modules.Users.Domain.ValueObjects; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Users.Infrastructure.Services; + +/// +/// Mock implementation of IUserDomainService for environments where Keycloak is not available. +/// This service creates users locally without external authentication integration. +/// +public class MockUserDomainService : IUserDomainService +{ + /// + /// Creates a user locally without Keycloak integration. + /// Generates a mock Keycloak ID for consistency. + /// + public Task> CreateUserAsync( + Username username, + Email email, + string firstName, + string lastName, + string password, + IEnumerable roles, + CancellationToken cancellationToken = default) + { + // Para ambientes sem Keycloak, criar usuário mock com ID simulado + var user = new User(username, email, firstName, lastName, $"mock_keycloak_{Guid.NewGuid()}"); + return Task.FromResult(Result.Success(user)); + } + + /// + /// Simulates synchronization with Keycloak. + /// Always returns success for mock implementation. + /// + public Task SyncUserWithKeycloakAsync(UserId userId, CancellationToken cancellationToken = default) + { + // Para ambientes sem Keycloak, simular sincronização bem-sucedida + return Task.FromResult(Result.Success()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Tests/Integration/Infrastructure/UserRepositoryTests.cs b/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs similarity index 100% rename from src/Modules/Users/Tests/Integration/Infrastructure/UserRepositoryTests.cs rename to src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs diff --git a/src/Modules/Users/Tests/Integration/Services/UsersModuleApiIntegrationTests.cs b/src/Modules/Users/Tests/Integration/UsersModuleApiIntegrationTests.cs similarity index 100% rename from src/Modules/Users/Tests/Integration/Services/UsersModuleApiIntegrationTests.cs rename to src/Modules/Users/Tests/Integration/UsersModuleApiIntegrationTests.cs diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockAuthenticationHelperTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockAuthenticationHelperTests.cs deleted file mode 100644 index 228ecf5d9..000000000 --- a/src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockAuthenticationHelperTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; -using MeAjudaAi.Shared.Constants; - -namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Services.Mock; - -/// -/// Tests for MockAuthenticationHelper to verify deterministic and unique behavior -/// -[Trait("Category", "Unit")] -[Trait("Module", "Users")] -[Trait("Component", "MockServices")] -public class MockAuthenticationHelperTests -{ - [Fact] - public void CreateMockKeycloakId_WithoutParameter_ShouldReturnUniqueIds() - { - // Act - var id1 = MockAuthenticationHelper.CreateMockKeycloakId(); - var id2 = MockAuthenticationHelper.CreateMockKeycloakId(); - var id3 = MockAuthenticationHelper.CreateMockKeycloakId(); - - // Assert - id1.Should().NotBe(id2); - id2.Should().NotBe(id3); - id1.Should().NotBe(id3); - - // All should start with "keycloak-" prefix - id1.Should().StartWith("keycloak-"); - id2.Should().StartWith("keycloak-"); - id3.Should().StartWith("keycloak-"); - } - - [Fact] - public void CreateMockKeycloakId_WithUserSpecificValue_ShouldReturnDeterministicId() - { - // Act - var id1 = MockAuthenticationHelper.CreateMockKeycloakId("user123"); - var id2 = MockAuthenticationHelper.CreateMockKeycloakId("user123"); - var id3 = MockAuthenticationHelper.CreateMockKeycloakId("user456"); - - // Assert - id1.Should().Be(id2); // Same user should get same ID - id1.Should().NotBe(id3); // Different users should get different IDs - - id1.Should().Be("keycloak-user123"); - id3.Should().Be("keycloak-user456"); - } - - [Fact] - public void CreateMockAuthenticationResult_ShouldReturnValidNonExpiredToken() - { - // Act - var result = MockAuthenticationHelper.CreateMockAuthenticationResult(); - - // Assert - result.ExpiresAt.Should().BeAfter(DateTime.UtcNow.AddYears(1000)); // Far future - result.UserId.Should().NotBe(Guid.Empty); - result.AccessToken.Should().NotBeNullOrEmpty(); - result.RefreshToken.Should().NotBeNullOrEmpty(); - result.Roles.Should().NotBeNull(); - } - - [Fact] - public void CreateMockTokenValidationResult_ShouldReturnValidResult() - { - // Act - var result = MockAuthenticationHelper.CreateMockTokenValidationResult(); - - // Assert - result.UserId.Should().NotBe(Guid.Empty); - result.Roles.Should().NotBeNull(); - result.Claims.Should().NotBeNull().And.NotBeEmpty(); - result.Claims.Should().ContainKey(AuthConstants.Claims.Subject); - result.Claims.Should().ContainKey(AuthConstants.Claims.PreferredUsername); - result.Claims.Should().ContainKey(AuthConstants.Claims.Email); - } -} diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockKeycloakServiceTests.cs b/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs similarity index 62% rename from src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockKeycloakServiceTests.cs rename to src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs index d5a3f5367..e5633e1f6 100644 --- a/src/Modules/Users/Tests/Unit/Infrastructure/Services/Mock/MockKeycloakServiceTests.cs +++ b/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; -using MeAjudaAi.Modules.Users.Infrastructure.Services.Mock; +using MeAjudaAi.Modules.Users.Tests.Infrastructure.Mocks; -namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Services.Mock; +namespace MeAjudaAi.Modules.Users.Tests.Unit.Mocks.Services; /// /// Tests for MockKeycloakService to verify core functionality @@ -16,7 +16,6 @@ public class MockKeycloakServiceTests public MockKeycloakServiceTests() { _service = new MockKeycloakService(); - _service.Reset(); // Clear shared state between tests } [Fact] @@ -36,21 +35,21 @@ public async Task CreateUserAsync_ShouldReturnSuccessWithKeycloakId() // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNullOrEmpty(); - result.Value.Should().StartWith("keycloak-"); + result.Value.Should().StartWith("keycloak_"); } [Fact] public async Task AuthenticateAsync_ShouldReturnSuccessWithValidToken() { // Act - var result = await _service.AuthenticateAsync("testuser", "password123"); + var result = await _service.AuthenticateAsync("validuser", "validpassword"); // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value.AccessToken.Should().NotBeNullOrEmpty(); result.Value.RefreshToken.Should().NotBeNullOrEmpty(); - result.Value.ExpiresAt.Should().BeAfter(DateTime.UtcNow.AddYears(1000)); + result.Value.ExpiresAt.Should().BeAfter(DateTime.UtcNow); result.Value.UserId.Should().NotBe(Guid.Empty); } @@ -58,7 +57,7 @@ public async Task AuthenticateAsync_ShouldReturnSuccessWithValidToken() public async Task ValidateTokenAsync_ShouldReturnSuccessWithValidResult() { // Act - var result = await _service.ValidateTokenAsync("mock-token"); + var result = await _service.ValidateTokenAsync("mock_token_123"); // Assert result.IsSuccess.Should().BeTrue(); @@ -82,28 +81,5 @@ public async Task DeactivateUserAsync_ShouldReturnSuccess() result.IsSuccess.Should().BeTrue(); } - [Fact] - public async Task ConcurrentUserCreation_ShouldBeThreadSafe() - { - // Arrange - var userCount = 50; - var tasks = new List>(); - // Act - Create users concurrently - for (int i = 0; i < userCount; i++) - { - int userId = i; // Capture loop variable - tasks.Add(Task.Run(async () => - { - var result = await _service.CreateUserAsync($"user{userId}", $"user{userId}@example.com", "Test", "User", "password", new[] { "user" }); - return result.Value; - })); - } - - var keycloakIds = await Task.WhenAll(tasks); - - // Assert - All IDs should be unique - keycloakIds.Should().OnlyHaveUniqueItems(); - keycloakIds.Should().AllSatisfy(id => id.Should().StartWith("keycloak-")); - } } diff --git a/src/Shared/Authorization/Middleware/PermissionOptimizationMiddleware.cs b/src/Shared/Authorization/Middleware/PermissionOptimizationMiddleware.cs index 33af02ca0..c8fa4cabb 100644 --- a/src/Shared/Authorization/Middleware/PermissionOptimizationMiddleware.cs +++ b/src/Shared/Authorization/Middleware/PermissionOptimizationMiddleware.cs @@ -155,7 +155,7 @@ private void ApplyReadOnlyOptimizations(HttpContext context) return; // Para operações de leitura em endpoints específicos, pode usar cache mais agressivo - if (path.StartsWith("/api/users/profile", StringComparison.OrdinalIgnoreCase) || + if (path.StartsWith("/api/v1/users/profile", StringComparison.OrdinalIgnoreCase) || path.StartsWith(ApiEndpoints.System.Health, StringComparison.OrdinalIgnoreCase)) { context.Items["UseAggressivePermissionCache"] = true; @@ -163,7 +163,8 @@ private void ApplyReadOnlyOptimizations(HttpContext context) } else if (path.StartsWith("/api/") && context.Request.Method == "GET") { - // Operações GET em APIs podem usar cache intermediário + // Catch-all para operações GET em qualquer versão da API - cache intermediário + // Suporta múltiplas versões da API (v1, v2, etc.) para compatibilidade context.Items["UseAggressivePermissionCache"] = false; context.Items["PermissionCacheDuration"] = TimeSpan.FromMinutes(10); } @@ -177,7 +178,7 @@ private static List GetRequiredPermissionsForPath(string path, stri var permissions = new List(); // Users module - if (path.StartsWith("/api/users")) + if (path.StartsWith("/api/v1/users")) { permissions.AddRange(method.ToUpperInvariant() switch { @@ -191,8 +192,8 @@ private static List GetRequiredPermissionsForPath(string path, stri }); } - // Providers module (futuro) - else if (path.StartsWith("/api/providers")) + // Providers module + else if (path.StartsWith("/api/v1/providers")) { permissions.AddRange(method.ToUpperInvariant() switch { @@ -204,8 +205,8 @@ private static List GetRequiredPermissionsForPath(string path, stri }); } - // Orders module (futuro) - else if (path.StartsWith("/api/orders")) + // Orders module (futuro) - Aguardando implementação do módulo completo + else if (path.StartsWith("/api/v1/orders")) { permissions.AddRange(method.ToUpperInvariant() switch { @@ -217,8 +218,8 @@ private static List GetRequiredPermissionsForPath(string path, stri }); } - // Reports module (futuro) - else if (path.StartsWith("/api/reports")) + // Reports module (futuro) - Aguardando implementação do módulo completo + else if (path.StartsWith("/api/v1/reports")) { permissions.AddRange(method.ToUpperInvariant() switch { @@ -229,8 +230,8 @@ private static List GetRequiredPermissionsForPath(string path, stri }); } - // Admin endpoints - else if (path.StartsWith("/api/admin") || path.Contains("/admin")) + // Admin endpoints - Verifica /api/v1/admin ou qualquer segmento "admin" no path + else if (path.StartsWith("/api/v1/admin") || IsAdminPath(path)) { permissions.Add(EPermission.AdminSystem); } @@ -238,6 +239,19 @@ private static List GetRequiredPermissionsForPath(string path, stri return permissions; } + /// + /// Verifica se o path contém "admin" como um segmento distinto. + /// + private static bool IsAdminPath(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments.Any(segment => + string.Equals(segment, "admin", StringComparison.OrdinalIgnoreCase)); + } + /// /// Verifica se o endpoint é público e não precisa de autenticação. /// diff --git a/src/Shared/MeAjudaAi.Shared.csproj b/src/Shared/MeAjudaAi.Shared.csproj index ada974203..609bfee4e 100644 --- a/src/Shared/MeAjudaAi.Shared.csproj +++ b/src/Shared/MeAjudaAi.Shared.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + false diff --git a/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs new file mode 100644 index 000000000..2ea6c720a --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderDeletedIntegrationEvent.cs @@ -0,0 +1,24 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando um prestador de serviços é excluído do sistema. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando um prestador é excluído (soft delete). +/// Outros módulos podem usar este evento para: +/// - Cancelar serviços associados +/// - Enviar notificações de encerramento +/// - Arquivar dados relacionados +/// - Atualizar estatísticas +/// +public sealed record ProviderDeletedIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + string Reason, + DateTime DeletedAt, + string? DeletedBy = null +) : IntegrationEvent(Source); \ No newline at end of file diff --git a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs new file mode 100644 index 000000000..8a4511d52 --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs @@ -0,0 +1,26 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando o perfil de um prestador é atualizado. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando dados do prestador são alterados. +/// Outros módulos podem usar este evento para: +/// - Sincronizar informações em caches +/// - Atualizar índices de busca +/// - Notificar sistemas externos +/// - Manter auditoria de mudanças +/// +public sealed record ProviderProfileUpdatedIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + IEnumerable UpdatedFields, + string? UpdatedBy = null, + string? PreviousName = null, + string? NewEmail = null, + string? NewPhoneNumber = null +) : IntegrationEvent(Source); \ No newline at end of file diff --git a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs new file mode 100644 index 000000000..cc9986a31 --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs @@ -0,0 +1,27 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando um novo prestador de serviços é registrado no sistema. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando um prestador é criado. +/// Outros módulos podem usar este evento para: +/// - Criar perfis associados +/// - Enviar notificações de boas-vindas +/// - Sincronizar dados com sistemas externos +/// - Atualizar estatísticas e métricas +/// +public sealed record ProviderRegisteredIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + string ProviderType, + string Email, + string? PhoneNumber = null, + string? City = null, + string? State = null, + DateTime? RegisteredAt = null +) : IntegrationEvent(Source); \ No newline at end of file diff --git a/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs new file mode 100644 index 000000000..ec79d3b33 --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderVerificationStatusUpdatedIntegrationEvent.cs @@ -0,0 +1,25 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando o status de verificação de um prestador é atualizado. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando um prestador tem seu status alterado. +/// Outros módulos podem usar este evento para: +/// - Enviar notificações de aprovação/reprovação +/// - Atualizar permissões de acesso +/// - Sincronizar com sistemas externos +/// - Gerar relatórios de conformidade +/// +public sealed record ProviderVerificationStatusUpdatedIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + string PreviousStatus, + string NewStatus, + string? UpdatedBy = null, + string? Comments = null +) : IntegrationEvent(Source); \ No newline at end of file diff --git a/tests/MeAjudaAi.E2E.Tests/Authorization/PermissionAuthorizationE2ETests.cs b/tests/MeAjudaAi.E2E.Tests/Authorization/PermissionAuthorizationE2ETests.cs index 594443b34..152622b6e 100644 --- a/tests/MeAjudaAi.E2E.Tests/Authorization/PermissionAuthorizationE2ETests.cs +++ b/tests/MeAjudaAi.E2E.Tests/Authorization/PermissionAuthorizationE2ETests.cs @@ -36,27 +36,27 @@ public async Task BasicUserWorkflow_ShouldHaveAppropriateAccess() // Act & Assert - Operações que o usuário básico PODE fazer // 1. Ver seu próprio perfil - var profileResponse = await _client.GetAsync("/api/users/profile"); + var profileResponse = await _client.GetAsync("/api/v1/users/profile"); Assert.Equal(HttpStatusCode.OK, profileResponse.StatusCode); // 2. Ler informações básicas de usuários - var readResponse = await _client.GetAsync("/api/users/basic-info"); + var readResponse = await _client.GetAsync("/api/v1/users/basic-info"); Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode); // Act & Assert - Operações que o usuário básico NÃO PODE fazer // 3. Criar usuários (deve retornar Forbidden) var createUserPayload = new { name = "New User", email = "new@test.com" }; - var createResponse = await _client.PostAsync("/api/users", + var createResponse = await _client.PostAsync("/api/v1/users", new StringContent(JsonSerializer.Serialize(createUserPayload), Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode); // 4. Deletar usuários (deve retornar Forbidden) - var deleteResponse = await _client.DeleteAsync("/api/users/some-user-id"); + var deleteResponse = await _client.DeleteAsync("/api/v1/users/some-user-id"); Assert.Equal(HttpStatusCode.Forbidden, deleteResponse.StatusCode); // 5. Acessar área administrativa (deve retornar Forbidden) - var adminResponse = await _client.GetAsync("/api/users/admin"); + var adminResponse = await _client.GetAsync("/api/v1/users/admin"); Assert.Equal(HttpStatusCode.Forbidden, adminResponse.StatusCode); } @@ -78,29 +78,29 @@ public async Task UserAdminWorkflow_ShouldHaveAdministrativeAccess() // Act & Assert - Operações administrativas que PODE fazer // 1. Listar todos os usuários - var listResponse = await _client.GetAsync("/api/users"); + var listResponse = await _client.GetAsync("/api/v1/users"); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); // 2. Criar usuários var createUserPayload = new { name = "Admin Created User", email = "admin@test.com" }; - var createResponse = await _client.PostAsync("/api/users", + var createResponse = await _client.PostAsync("/api/v1/users", new StringContent(JsonSerializer.Serialize(createUserPayload), Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); // 3. Atualizar usuários var updatePayload = new { name = "Updated Name" }; - var updateResponse = await _client.PutAsync("/api/users/some-user-id", + var updateResponse = await _client.PutAsync("/api/v1/users/some-user-id", new StringContent(JsonSerializer.Serialize(updatePayload), Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); // 4. Acessar área administrativa de usuários - var adminResponse = await _client.GetAsync("/api/users/admin"); + var adminResponse = await _client.GetAsync("/api/v1/users/admin"); Assert.Equal(HttpStatusCode.OK, adminResponse.StatusCode); // Act & Assert - Operações que NÃO PODE fazer (sem permissão de delete) // 5. Deletar usuários (deve retornar Forbidden - precisa de permissão específica) - var deleteResponse = await _client.DeleteAsync("/api/users/some-user-id"); + var deleteResponse = await _client.DeleteAsync("/api/v1/users/some-user-id"); Assert.Equal(HttpStatusCode.Forbidden, deleteResponse.StatusCode); // 6. Operações de sistema (deve retornar Forbidden) @@ -128,18 +128,18 @@ public async Task SystemAdminWorkflow_ShouldHaveFullAccess() // Act & Assert - Deve ter acesso completo // 1. Todas as operações de usuários - var listResponse = await _client.GetAsync("/api/users"); + var listResponse = await _client.GetAsync("/api/v1/users"); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); - var createResponse = await _client.PostAsync("/api/users", + var createResponse = await _client.PostAsync("/api/v1/users", new StringContent(JsonSerializer.Serialize(new { name = "System User" }), Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); - var updateResponse = await _client.PutAsync("/api/users/some-user-id", + var updateResponse = await _client.PutAsync("/api/v1/users/some-user-id", new StringContent(JsonSerializer.Serialize(new { name = "Updated" }), Encoding.UTF8, "application/json")); Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); - var deleteResponse = await _client.DeleteAsync("/api/users/some-user-id"); + var deleteResponse = await _client.DeleteAsync("/api/v1/users/some-user-id"); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); // 2. Operações de sistema @@ -147,7 +147,7 @@ public async Task SystemAdminWorkflow_ShouldHaveFullAccess() Assert.Equal(HttpStatusCode.OK, systemResponse.StatusCode); // 3. Área administrativa completa - var adminResponse = await _client.GetAsync("/api/users/admin"); + var adminResponse = await _client.GetAsync("/api/v1/users/admin"); Assert.Equal(HttpStatusCode.OK, adminResponse.StatusCode); } @@ -164,11 +164,11 @@ public async Task ModuleSpecificPermissions_ShouldIsolateAccess() _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", usersOnlyToken); // Act & Assert - Acesso ao módulo Users - var usersResponse = await _client.GetAsync("/api/users"); + var usersResponse = await _client.GetAsync("/api/v1/users"); Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode); // Act & Assert - Sem acesso a outros módulos (quando implementados) - var providersResponse = await _client.GetAsync("/api/providers"); + var providersResponse = await _client.GetAsync("/api/v1/providers"); Assert.Equal(HttpStatusCode.Forbidden, providersResponse.StatusCode); var ordersResponse = await _client.GetAsync("/api/orders"); @@ -197,8 +197,8 @@ public async Task ConcurrentUsersSameResource_ShouldRespectIndividualPermissions adminClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminUserToken); // Act & Assert - Operação que apenas admin pode fazer - var basicUserDeleteResponse = await basicClient.DeleteAsync("/api/users/test-user"); - var adminUserDeleteResponse = await adminClient.DeleteAsync("/api/users/test-user"); + var basicUserDeleteResponse = await basicClient.DeleteAsync("/api/v1/users/test-user"); + var adminUserDeleteResponse = await adminClient.DeleteAsync("/api/v1/users/test-user"); // Assert Assert.Equal(HttpStatusCode.Forbidden, basicUserDeleteResponse.StatusCode); @@ -222,7 +222,7 @@ public async Task PermissionCaching_ShouldWorkAcrossRequests() for (int i = 0; i < 5; i++) { - var response = await _client.GetAsync("/api/users/profile"); + var response = await _client.GetAsync("/api/v1/users/profile"); responses.Add(response); } @@ -241,7 +241,7 @@ public async Task TokenExpiredOrInvalid_ShouldReturnUnauthorized() _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token"); // Act - var response = await _client.GetAsync("/api/users/profile"); + var response = await _client.GetAsync("/api/v1/users/profile"); // Assert Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); @@ -259,7 +259,7 @@ public async Task MissingRequiredPermission_ShouldReturnForbiddenWithDetails() _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", limitedToken); // Act - var createResponse = await _client.PostAsync("/api/users", + var createResponse = await _client.PostAsync("/api/v1/users", new StringContent(JsonSerializer.Serialize(new { name = "Test" }), Encoding.UTF8, "application/json")); // Assert diff --git a/tests/MeAjudaAi.E2E.Tests/Base/TestContainerTestBase.cs b/tests/MeAjudaAi.E2E.Tests/Base/TestContainerTestBase.cs index 6f67ed3e3..63bd57493 100644 --- a/tests/MeAjudaAi.E2E.Tests/Base/TestContainerTestBase.cs +++ b/tests/MeAjudaAi.E2E.Tests/Base/TestContainerTestBase.cs @@ -71,20 +71,22 @@ public virtual async ValueTask InitializeAsync() ["Keycloak:Enabled"] = "false", ["Cache:Enabled"] = "false", // Disable Redis for now ["Cache:ConnectionString"] = _redisContainer.GetConnectionString(), - // Configuração de Rate Limiting para testes - valores muito altos para evitar bloqueios - ["AdvancedRateLimit:Anonymous:RequestsPerMinute"] = "10000", - ["AdvancedRateLimit:Anonymous:RequestsPerHour"] = "100000", - ["AdvancedRateLimit:Anonymous:RequestsPerDay"] = "1000000", - ["AdvancedRateLimit:Authenticated:RequestsPerMinute"] = "10000", - ["AdvancedRateLimit:Authenticated:RequestsPerHour"] = "100000", - ["AdvancedRateLimit:Authenticated:RequestsPerDay"] = "1000000", - ["AdvancedRateLimit:General:WindowInSeconds"] = "60", - ["AdvancedRateLimit:General:EnableIpWhitelist"] = "false", + // Desabilitar completamente Rate Limiting nos testes E2E + ["AdvancedRateLimit:General:Enabled"] = "false", + // Valores de fallback muito altos caso não consiga desabilitar + ["AdvancedRateLimit:Anonymous:RequestsPerMinute"] = "999999", + ["AdvancedRateLimit:Anonymous:RequestsPerHour"] = "999999", + ["AdvancedRateLimit:Anonymous:RequestsPerDay"] = "999999", + ["AdvancedRateLimit:Authenticated:RequestsPerMinute"] = "999999", + ["AdvancedRateLimit:Authenticated:RequestsPerHour"] = "999999", + ["AdvancedRateLimit:Authenticated:RequestsPerDay"] = "999999", + ["AdvancedRateLimit:General:WindowInSeconds"] = "3600", + ["AdvancedRateLimit:General:EnableIpWhitelist"] = "true", // Configuração legada também para garantir - ["RateLimit:DefaultRequestsPerMinute"] = "10000", - ["RateLimit:AuthRequestsPerMinute"] = "10000", - ["RateLimit:SearchRequestsPerMinute"] = "10000", - ["RateLimit:WindowInSeconds"] = "60" + ["RateLimit:DefaultRequestsPerMinute"] = "999999", + ["RateLimit:AuthRequestsPerMinute"] = "999999", + ["RateLimit:SearchRequestsPerMinute"] = "999999", + ["RateLimit:WindowInSeconds"] = "3600" }); // Adicionar ambiente de teste @@ -115,6 +117,20 @@ public virtual async ValueTask InitializeAsync() warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); + // Reconfigurar ProvidersDbContext com TestContainer connection string + var providersDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (providersDescriptor != null) + services.Remove(providersDescriptor); + + services.AddDbContext(options => + { + options.UseNpgsql(_postgresContainer.GetConnectionString()) + .UseSnakeCaseNamingConvention() + .EnableSensitiveDataLogging(false) + .ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + }); + // Substituir IKeycloakService por MockKeycloakService para testes var keycloakDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IKeycloakService)); if (keycloakDescriptor != null) @@ -148,8 +164,7 @@ public virtual async ValueTask InitializeAsync() services.AddScoped>(provider => () => { var context = provider.GetRequiredService(); - // Aplicar migrações apenas em testes - context.Database.Migrate(); + // Migrations are applied explicitly in ApplyMigrationsAsync, no action needed here return context; }); }); @@ -212,15 +227,16 @@ private async Task ApplyMigrationsAsync() { using var scope = _factory.Services.CreateScope(); - // Aplicar migrações no UsersDbContext + // Garantir que o banco está limpo primeiro var usersContext = scope.ServiceProvider.GetRequiredService(); await usersContext.Database.EnsureDeletedAsync(); - await usersContext.Database.EnsureCreatedAsync(); - // Aplicar migrações no ProvidersDbContext + // Aplicar migrações no UsersDbContext (isso cria o banco e o schema users) + await usersContext.Database.MigrateAsync(); + + // Para ProvidersDbContext, só aplicar migrações (o banco já existe, só precisamos do schema providers) var providersContext = scope.ServiceProvider.GetRequiredService(); - await providersContext.Database.EnsureDeletedAsync(); - await providersContext.Database.EnsureCreatedAsync(); + await providersContext.Database.MigrateAsync(); } // Helper methods usando serialização compartilhada @@ -307,12 +323,8 @@ protected static void AuthenticateAsAnonymous() } protected async Task PostJsonAsync(Uri requestUri, T content) - { - throw new NotImplementedException(); - } + => await PostJsonAsync(requestUri.ToString(), content); protected async Task PutJsonAsync(Uri requestUri, T content) - { - throw new NotImplementedException(); - } + => await PutJsonAsync(requestUri.ToString(), content); } diff --git a/tests/MeAjudaAi.Integration.Tests/Auth/AuthenticationTests.cs b/tests/MeAjudaAi.Integration.Tests/Auth/AuthenticationTests.cs index f2c0915eb..6e16b8aeb 100644 --- a/tests/MeAjudaAi.Integration.Tests/Auth/AuthenticationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Auth/AuthenticationTests.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Integration.Tests.Auth; /// /// Testes para verificar se o sistema de autenticação mock está funcionando /// -public class AuthenticationTests : InstanceApiTestBase +public class AuthenticationTests : ApiTestBase { [Fact] public async Task GetUsers_WithoutAuthentication_ShouldReturnUnauthorized() diff --git a/tests/MeAjudaAi.Integration.Tests/Authorization/InstancePermissionAuthorizationIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Authorization/InstancePermissionAuthorizationIntegrationTests.cs index 35be64851..f254ed746 100644 --- a/tests/MeAjudaAi.Integration.Tests/Authorization/InstancePermissionAuthorizationIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Authorization/InstancePermissionAuthorizationIntegrationTests.cs @@ -11,7 +11,7 @@ namespace MeAjudaAi.Integration.Tests.Authorization; /// Elimina condições de corrida e flakiness causados por estado estático /// [Collection("IntegrationTests")] -public class InstancePermissionAuthorizationIntegrationTests : InstanceApiTestBase +public class InstancePermissionAuthorizationIntegrationTests : ApiTestBase { [Fact] public async Task AdminUser_ShouldHaveAccessToAllEndpoints() diff --git a/tests/MeAjudaAi.Integration.Tests/Authorization/PermissionAuthorizationIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Authorization/PermissionAuthorizationIntegrationTests.cs index 5bb526605..358358f75 100644 --- a/tests/MeAjudaAi.Integration.Tests/Authorization/PermissionAuthorizationIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Authorization/PermissionAuthorizationIntegrationTests.cs @@ -23,7 +23,7 @@ namespace MeAjudaAi.Integration.Tests.Authorization; /// /// Testes de integração para o sistema de autorização baseado em permissões. /// -public class PermissionAuthorizationIntegrationTests : InstanceApiTestBase +public class PermissionAuthorizationIntegrationTests : ApiTestBase { private readonly ITestOutputHelper _output; diff --git a/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs b/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs index f6ed68206..2880eb989 100644 --- a/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs +++ b/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs @@ -13,8 +13,9 @@ namespace MeAjudaAi.Integration.Tests.Base; /// -/// Classe base simplificada para testes de integração -/// Cria containers individuais para máxima compatibilidade com CI +/// Classe base unificada para testes de integração com suporte a autenticação baseada em instância. +/// Elimina condições de corrida e instabilidade causadas por estado estático. +/// Cria containers individuais para máxima compatibilidade com CI. /// public abstract class ApiTestBase : IAsyncLifetime { @@ -23,10 +24,11 @@ public abstract class ApiTestBase : IAsyncLifetime protected HttpClient Client { get; private set; } = null!; protected IServiceProvider Services => _factory!.Services; + protected ITestAuthenticationConfiguration AuthConfig { get; private set; } = null!; public async ValueTask InitializeAsync() { - // Set environment variables for testing + // Define variáveis de ambiente para testes Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing"); @@ -39,7 +41,7 @@ public async ValueTask InitializeAsync() builder.UseEnvironment("Testing"); builder.ConfigureServices(services => { - // Substitute database with test container - Remove all DbContext related services + // Substitui banco de dados por container de teste - Remove todos os serviços relacionados ao DbContext var usersDbContextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); if (usersDbContextDescriptor != null) services.Remove(usersDbContextDescriptor); @@ -48,7 +50,7 @@ public async ValueTask InitializeAsync() if (providersDbContextDescriptor != null) services.Remove(providersDbContextDescriptor); - // Also remove the actual DbContext services if they exist + // Remove também os serviços DbContext se existirem var usersDbContextService = services.SingleOrDefault(d => d.ServiceType == typeof(UsersDbContext)); if (usersDbContextService != null) services.Remove(usersDbContextService); @@ -57,31 +59,43 @@ public async ValueTask InitializeAsync() if (providersDbContextService != null) services.Remove(providersDbContextService); - // Add test database contexts + // Adiciona contextos de banco de dados para testes services.AddDbContext(options => { - options.UseNpgsql(_databaseFixture.ConnectionString); + options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Users.Infrastructure"); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "users"); + }); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); services.AddDbContext(options => { - options.UseNpgsql(_databaseFixture.ConnectionString); + options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Providers.Infrastructure"); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "providers"); + }); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); - // Add test authentication to override any existing authentication + // Adiciona autenticação de teste baseada em instância para evitar estado estático services.RemoveRealAuthentication(); - services.AddConfigurableTestAuthentication(); + services.AddInstanceTestAuthentication(); - // Remove ClaimsTransformation that causes hanging in tests + // Remove ClaimsTransformation que causa travamentos nos testes var claimsTransformationDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IClaimsTransformation)); if (claimsTransformationDescriptor != null) services.Remove(claimsTransformationDescriptor); }); - // Enable detailed logging for debugging + // Habilita logging detalhado para debug builder.ConfigureLogging(logging => { logging.ClearProviders(); @@ -95,105 +109,81 @@ public async ValueTask InitializeAsync() Client = _factory.CreateClient(); - // Ensure database schema using EnsureCreatedAsync for testing - // Note: UsersDbContext has pending model changes that would require new migrations + // Obtém a configuração de autenticação da instância do container DI + AuthConfig = _factory.Services.GetRequiredService(); + + // Aplica migrações do banco de dados para testes + // Nota: Ambos os módulos usam setup baseado em migrações para consistência com produção using var scope = _factory.Services.CreateScope(); var usersContext = scope.ServiceProvider.GetRequiredService(); var providersContext = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetService>(); - // Create schemas first + // Aplica migrações exatamente como nos testes E2E + await ApplyMigrationsAsync(usersContext, providersContext, logger); + } + + private static async Task ApplyMigrationsAsync(UsersDbContext usersContext, ProvidersDbContext providersContext, ILogger? logger) + { + // Garante estado limpo do banco de dados (como nos testes E2E) try { - await providersContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS providers;"); - await usersContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS users;"); - logger?.LogInformation("Database schemas created successfully"); + await usersContext.Database.EnsureDeletedAsync(); + logger?.LogInformation("🧹 Banco de dados existente limpo"); } catch (Exception ex) { - logger?.LogWarning(ex, "Failed to create schemas, they may already exist"); + logger?.LogError(ex, "❌ Falha crítica ao limpar banco existente: {Message}", ex.Message); + throw new InvalidOperationException("Não foi possível limpar o banco de dados antes dos testes", ex); } - // For UsersDbContext, use EnsureCreatedAsync (works fine for users) + // Aplica migrações no UsersDbContext primeiro (cria database e schema users) try { - await usersContext.Database.EnsureCreatedAsync(); - logger?.LogInformation("Users database schema created successfully"); + logger?.LogInformation("🔄 Aplicando migrações do módulo Users..."); + await usersContext.Database.MigrateAsync(); + logger?.LogInformation("✅ Migrações do banco Users completadas com sucesso"); } catch (Exception ex) { - logger?.LogError(ex, "Failed to create Users database schema"); - throw; + logger?.LogError(ex, "❌ Falha ao aplicar migrações do Users: {Message}", ex.Message); + throw new InvalidOperationException("Não foi possível aplicar migrações do banco Users", ex); } - // For ProvidersDbContext, use migrations for proper table structure + // Aplica migrações no ProvidersDbContext (banco já existe, só precisa do schema providers) try { - logger?.LogInformation("🔄 Running Providers migrations..."); + logger?.LogInformation("🔄 Aplicando migrações do módulo Providers..."); await providersContext.Database.MigrateAsync(); - logger?.LogInformation("✅ Providers database migrations completed successfully"); + logger?.LogInformation("✅ Migrações do banco Providers completadas com sucesso"); } catch (Exception ex) { - logger?.LogWarning(ex, "⚠️ Migrations failed for Providers, trying EnsureCreatedAsync"); - - try - { - await providersContext.Database.EnsureCreatedAsync(); - logger?.LogInformation("✅ Providers database schema created with EnsureCreatedAsync"); - } - catch (Exception ensureEx) - { - logger?.LogWarning(ensureEx, "⚠️ EnsureCreatedAsync also failed, falling back to manual creation"); - - try - { - await CreateProvidersTableManually(providersContext, logger); - logger?.LogInformation("✅ Providers database schema created using manual table creation"); - } - catch (Exception manualEx) - { - logger?.LogError(manualEx, "❌ All Providers table creation methods failed"); - throw new InvalidOperationException("Unable to initialize Providers database schema", manualEx); - } - } + logger?.LogError(ex, "❌ Falha ao aplicar migrações do Providers: {Message}", ex.Message); + throw new InvalidOperationException("Não foi possível aplicar migrações do banco Providers", ex); } - // Verify tables exist + // Verifica se as tabelas existem try { var usersCount = await usersContext.Users.CountAsync(); - logger?.LogInformation("Users database verification successful - Count: {UsersCount}", usersCount); + logger?.LogInformation("Verificação do banco Users bem-sucedida - Contagem: {UsersCount}", usersCount); } catch (Exception ex) { - logger?.LogError(ex, "Users database verification failed"); - throw new InvalidOperationException("Users database is not properly initialized", ex); + logger?.LogError(ex, "Verificação do banco Users falhou"); + throw new InvalidOperationException("Banco Users não foi inicializado corretamente", ex); } try { var providersCount = await providersContext.Providers.CountAsync(); - logger?.LogInformation("Providers database verification successful - Count: {ProvidersCount}", providersCount); + logger?.LogInformation("Verificação do banco Providers bem-sucedida - Contagem: {ProvidersCount}", providersCount); } catch (Exception ex) { - logger?.LogError(ex, "Providers database verification failed - attempting emergency table creation"); - - // Emergency table creation as last resort - try - { - await CreateProvidersTableManually(providersContext, logger); - - // Retry verification after manual creation - var providersCount = await providersContext.Providers.CountAsync(); - logger?.LogInformation("Emergency table creation successful - Count: {ProvidersCount}", providersCount); - } - catch (Exception emergencyEx) - { - logger?.LogError(emergencyEx, "Emergency table creation also failed"); - throw new InvalidOperationException("Providers database could not be initialized despite all attempts", emergencyEx); - } + logger?.LogError(ex, "Verificação do banco Providers falhou"); + throw new InvalidOperationException("Banco Providers não foi inicializado corretamente", ex); } } @@ -207,16 +197,16 @@ public async ValueTask DisposeAsync() private static async Task CreateProvidersTableManually(ProvidersDbContext context, ILogger? logger) { - logger?.LogInformation("🔨 Starting manual Providers table creation with clean slate"); + logger?.LogInformation("🔨 Iniciando criação manual das tabelas do Providers com estado limpo"); - // First, drop existing tables to ensure clean slate + // Primeiro, remove tabelas existentes para garantir estado limpo await context.Database.ExecuteSqlRawAsync(@" DROP TABLE IF EXISTS providers.qualification CASCADE; DROP TABLE IF EXISTS providers.document CASCADE; DROP TABLE IF EXISTS providers.providers CASCADE; "); - // Create the main providers table with all necessary columns based on the EF Core model + // Cria a tabela principal providers com todas as colunas necessárias baseadas no modelo EF Core var createProvidersTable = @" CREATE TABLE IF NOT EXISTS providers.providers ( id uuid PRIMARY KEY, @@ -244,7 +234,7 @@ zip_code varchar(20) NOT NULL, country varchar(50) NOT NULL );"; - // Create the documents table (owned entity) + // Cria a tabela de documentos (entidade owned) var createDocumentsTable = @" CREATE TABLE IF NOT EXISTS providers.document ( provider_id uuid NOT NULL, @@ -255,7 +245,7 @@ document_type varchar(20) NOT NULL, FOREIGN KEY (provider_id) REFERENCES providers.providers(id) ON DELETE CASCADE );"; - // Create the qualifications table (owned entity) + // Cria a tabela de qualificações (entidade owned) var createQualificationsTable = @" CREATE TABLE IF NOT EXISTS providers.qualification ( provider_id uuid NOT NULL, @@ -280,20 +270,20 @@ FOREIGN KEY (provider_id) REFERENCES providers.providers(id) ON DELETE CASCADE try { await context.Database.ExecuteSqlRawAsync(createProvidersTable); - logger?.LogInformation("✅ Created providers table manually"); + logger?.LogInformation("✅ Tabela providers criada manualmente"); await context.Database.ExecuteSqlRawAsync(createDocumentsTable); - logger?.LogInformation("✅ Created documents table manually"); + logger?.LogInformation("✅ Tabela documents criada manualmente"); await context.Database.ExecuteSqlRawAsync(createQualificationsTable); - logger?.LogInformation("✅ Created qualifications table manually"); + logger?.LogInformation("✅ Tabela qualifications criada manualmente"); await context.Database.ExecuteSqlRawAsync(createIndices); - logger?.LogInformation("✅ Created indices manually"); + logger?.LogInformation("✅ Índices criados manualmente"); } catch (Exception ex) { - logger?.LogError(ex, "❌ Failed to create tables manually"); + logger?.LogError(ex, "❌ Falha ao criar tabelas manualmente"); throw; } } diff --git a/tests/MeAjudaAi.Integration.Tests/Base/InstanceApiTestBase.cs b/tests/MeAjudaAi.Integration.Tests/Base/InstanceApiTestBase.cs index c5a97a82e..543d8e013 100644 --- a/tests/MeAjudaAi.Integration.Tests/Base/InstanceApiTestBase.cs +++ b/tests/MeAjudaAi.Integration.Tests/Base/InstanceApiTestBase.cs @@ -67,6 +67,8 @@ public async ValueTask InitializeAsync() npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "users"); }); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); services.AddDbContext(options => @@ -77,6 +79,8 @@ public async ValueTask InitializeAsync() npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "providers"); }); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); // Add instance-based test authentication instead of static @@ -107,68 +111,54 @@ public async ValueTask InitializeAsync() // Get the authentication configuration instance from the DI container AuthConfig = _factory.Services.GetRequiredService(); - // Ensure database schema using EnsureCreatedAsync for testing - // Note: UsersDbContext has pending model changes that would require new migrations + // Apply database migrations for testing + // Note: Both modules use migration-based setup for consistency with production using var scope = _factory.Services.CreateScope(); var usersContext = scope.ServiceProvider.GetRequiredService(); var providersContext = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetService>(); - // Create schemas first + // Apply migrations exactly like E2E tests + await ApplyMigrationsAsync(usersContext, providersContext, logger); + } + + private static async Task ApplyMigrationsAsync(UsersDbContext usersContext, ProvidersDbContext providersContext, ILogger? logger) + { + // Ensure clean database state (like E2E tests) try { - await providersContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS providers;"); - await usersContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS users;"); - logger?.LogInformation("Database schemas created successfully"); + await usersContext.Database.EnsureDeletedAsync(); + logger?.LogInformation("🧹 Cleaned existing database"); } catch (Exception ex) { - logger?.LogWarning(ex, "Failed to create schemas, they may already exist"); + logger?.LogWarning(ex, "Failed to clean existing database, it may not exist"); } - // For UsersDbContext, use EnsureCreatedAsync (works fine for users) + // Apply migrations on UsersDbContext first (creates database and users schema) try { - await usersContext.Database.EnsureCreatedAsync(); - logger?.LogInformation("Users database schema created successfully"); + logger?.LogInformation("🔄 Applying Users migrations..."); + await usersContext.Database.MigrateAsync(); + logger?.LogInformation("✅ Users database migrations completed successfully"); } catch (Exception ex) { - logger?.LogError(ex, "Failed to create Users database schema"); - throw; + logger?.LogError(ex, "❌ Failed to apply Users migrations: {Message}", ex.Message); + throw new InvalidOperationException("Unable to apply Users database migrations", ex); } - // For ProvidersDbContext, use migrations for proper table structure + // Apply migrations on ProvidersDbContext (database exists, only need providers schema) try { - logger?.LogInformation("🔄 Running Providers migrations..."); + logger?.LogInformation("🔄 Applying Providers migrations..."); await providersContext.Database.MigrateAsync(); logger?.LogInformation("✅ Providers database migrations completed successfully"); } catch (Exception ex) { - logger?.LogWarning(ex, "⚠️ Migrations failed for Providers, trying EnsureCreatedAsync"); - - try - { - await providersContext.Database.EnsureCreatedAsync(); - logger?.LogInformation("✅ Providers database schema created with EnsureCreatedAsync"); - } - catch (Exception ensureEx) - { - logger?.LogWarning(ensureEx, "⚠️ EnsureCreatedAsync also failed, falling back to manual creation"); - - try - { - await CreateProvidersTableManually(providersContext, logger); - logger?.LogInformation("✅ Providers database schema created using manual table creation"); - } - catch (Exception manualEx) - { - logger?.LogError(manualEx, "❌ All Providers table creation methods failed"); - throw new InvalidOperationException("Unable to initialize Providers database schema", manualEx); - } - } + logger?.LogError(ex, "❌ Failed to apply Providers migrations: {Message}", ex.Message); + throw new InvalidOperationException("Unable to apply Providers database migrations", ex); } // Verify tables exist @@ -190,22 +180,8 @@ public async ValueTask InitializeAsync() } catch (Exception ex) { - logger?.LogError(ex, "Providers database verification failed - attempting emergency table creation"); - - // Emergency table creation as last resort - try - { - await CreateProvidersTableManually(providersContext, logger); - - // Retry verification after manual creation - var providersCount = await providersContext.Providers.CountAsync(); - logger?.LogInformation("Emergency table creation successful - Count: {ProvidersCount}", providersCount); - } - catch (Exception emergencyEx) - { - logger?.LogError(emergencyEx, "Emergency table creation also failed"); - throw new InvalidOperationException("Providers database could not be initialized despite all attempts", emergencyEx); - } + logger?.LogError(ex, "Providers database verification failed"); + throw new InvalidOperationException("Providers database is not properly initialized", ex); } } diff --git a/tests/MeAjudaAi.Integration.Tests/Messaging/MessageBusSelectionTests.cs b/tests/MeAjudaAi.Integration.Tests/Messaging/MessageBusSelectionTests.cs index 325154bda..ff6db1dfc 100644 --- a/tests/MeAjudaAi.Integration.Tests/Messaging/MessageBusSelectionTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Messaging/MessageBusSelectionTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; using MeAjudaAi.Integration.Tests.Infrastructure; using MeAjudaAi.Shared.Messaging; using MeAjudaAi.Shared.Messaging.Factory; @@ -16,7 +17,7 @@ namespace MeAjudaAi.Integration.Tests.Messaging; /// Testes para verificar se o MessageBus correto é selecionado baseado no ambiente /// [Collection("Integration Tests Collection")] -public class MessageBusSelectionTests : Base.ApiTestBase +public class MessageBusSelectionTests : ApiTestBase { [Fact] public void MessageBusFactory_InTestingEnvironment_ShouldReturnMock() diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ImplementedFeaturesTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ImplementedFeaturesTests.cs index 511db86e2..ba222bef0 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ImplementedFeaturesTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ImplementedFeaturesTests.cs @@ -39,7 +39,7 @@ public async Task ProvidersEndpoint_ShouldBeAccessible() public async Task ProvidersEndpoint_WithAuthentication_ShouldReturnValidResponse() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers"); @@ -82,7 +82,7 @@ public async Task ProvidersEndpoint_WithAuthentication_ShouldReturnValidResponse public async Task ProvidersEndpoint_ShouldSupportPagination() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers?page=1&pageSize=5"); @@ -95,7 +95,7 @@ public async Task ProvidersEndpoint_ShouldSupportPagination() public async Task ProvidersEndpoint_ShouldSupportFilters() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers?name=test&type=1&verificationStatus=1"); @@ -108,7 +108,7 @@ public async Task ProvidersEndpoint_ShouldSupportFilters() public async Task GetProviderById_Endpoint_ShouldExist() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); var testId = Guid.NewGuid(); // Act @@ -123,7 +123,7 @@ public async Task GetProviderById_Endpoint_ShouldExist() public async Task CreateProvider_Endpoint_ShouldExist() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); var providerData = new { userId = Guid.NewGuid(), diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersApiTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersApiTests.cs index 335198b05..88ee71176 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersApiTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersApiTests.cs @@ -40,7 +40,7 @@ public async Task ProvidersEndpoint_ShouldBeAccessible() public async Task ProvidersEndpoint_WithAuthentication_ShouldReturnValidResponse() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers"); @@ -87,7 +87,7 @@ public async Task ProvidersEndpoint_WithAuthentication_ShouldReturnValidResponse public async Task ProvidersEndpoint_ShouldSupportPagination() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers?page=1&pageSize=5"); @@ -103,7 +103,7 @@ public async Task ProvidersEndpoint_ShouldSupportPagination() public async Task ProvidersEndpoint_ShouldSupportFilters() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); // Act var response = await Client.GetAsync("/api/v1/providers?name=test&type=1&verificationStatus=1"); @@ -141,7 +141,7 @@ public async Task GetProviderById_Endpoint_ShouldExist() public async Task CreateProvider_Endpoint_ShouldExist() { // Arrange - ConfigurableTestAuthenticationHandler.ConfigureAdmin(); + AuthConfig.ConfigureAdmin(); var providerData = new { userId = Guid.NewGuid(), diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersIntegrationTests.cs index abd00f7b6..0dd353979 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Providers/ProvidersIntegrationTests.cs @@ -1,10 +1,10 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; using FluentAssertions; using MeAjudaAi.Integration.Tests.Base; using MeAjudaAi.Shared.Commands; using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; namespace MeAjudaAi.Integration.Tests.Modules.Providers; @@ -17,7 +17,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Providers; /// - Soft Delete de prestadores /// - Gerenciamento de documentos e qualificações /// -public class ProvidersIntegrationTests(ITestOutputHelper testOutput) : InstanceApiTestBase +public class ProvidersIntegrationTests(ITestOutputHelper testOutput) : ApiTestBase { [Fact] public async Task CreateProvider_WithValidData_ShouldReturnCreated() diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersApiTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersApiTests.cs index 350b7969f..9bb5b831a 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersApiTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersApiTests.cs @@ -18,7 +18,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Users; /// - Autorização está funcionando /// - Dados são persistidos corretamente /// -public class UsersApiTests : InstanceApiTestBase +public class UsersApiTests : ApiTestBase { [Fact] public async Task UsersEndpoint_ShouldBeAccessible() diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersIntegrationTests.cs index 663af6837..5a9785680 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersIntegrationTests.cs @@ -17,7 +17,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Users; /// - Soft Delete de usuários /// - Gerenciamento via Keycloak /// -public class UsersIntegrationTests(ITestOutputHelper testOutput) : InstanceApiTestBase +public class UsersIntegrationTests(ITestOutputHelper testOutput) : ApiTestBase { [Fact] public async Task CreateUser_WithValidData_ShouldReturnCreated() diff --git a/tests/MeAjudaAi.Integration.Tests/RegressionTests.cs b/tests/MeAjudaAi.Integration.Tests/RegressionTests.cs index 9fe511aea..07b7ea1af 100644 --- a/tests/MeAjudaAi.Integration.Tests/RegressionTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/RegressionTests.cs @@ -5,7 +5,7 @@ namespace MeAjudaAi.Integration.Tests; -public class RegressionTests : InstanceApiTestBase +public class RegressionTests : ApiTestBase { private readonly ITestOutputHelper _output; diff --git a/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestInfrastructureOptions.cs b/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestInfrastructureOptions.cs index 176abc7cb..16229db8b 100644 --- a/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestInfrastructureOptions.cs +++ b/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestInfrastructureOptions.cs @@ -52,6 +52,11 @@ public class TestDatabaseOptions /// Se deve aplicar migrations automaticamente /// public bool AutoMigrate { get; set; } = true; + + /// + /// Se deve usar InMemory database ao invés de PostgreSQL + /// + public bool UseInMemoryDatabase { get; set; } = false; } public class TestCacheOptions