From 0a1f6a39ee52ba138cc354e0514e68e73aab62ce Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Fri, 13 Mar 2026 16:53:57 -0300 Subject: [PATCH 01/23] feat: implement Providers and Search Providers modules, including entities, DTOs, mappers, repositories, event handlers, APIs, and comprehensive tests. --- prompts/design-react-project.md | 262 ++++++++++++++++++ .../Providers/DTOs/ModuleProviderBasicDto.cs | 1 + .../Providers/DTOs/ModuleProviderDto.cs | 1 + .../DTOs/ModuleProviderIndexingDto.cs | 1 + .../Providers/Application/DTOs/ProviderDto.cs | 1 + .../Application/DTOs/PublicProviderDto.cs | 1 + .../GetPublicProviderByIdQueryHandler.cs | 1 + .../Application/Mappers/ProviderMapper.cs | 9 +- .../ModuleApi/ProvidersModuleApi.cs | 3 + .../Providers/Domain/Entities/Provider.cs | 20 +- .../ProviderProfileUpdatedDomainEvent.cs | 2 + .../Events/ProviderRegisteredDomainEvent.cs | 4 +- .../Repositories/IProviderRepository.cs | 8 + .../Configurations/ProviderConfiguration.cs | 9 + .../Repositories/ProviderRepository.cs | 9 + .../GetMyProviderProfileEndpointTests.cs | 19 +- .../GetMyProviderStatusEndpointTests.cs | 19 +- .../UpdateMyProviderProfileEndpointTests.cs | 19 +- .../UploadMyDocumentEndpointTests.cs | 38 ++- .../Services/ProvidersModuleApiTests.cs | 1 + .../Unit/Domain/Entities/ProviderTests.cs | 3 + ...erProfileUpdatedDomainEventHandlerTests.cs | 2 + ...oviderRegisteredDomainEventHandlerTests.cs | 9 +- .../Application/DTOs/SearchableProviderDto.cs | 1 + .../Handlers/SearchProvidersQueryHandler.cs | 1 + .../ModuleApi/SearchProvidersModuleApi.cs | 3 +- .../Domain/Entities/SearchableProvider.cs | 22 +- .../SearchableProviderConfiguration.cs | 5 + .../DTOs/ProviderSearchResultDto.cs | 1 + .../SearchableProviderRepository.cs | 2 + .../SearchProvidersIntegrationTestBase.cs | 30 +- .../SearchProvidersQueryHandlerTests.cs | 25 +- .../SearchProvidersModuleApiTests.cs | 57 ++-- .../Entities/SearchableProviderTests.cs | 12 +- .../Unit/Domain/Models/SearchResultTests.cs | 16 +- .../SearchableProviderRepositoryTests.cs | 34 ++- src/Shared/Utilities/SlugHelper.cs | 66 +++++ .../MeAjudaAi.Web.Admin/packages.lock.json | 6 + .../Unit/Utilities/SlugHelperTests.cs | 28 ++ .../Pages/ProvidersPageTests.cs | 1 + .../packages.lock.json | 6 +- 41 files changed, 657 insertions(+), 101 deletions(-) create mode 100644 src/Shared/Utilities/SlugHelper.cs create mode 100644 tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs diff --git a/prompts/design-react-project.md b/prompts/design-react-project.md index e69de29bb..232907102 100644 --- a/prompts/design-react-project.md +++ b/prompts/design-react-project.md @@ -0,0 +1,262 @@ +# Conversão de Design para Componentes React + +Analise o design anexado (screenshot ou frame do Figma) e converta para componentes React seguindo os padrões abaixo. + +--- + +## Stack + +- **React 19** (sem `forwardRef`) +- **TypeScript** strict +- **Tailwind CSS v4** com `@theme` e CSS variables +- **Base UI React** (`@base-ui/react`) para componentes headless +- **Tailwind Variants** (`tailwind-variants`) para variantes +- **Tailwind Merge** (`tailwind-merge`) para merge de classes +- **Lucide React** ou **Phosphor Icons** para ícones + +--- + +## Nomenclatura + +- Arquivos: **lowercase com hífens** → `user-card.tsx`, `use-modal.ts` +- **Sempre named exports**, nunca default export +- Não criar barrel files (`index.ts`) para pastas internas + +--- + +## Estrutura de Componente + +```tsx +import { tv, type VariantProps } from "tailwind-variants"; +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export const buttonVariants = tv({ + base: [ + "inline-flex cursor-pointer items-center justify-center font-medium rounded-lg border transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + ], + variants: { + variant: { + primary: + "border-primary bg-primary text-primary-foreground hover:bg-primary-hover", + secondary: + "border-border bg-secondary text-secondary-foreground hover:bg-muted", + ghost: + "border-transparent bg-transparent text-muted-foreground hover:text-foreground", + destructive: + "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90", + }, + size: { + sm: "h-6 px-2 gap-1.5 text-xs [&_svg]:size-3", + md: "h-7 px-3 gap-2 text-sm [&_svg]:size-3.5", + lg: "h-9 px-4 gap-2.5 text-base [&_svg]:size-4", + }, + }, + defaultVariants: { variant: "primary", size: "md" }, +}); + +export interface ButtonProps + extends ComponentProps<"button">, VariantProps {} + +export function Button({ + className, + variant, + size, + disabled, + children, + ...props +}: ButtonProps) { + return ( + + ); +} +``` + +--- + +## Compound Components + +```tsx +import { twMerge } from "tailwind-merge"; +import type { ComponentProps } from "react"; + +export interface CardProps extends ComponentProps<"div"> {} + +export function Card({ className, ...props }: CardProps) { + return ( +
+ ); +} + +export function CardHeader({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +export function CardTitle({ className, ...props }: ComponentProps<"h3">) { + return ( +

+ ); +} + +export function CardContent({ className, ...props }: ComponentProps<"div">) { + return
; +} +``` + +--- + +## Cores (CSS Variables) + +``` +bg-surface, bg-surface-raised → fundos +bg-primary, bg-secondary, bg-muted → ações/estados +bg-destructive → erros/danger + +text-foreground → texto principal +text-foreground-subtle → texto secundário +text-muted-foreground → texto desabilitado +text-primary-foreground → texto em bg primary + +border-border, border-input → bordas padrão +border-primary, border-destructive → bordas de destaque + +ring-ring → focus ring +``` + +--- + +## TypeScript + +```tsx +// ✅ Estender ComponentProps + VariantProps +export interface ButtonProps + extends ComponentProps<"button">, VariantProps {} + +// ✅ Import type para tipos +import type { ComponentProps } from "react"; +import type { VariantProps } from "tailwind-variants"; + +// ❌ Não usar React.FC nem any +``` + +--- + +## Padrões Importantes + +```tsx +// Sempre usar twMerge +className={twMerge('classes-base', className)} + +// Sempre usar data-slot +
+ +// Estados com data-attributes +data-disabled={disabled ? '' : undefined} +className="data-[disabled]:opacity-50 data-[selected]:bg-primary" + +// Focus visible +'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + +// Ícones com tamanho + +'[&_svg]:size-3.5' // em variantes + +// Botões de ícone precisam de aria-label + + +// Props spread no final +{...props} +``` + +--- + +## Base UI (componentes headless) + +```tsx +// Dialog +import * as Dialog from "@base-ui/react/dialog"; + + + + + +; + +// Tabs +import * as Tabs from "@base-ui/react/tabs"; + + + + + +; + +// Select +import * as Select from "@base-ui/react/select"; + + + + + + + +; + +// Menu +import * as Menu from "@base-ui/react/menu"; + + + + + + + +; +``` + +--- + +## Checklist + +- [ ] Arquivo lowercase com hífens +- [ ] Named export +- [ ] `ComponentProps<'elemento'>` + `VariantProps` +- [ ] Variantes com `tv()`, classes com `twMerge()` +- [ ] `data-slot` para identificação +- [ ] Estados via `data-[state]:` +- [ ] Cores do tema (não hardcoded) +- [ ] Focus visible em interativos +- [ ] `aria-label` em botões de ícone +- [ ] `{...props}` no final + +--- + +Agora analise o design anexado e gere o código do componente. diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs index 2ca58bfe4..94055259f 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderBasicDto.cs @@ -6,6 +6,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderBasicDto( Guid Id, string Name, + string Slug, string Email, string ProviderType, string VerificationStatus, diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs index 29e36eb37..0ef4636ed 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderDto.cs @@ -8,6 +8,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderDto( Guid Id, string Name, + string Slug, string Email, string Document, [property: JsonPropertyName("type")] diff --git a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs index 4b257ed9d..38b5b1106 100644 --- a/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs +++ b/src/Contracts/Contracts/Modules/Providers/DTOs/ModuleProviderIndexingDto.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Contracts.Modules.Providers.DTOs; public sealed record ModuleProviderIndexingDto( Guid ProviderId, string Name, + string Slug, double Latitude, double Longitude, IReadOnlyCollection ServiceIds, diff --git a/src/Modules/Providers/Application/DTOs/ProviderDto.cs b/src/Modules/Providers/Application/DTOs/ProviderDto.cs index 1699b56af..340d61322 100644 --- a/src/Modules/Providers/Application/DTOs/ProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/ProviderDto.cs @@ -9,6 +9,7 @@ public sealed record ProviderDto( Guid Id, Guid UserId, string Name, + string Slug, EProviderType Type, BusinessProfileDto BusinessProfile, EProviderStatus Status, diff --git a/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs b/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs index 558f99d24..b3a3390de 100644 --- a/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/PublicProviderDto.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Modules.Providers.Application.DTOs; public sealed record PublicProviderDto( Guid Id, string Name, + string Slug, EProviderType Type, string? FantasyName, string? Description, diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs index 043809b11..9b7414f37 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs @@ -68,6 +68,7 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, var dto = new PublicProviderDto( provider.Id, provider.Name, + provider.Slug, provider.Type, businessProfile.FantasyName, businessProfile.Description, diff --git a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs index 797e312ac..380ce0f9f 100644 --- a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs +++ b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs @@ -15,10 +15,11 @@ public static class ProviderMapper public static ProviderDto ToDto(this Provider provider) { return new ProviderDto( - provider.Id.Value, - provider.UserId, - provider.Name, - provider.Type, + Id: provider.Id.Value, + UserId: provider.UserId, + Name: provider.Name, + Slug: provider.Slug, + Type: provider.Type, provider.BusinessProfile.ToDto(), provider.Status, provider.VerificationStatus, diff --git a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs index 6ddf188fe..d7a513fb1 100644 --- a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs +++ b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs @@ -334,6 +334,7 @@ public async Task>> GetProvidersByV var indexingDto = new ModuleProviderIndexingDto( ProviderId: providerEntity.Id.Value, Name: providerEntity.Name, + Slug: providerEntity.Slug, Latitude: coordinates.Latitude, Longitude: coordinates.Longitude, ServiceIds: serviceIds, @@ -360,6 +361,7 @@ private static ModuleProviderDto MapToModuleDto(ProviderDto providerDto) return new ModuleProviderDto( Id: providerDto.Id, Name: providerDto.Name, + Slug: providerDto.Slug, Email: providerDto.BusinessProfile?.ContactInfo?.Email ?? string.Empty, Document: GetMainDocument(providerDto)?.Number ?? string.Empty, ProviderType: providerDto.Type.ToString(), @@ -378,6 +380,7 @@ private static ModuleProviderBasicDto MapToModuleBasicDto(ProviderDto providerDt return new ModuleProviderBasicDto( Id: providerDto.Id, Name: providerDto.Name, + Slug: providerDto.Slug, Email: providerDto.BusinessProfile?.ContactInfo?.Email ?? string.Empty, ProviderType: providerDto.Type.ToString(), VerificationStatus: providerDto.VerificationStatus.ToString(), diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index d40b3bf6a..76a1cbe40 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -34,6 +34,14 @@ public sealed class Provider : AggregateRoot /// public string Name { get; private set; } = string.Empty; + /// + /// Slug amigável para URL. + /// + /// + /// Gerado automaticamente a partir do nome. Usado para SEO e URLs públicas. + /// + public string Slug { get; private set; } = string.Empty; + /// /// Tipo do prestador de serviços (Individual ou Company). /// @@ -129,6 +137,7 @@ public Provider( UserId = userId; Name = name.Trim(); + Slug = SlugHelper.Generate(Name); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -163,6 +172,7 @@ public Provider( UserId = userId; Name = name.Trim(); + Slug = SlugHelper.Generate(Name); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -175,7 +185,8 @@ public Provider( UserId, Name, Type, - BusinessProfile.ContactInfo.Email)); + BusinessProfile.ContactInfo.Email, + Slug)); } /// @@ -199,7 +210,12 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? var newName = name.Trim(); if (Name != newName) + { updatedFields.Add("Name"); + Name = newName; + Slug = SlugHelper.Generate(Name); + updatedFields.Add("Slug"); + } if (!BusinessProfile.ContactInfo.Email.Equals(businessProfile.ContactInfo.Email, StringComparison.OrdinalIgnoreCase)) updatedFields.Add("Email"); @@ -213,7 +229,6 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? if (BusinessProfile.Description != businessProfile.Description) updatedFields.Add("Description"); - Name = newName; BusinessProfile = businessProfile; MarkAsUpdated(); @@ -222,6 +237,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? 1, Name, BusinessProfile.ContactInfo.Email, + Slug, updatedBy, updatedFields.ToArray())); } diff --git a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs index 52c636930..9eeb5e587 100644 --- a/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs +++ b/src/Modules/Providers/Domain/Events/ProviderProfileUpdatedDomainEvent.cs @@ -9,6 +9,7 @@ namespace MeAjudaAi.Modules.Providers.Domain.Events; /// Versão do agregado no momento do evento /// Novo nome do prestador de serviços /// Novo email de contato +/// Novo slug amigável para URL /// Quem fez a atualização /// Lista dos campos que foram atualizados public record ProviderProfileUpdatedDomainEvent( @@ -16,6 +17,7 @@ public record ProviderProfileUpdatedDomainEvent( int Version, string Name, string Email, + string Slug, string? UpdatedBy, string[] UpdatedFields ) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs index e81ac8e68..04abed8ca 100644 --- a/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs +++ b/src/Modules/Providers/Domain/Events/ProviderRegisteredDomainEvent.cs @@ -17,11 +17,13 @@ namespace MeAjudaAi.Modules.Providers.Domain.Events; /// Nome do prestador de serviços /// Tipo do prestador de serviços /// Email de contato do prestador de serviços +/// Slug amigável para URL public record ProviderRegisteredDomainEvent( Guid AggregateId, int Version, Guid UserId, string Name, EProviderType Type, - string Email + string Email, + string Slug ) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs b/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs index 746fc469e..8cb8a118b 100644 --- a/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs +++ b/src/Modules/Providers/Domain/Repositories/IProviderRepository.cs @@ -21,6 +21,14 @@ public interface IProviderRepository /// O prestador encontrado ou null se não existir Task GetByIdAsync(ProviderId id, CancellationToken cancellationToken = default); + /// + /// Busca um prestador de serviços pelo seu slug amigável. + /// + /// Slug amigável do prestador + /// Token de cancelamento da operação + /// O prestador encontrado ou null se não existir + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + /// /// Busca múltiplos prestadores de serviços pelos seus identificadores únicos. /// diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index a24e92fe9..2d5c2450a 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -30,6 +30,11 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasColumnName("name"); + builder.Property(p => p.Slug) + .HasMaxLength(120) + .IsRequired() + .HasColumnName("slug"); + builder.Property(p => p.Type) .HasConversion( type => type.ToString(), @@ -242,6 +247,10 @@ public void Configure(EntityTypeBuilder builder) .IsUnique() .HasDatabaseName("ix_providers_user_id"); + builder.HasIndex(p => p.Slug) + .IsUnique() + .HasDatabaseName("ix_providers_slug"); + builder.HasIndex(p => p.Name) .HasDatabaseName("ix_providers_name"); diff --git a/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs b/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs index d5489512c..2b7917d4a 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Repositories/ProviderRepository.cs @@ -46,6 +46,15 @@ public async Task AddAsync(Provider provider, CancellationToken cancellationToke .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, cancellationToken); } + /// + /// Busca um prestador de serviços pelo seu slug amigável. + /// + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return await GetProvidersQuery() + .FirstOrDefaultAsync(p => p.Slug == slug && !p.IsDeleted, cancellationToken); + } + /// /// Busca múltiplos prestadores de serviços pelos seus identificadores únicos. /// diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs index 7a6d5540a..9643d8e20 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs @@ -42,9 +42,22 @@ public async Task GetMyProfileAsync_WithValidUserId_ShouldDispatchQuery() var userId = Guid.NewGuid(); var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); var providerDto = new ProviderDto( - Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: Guid.NewGuid(), + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); var dispatchResult = Result.Success(providerDto); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs index 59ae6b54e..b6afc358f 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs @@ -44,9 +44,22 @@ public async Task GetMyStatusAsync_WithValidUserId_ShouldReturnStatus() var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); var providerDto = new ProviderDto( - Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Gold, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: Guid.NewGuid(), + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Gold, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); var dispatchResult = Result.Success(providerDto); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs index 2d307563e..3ca39badb 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs @@ -48,9 +48,22 @@ public async Task UpdateMyProfileAsync_WithValidRequest_ShouldDispatchUpdateComm // Setup Query to return ProviderId var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.Active, + VerificationStatus: EVerificationStatus.Verified, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); _queryDispatcherMock .Setup(x => x.QueryAsync>( diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs index bc2909e0e..bc77f38e3 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs @@ -54,9 +54,22 @@ public async Task UploadDocumentAsync_WithValidRequest_ShouldUploadAndReturnOk() var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.PendingBasicInfo, + VerificationStatus: EVerificationStatus.Pending, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); // Mock Query (Get provider by user id) _queryDispatcherMock @@ -128,9 +141,22 @@ public async Task UploadDocumentAsync_WhenCommandFails_ShouldReturnBadRequest() var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); var providerDto = new ProviderDto( - providerId, userId, "Test", EProviderType.Individual, null!, - EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, - new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + Id: providerId, + UserId: userId, + Name: "Test", + Slug: "test", + Type: EProviderType.Individual, + BusinessProfile: null!, + Status: EProviderStatus.PendingBasicInfo, + VerificationStatus: EVerificationStatus.Pending, + Tier: EProviderTier.Standard, + Documents: new List(), + Qualifications: new List(), + Services: new List(), + CreatedAt: DateTime.UtcNow, + UpdatedAt: null, + IsDeleted: false, + DeletedAt: null); _queryDispatcherMock.Setup(x => x.QueryAsync>( It.IsAny(), It.IsAny())) diff --git a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs index b86798442..70ebef4cf 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs @@ -217,6 +217,7 @@ private static ProviderDto CreateTestProviderDto(Guid id) Id: id, UserId: Guid.NewGuid(), Name: "Test Provider", + Slug: "test-provider", Type: EProviderType.Individual, BusinessProfile: new BusinessProfileDto( LegalName: "Test Provider Legal Name", diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index 1ccf3bb61..bb3b6f535 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -65,6 +65,7 @@ public void Constructor_WithValidParameters_ShouldCreateProvider() provider.Type.Should().Be(type); provider.BusinessProfile.Should().Be(businessProfile); provider.VerificationStatus.Should().Be(EVerificationStatus.Pending); + provider.Slug.Should().Be("john-provider"); provider.IsDeleted.Should().BeFalse(); provider.DeletedAt.Should().BeNull(); provider.Documents.Should().BeEmpty(); @@ -95,6 +96,7 @@ public void Constructor_ShouldRaiseProviderRegisteredDomainEvent() registeredEvent.Name.Should().Be(name); registeredEvent.Type.Should().Be(type); registeredEvent.Email.Should().Be(businessProfile.ContactInfo.Email); + registeredEvent.Slug.Should().Be("john-provider"); } [Theory] @@ -166,6 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); + profileUpdatedEvent.Slug.Should().Be("updated-provider-name"); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs index e7937258a..7aa7d7f71 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs @@ -60,6 +60,7 @@ public async Task HandleAsync_ShouldPublishIntegrationEvent() 1, "Updated Name", "updated@test.com", + "updated-name", null, new[] { "Name", "Email" } ); @@ -98,6 +99,7 @@ public async Task HandleAsync_WhenMessageBusFails_ShouldThrowException() 1, "Updated Name", "updated@test.com", + "updated-name", null, new[] { "Name", "Email" } ); diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs index 237b2ebe2..7ba1f642d 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs @@ -67,7 +67,8 @@ public async Task HandleAsync_WithValidEvent_ShouldPublishIntegrationEvent() userId, "Provider Test", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "provider-test" ); // Act @@ -92,7 +93,8 @@ public async Task HandleAsync_WithMissingProvider_ShouldNotPublishEvent() UuidGenerator.NewId(), "Nonexistent Provider", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "nonexistent-provider" ); // Act @@ -118,7 +120,8 @@ public async Task HandleAsync_WhenCancelled_ShouldPropagateCancellation() UuidGenerator.NewId(), "Provider Test", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "provider-test" ); using var cts = new CancellationTokenSource(); diff --git a/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs b/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs index 7f399f6d0..ae6297ece 100644 --- a/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs +++ b/src/Modules/SearchProviders/Application/DTOs/SearchableProviderDto.cs @@ -13,6 +13,7 @@ public sealed record SearchableProviderDto( int TotalReviews, ESubscriptionTier SubscriptionTier, IReadOnlyList ServiceIds, + string Slug, string? Description = null, double? DistanceInKm = null, string? City = null, diff --git a/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs b/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs index 9cab8cb2f..d4f319e41 100644 --- a/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs +++ b/src/Modules/SearchProviders/Application/Handlers/SearchProvidersQueryHandler.cs @@ -87,6 +87,7 @@ public async Task>> HandleAsync( TotalReviews: p.TotalReviews, SubscriptionTier: p.SubscriptionTier, ServiceIds: p.ServiceIds, + Slug: p.Slug, Description: p.Description, DistanceInKm: searchResult.DistancesInKm[index], City: p.City, diff --git a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs index 8b7d6dbd2..32cbb54c6 100644 --- a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs +++ b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs @@ -165,7 +165,7 @@ public async Task IndexProviderAsync(Guid providerId, CancellationToken // Atualizar informações do provider existente var location = new GeoPoint(providerData.Latitude, providerData.Longitude); - existing.UpdateBasicInfo(providerData.Name, providerData.Description, providerData.City, providerData.State); + existing.UpdateBasicInfo(providerData.Name, providerData.Slug, providerData.Description, providerData.City, providerData.State); existing.UpdateLocation(location); existing.UpdateRating(providerData.AverageRating, providerData.TotalReviews); existing.UpdateSubscriptionTier(ToDomainTier(providerData.SubscriptionTier)); @@ -187,6 +187,7 @@ public async Task IndexProviderAsync(Guid providerId, CancellationToken var searchableProvider = SearchableProvider.Create( providerId: providerData.ProviderId, name: providerData.Name, + slug: providerData.Slug, location: location, subscriptionTier: ToDomainTier(providerData.SubscriptionTier), description: providerData.Description, diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 26d67c805..3a73986c1 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -21,6 +21,11 @@ public sealed class SearchableProvider : AggregateRoot /// public string Name { get; private set; } = string.Empty; + /// + /// Slug amigável para URL do provedor. + /// + public string Slug { get; private set; } = string.Empty; + /// /// Localização geográfica do provedor. /// @@ -95,6 +100,7 @@ private SearchableProvider( public static SearchableProvider Create( Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier = ESubscriptionTier.Free, string? description = null, @@ -106,6 +112,11 @@ public static SearchableProvider Create( throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + } + ArgumentNullException.ThrowIfNull(location); var searchableProvider = new SearchableProvider( @@ -115,6 +126,7 @@ public static SearchableProvider Create( location, subscriptionTier) { + Slug = slug.Trim().ToLowerInvariant(), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -136,6 +148,7 @@ internal static SearchableProvider Reconstitute( Guid id, Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier, decimal averageRating, @@ -153,6 +166,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { + Slug = slug, Description = description, City = city, State = state, @@ -168,14 +182,20 @@ internal static SearchableProvider Reconstitute( /// /// Atualiza as informações básicas do provedor. /// - public void UpdateBasicInfo(string name, string? description, string? city, string? state) + public void UpdateBasicInfo(string name, string slug, string? description, string? city, string? state) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + } + Name = name.Trim(); + Slug = slug.Trim().ToLowerInvariant(); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs index 04120a7de..e1d6eaff7 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs @@ -41,6 +41,11 @@ public void Configure(EntityTypeBuilder builder) .HasMaxLength(200) .HasColumnName("name"); + builder.Property(p => p.Slug) + .IsRequired() + .HasMaxLength(200) + .HasColumnName("slug"); + builder.Property(p => p.Description) .HasMaxLength(1000) .HasColumnName("description"); diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs index 500f444ee..36639c909 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/DTOs/ProviderSearchResultDto.cs @@ -9,6 +9,7 @@ internal sealed class ProviderSearchResultDto public Guid Id { get; set; } public Guid ProviderId { get; set; } public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; public string? Description { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs index ac1a3aa72..5f8247378 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs @@ -95,6 +95,7 @@ public async Task SearchAsync( id AS Id, provider_id AS ProviderId, name AS Name, + slug AS Slug, description AS Description, ST_Y(location::geometry) AS Latitude, ST_X(location::geometry) AS Longitude, @@ -226,6 +227,7 @@ private static SearchableProvider MapToEntity(ProviderSearchResultDto dto) id: dto.Id, providerId: dto.ProviderId, name: dto.Name, + slug: dto.Slug, location: new GeoPoint(dto.Latitude, dto.Longitude), subscriptionTier: (ESubscriptionTier)dto.SubscriptionTier, averageRating: dto.AverageRating, diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 414d42811..43aafce06 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -162,13 +162,14 @@ protected SearchableProvider CreateTestSearchableProvider( var location = new GeoPoint(latitude, longitude); var provider = SearchableProvider.Create( - providerId, - name, - location, - tier, - description, - city, - state); + providerId: providerId, + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } @@ -189,13 +190,14 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var location = new GeoPoint(latitude, longitude); var provider = SearchableProvider.Create( - providerId, - name, - location, - tier, - description, - city, - state); + providerId: providerId, + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs index 2faf1494c..658335b31 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs @@ -355,10 +355,11 @@ public async Task HandleAsync_ShouldMapDistanceCorrectly() RadiusInKm: 500); var provider = SearchableProvider.Create( - Guid.NewGuid(), - "Test Provider", - new GeoPoint(-22.9068, -43.1729), // Rio de Janeiro - ESubscriptionTier.Free); + providerId: Guid.NewGuid(), + name: "Test Provider", + slug: "test-provider", + location: new GeoPoint(-22.9068, -43.1729), // Rio de Janeiro + subscriptionTier: ESubscriptionTier.Free); var distance = provider.CalculateDistanceToInKm(searchLocation); _repositoryMock @@ -392,14 +393,16 @@ private static List CreateTestProviders(int count) var providers = new List(); for (int i = 0; i < count; i++) { + var name = $"Provider {i + 1}"; providers.Add(SearchableProvider.Create( - Guid.NewGuid(), - $"Provider {i + 1}", - new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), - (ESubscriptionTier)(i % 4), - $"Description {i + 1}", - "São Paulo", - "SP")); + providerId: Guid.NewGuid(), + name: name, + slug: name.ToLower().Replace(" ", "-"), + location: new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), + subscriptionTier: (ESubscriptionTier)(i % 4), + description: $"Description {i + 1}", + city: "São Paulo", + state: "SP")); } return providers; } diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs index 8647bfb59..0b85a999b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs @@ -158,6 +158,7 @@ public async Task SearchProvidersAsync_WithValidParameters_ShouldReturnResults() new( ProviderId: providerId, Name: "Provider 1", + Slug: "provider-1", Location: new LocationDto( Latitude: -23.5, Longitude: -46.6), @@ -323,23 +324,25 @@ public async Task IndexProviderAsync_WithNewProvider_ShouldCreateAndIndex() { // Arrange var providerId = Guid.NewGuid(); - var providerData = new ModuleProviderIndexingDto( + var dto = new ModuleProviderIndexingDto( ProviderId: providerId, - Name: "New Provider", - Latitude: -23.561414, - Longitude: -46.656559, - ServiceIds: new[] { Guid.NewGuid() }, + Name: "Test Provider", + Slug: "test-provider", + Latitude: -23.55, + Longitude: -46.63, + ServiceIds: new List { Guid.NewGuid() }, AverageRating: 4.5m, TotalReviews: 10, - SubscriptionTier: ESubscriptionTier.Gold, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Gold, IsActive: true, - Description: "Test description", + Description: "A test provider", City: "São Paulo", - State: "SP"); + State: "SP" + ); _providersApiMock .Setup(x => x.GetProviderForIndexingAsync(providerId, It.IsAny())) - .ReturnsAsync(Result.Success(providerData)); + .ReturnsAsync(Result.Success(dto)); _repositoryMock .Setup(x => x.GetByProviderIdAsync(providerId, It.IsAny())) @@ -360,23 +363,25 @@ public async Task IndexProviderAsync_WithExistingProvider_ShouldUpdate() // Arrange var providerId = Guid.NewGuid(); var existingProvider = SearchableProvider.Create( - providerId, - "Old Name", - new GeoPoint(-23.5, -46.6), - DomainEnums.ESubscriptionTier.Free, - "Old description", - "Old City", - "OC"); + providerId: providerId, + name: "Old Name", + slug: "old-name", + location: new GeoPoint(-23.5, -46.6), + subscriptionTier: DomainEnums.ESubscriptionTier.Free, + description: "Old description", + city: "Old City", + state: "OC"); var updatedData = new ModuleProviderIndexingDto( ProviderId: providerId, Name: "Updated Provider", + Slug: "updated-provider", Latitude: -23.561414, Longitude: -46.656559, ServiceIds: new[] { Guid.NewGuid(), Guid.NewGuid() }, AverageRating: 4.8m, TotalReviews: 25, - SubscriptionTier: ESubscriptionTier.Platinum, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Platinum, IsActive: true, Description: "Updated description", City: "São Paulo", @@ -468,12 +473,13 @@ public async Task IndexProviderAsync_WhenInactiveProvider_ShouldDeactivateInInde var providerData = new ModuleProviderIndexingDto( ProviderId: providerId, Name: "Inactive Provider", + Slug: "inactive-provider", Latitude: -23.5, Longitude: -46.6, ServiceIds: Array.Empty(), AverageRating: 0, TotalReviews: 0, - SubscriptionTier: ESubscriptionTier.Free, + SubscriptionTier: MeAjudaAi.Contracts.Modules.SearchProviders.Enums.ESubscriptionTier.Free, IsActive: false, Description: "Test", City: "São Paulo", @@ -530,13 +536,14 @@ public async Task RemoveProviderAsync_WithExistingProvider_ShouldRemoveFromIndex // Arrange var providerId = Guid.NewGuid(); var existingProvider = SearchableProvider.Create( - providerId, - "Provider to Remove", - new GeoPoint(-23.5, -46.6), - DomainEnums.ESubscriptionTier.Free, - "Test", - "São Paulo", - "SP"); + providerId: providerId, + name: "Provider to Remove", + slug: "provider-to-remove", + location: new GeoPoint(-23.5, -46.6), + subscriptionTier: DomainEnums.ESubscriptionTier.Free, + description: "Test", + city: "São Paulo", + state: "SP"); _repositoryMock .Setup(x => x.GetByProviderIdAsync(providerId, It.IsAny())) diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 5aa843c32..06a6a84ca 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -26,6 +26,7 @@ public void Create_WithValidData_ShouldCreateSearchableProvider() var provider = SearchableProvider.Create( providerId, name, + "test-provider", location, subscriptionTier, "Test description", @@ -55,7 +56,7 @@ public void Create_WithEmptyName_ShouldThrowArgumentException() var location = new GeoPoint(-23.5505, -46.6333); // Act - var act = () => SearchableProvider.Create(providerId, "", location); + var act = () => SearchableProvider.Create(providerId, "", "slug", location); // Assert act.Should().Throw() @@ -70,7 +71,7 @@ public void Create_WithWhitespaceName_ShouldThrowArgumentException() var location = new GeoPoint(-23.5505, -46.6333); // Act - var act = () => SearchableProvider.Create(providerId, " ", location); + var act = () => SearchableProvider.Create(providerId, " ", "slug", location); // Assert act.Should().Throw(); @@ -87,7 +88,7 @@ public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() var newState = "RJ"; // Act - provider.UpdateBasicInfo(newName, newDescription, newCity, newState); + provider.UpdateBasicInfo(newName, "updated-provider", newDescription, newCity, newState); // Assert provider.Name.Should().Be(newName); @@ -104,7 +105,7 @@ public void UpdateBasicInfo_WithEmptyName_ShouldThrowArgumentException() var provider = CreateValidProvider(); // Act - var act = () => provider.UpdateBasicInfo("", "desc", "city", "ST"); + var act = () => provider.UpdateBasicInfo("", "slug", "desc", "city", "ST"); // Assert act.Should().Throw(); @@ -296,7 +297,7 @@ public void CalculateDistanceToInKm_SameLocation_ShouldReturnZero() { // Arrange var location = new GeoPoint(-23.5505, -46.6333); - var provider = SearchableProvider.Create(Guid.NewGuid(), "Test", location); + var provider = SearchableProvider.Create(Guid.NewGuid(), "Test", "test", location); // Act var distance = provider.CalculateDistanceToInKm(location); @@ -310,6 +311,7 @@ private static SearchableProvider CreateValidProvider() return SearchableProvider.Create( Guid.NewGuid(), "Test Provider", + "test-provider", new GeoPoint(-23.5505, -46.6333), // São Paulo ESubscriptionTier.Free, "Test description", diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs index 8cdc54e13..4f5bf7a55 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs @@ -185,14 +185,16 @@ private IReadOnlyList CreateProviders(int count) var providerId = Guid.NewGuid(); var location = new GeoPoint(-23.5505 + i * 0.1, -46.6333 + i * 0.1); + var providerName = _faker.Company.CompanyName(); var provider = SearchableProvider.Create( - providerId, - _faker.Person.FullName, - location, - _faker.Random.Enum(), - _faker.Lorem.Sentence(), - _faker.Address.City(), - _faker.Address.StateAbbr() + providerId: providerId, + name: providerName, + slug: providerName.ToLower().Replace(" ", "-"), + location: location, + subscriptionTier: _faker.Random.Enum(), + description: _faker.Lorem.Sentence(), + city: _faker.Address.City(), + state: _faker.Address.StateAbbr() ); providers.Add(provider); diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs index 5c2fb386a..fcc3d4088 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs @@ -50,14 +50,20 @@ private SearchableProvider CreateTestProvider( longitude ?? _faker.Address.Longitude() ); + var actualProviderId = providerId ?? UuidGenerator.NewId(); + var actualName = name ?? _faker.Company.CompanyName(); + var actualSlug = actualName.ToLower().Replace(" ", "-"); + var actualSubscriptionTier = tier ?? _faker.Random.Enum(); + return SearchableProvider.Create( - providerId ?? UuidGenerator.NewId(), - name ?? _faker.Company.CompanyName(), - location, - tier ?? ESubscriptionTier.Free, - _faker.Lorem.Sentence(), - _faker.Address.City(), - _faker.Address.StateAbbr() + providerId: actualProviderId, + name: actualName, + slug: actualSlug, + location: location, + subscriptionTier: actualSubscriptionTier, + description: _faker.Lorem.Sentence(), + city: _faker.Address.City(), + state: _faker.Address.StateAbbr() ); } @@ -187,7 +193,12 @@ public async Task UpdateAsync_WithValidProvider_ShouldMarkAsModified() _context.Entry(provider).State = EntityState.Detached; // Modify provider - provider.UpdateBasicInfo("Updated Name", "Updated Description", "São Paulo", "SP"); + provider.UpdateBasicInfo( + name: "Updated Name", + slug: "updated-name", + description: "Updated Description", + city: "São Paulo", + state: "SP"); // Act await _repository.UpdateAsync(provider); @@ -207,7 +218,12 @@ public async Task UpdateAsync_WithValidProvider_ShouldPersistChanges() await _context.SaveChangesAsync(); // Modify provider - provider.UpdateBasicInfo("Updated Name", "Updated Description", "Rio de Janeiro", "RJ"); + provider.UpdateBasicInfo( + name: "Updated Name", + slug: "updated-name", + description: "Updated Description", + city: "Rio de Janeiro", + state: "RJ"); // Act await _repository.UpdateAsync(provider); diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs new file mode 100644 index 000000000..8cab9d559 --- /dev/null +++ b/src/Shared/Utilities/SlugHelper.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace MeAjudaAi.Shared.Utilities; + +/// +/// Helper para geração de slugs amigáveis para URL +/// +public static partial class SlugHelper +{ + [GeneratedRegex(@"[^a-z0-9\s-]")] + private static partial Regex NonAlphanumericRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex MultipleWhitespaceRegex(); + + [GeneratedRegex(@"-+")] + private static partial Regex MultipleHifenRegex(); + + /// + /// Gera um slug a partir de um texto + /// + /// Texto original + /// Slug formatado + public static string Generate(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + // 1. Lowercase e Normalização + string slug = text.ToLowerInvariant(); + slug = RemoveDiacritics(slug); + + // 2. Remover caracteres não alfanuméricos (exceto espaços e hifens) + slug = NonAlphanumericRegex().Replace(slug, ""); + + // 3. Substituir espaços por hifens + slug = MultipleWhitespaceRegex().Replace(slug, "-"); + + // 4. Remover hifens duplicados + slug = MultipleHifenRegex().Replace(slug, "-"); + + // 5. Trim hifens das extremidades + return slug.Trim('-'); + } + + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } +} diff --git a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json index e4c44fa1c..1adbc52d3 100644 --- a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json +++ b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json @@ -85,6 +85,12 @@ "resolved": "10.0.5", "contentHash": "tZwWmlI7WWT1KnDA/S5tkqHd2ZXxnYL0VLqbPAmDVppVlIqqQZLP3+OiW+IgcVSsAgi6WMlGcAd3DrhpFHw/uw==" }, + "Microsoft.DotNet.HotReload.WebAssembly.Browser": { + "type": "Direct", + "requested": "[10.0.104, )", + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" + }, "Microsoft.Extensions.Http.Resilience": { "type": "Direct", "requested": "[10.4.0, )", diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs new file mode 100644 index 000000000..d6cfca5cf --- /dev/null +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using MeAjudaAi.Shared.Utilities; + +namespace MeAjudaAi.Shared.Tests.Unit.Utilities; + +public class SlugHelperTests +{ + [Theory] + [InlineData("Clinica Auto Muriae", "clinica-auto-muriae")] + [InlineData("João & Maria Serviços", "joao-maria-servicos")] + [InlineData(" Espaços Extras ", "espacos-extras")] + [InlineData("C# & .NET Developer", "c-net-developer")] + [InlineData("Acentuação: áéíóú àèìòù âêîôû äëïöü ãõ ñ ç", "acentuacao-aeiou-aeiou-aeiou-aeiou-ao-n-c")] + [InlineData("Maçã Verde", "maca-verde")] + [InlineData("UPPERCASE text", "uppercase-text")] + [InlineData("multiple---hifens", "multiple-hifens")] + [InlineData("!@#$%^&*()_+", "")] + [InlineData(null, "")] + [InlineData("", "")] + public void Generate_ShouldReturnExpectedSlug(string input, string expected) + { + // Act + var result = SlugHelper.Generate(input); + + // Assert + result.Should().Be(expected); + } +} diff --git a/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs b/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs index 202a88628..ba80f99e8 100644 --- a/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs +++ b/tests/MeAjudaAi.Web.Admin.Tests/Pages/ProvidersPageTests.cs @@ -60,6 +60,7 @@ public async Task Providers_Page_Should_Display_Providers_In_DataGrid() var testProvider = new ModuleProviderDto( Guid.NewGuid(), "Fornecedor Teste", + "fornecedor-teste", "teste@exemplo.com", "12345678901", "Individual", diff --git a/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json b/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json index 8f6134a34..a5bd6d95e 100644 --- a/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Web.Admin.Tests/packages.lock.json @@ -183,8 +183,8 @@ }, "Microsoft.DotNet.HotReload.WebAssembly.Browser": { "type": "Transitive", - "resolved": "10.0.103", - "contentHash": "tIq6T4oyBMjxxxllslJu2fOJqEj8SOW29IfWqHYxubreOVlFWnxPN2PjDMuwoHjkrOnPKyuqoNcVYh5YOZcs0Q==" + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", @@ -563,7 +563,7 @@ "MeAjudaAi.Contracts": "[1.0.0, )", "Microsoft.AspNetCore.Components.WebAssembly": "[10.0.5, )", "Microsoft.AspNetCore.Components.WebAssembly.Authentication": "[10.0.5, )", - "Microsoft.DotNet.HotReload.WebAssembly.Browser": "[10.0.103, )", + "Microsoft.DotNet.HotReload.WebAssembly.Browser": "[10.0.104, )", "Microsoft.Extensions.Http.Resilience": "[10.4.0, )", "MudBlazor": "[8.15.0, )", "Polly": "[8.6.6, )", From 4b77f329ac890a0ba88e4ac3428856d8b3b4e4bc Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 11:34:27 -0300 Subject: [PATCH 02/23] feat: Introduce Provider entity, SearchProviders module, and slug generation utility. --- prompts/design-react-project.md | 2 +- .../DTOs/ModuleSearchableProviderDto.cs | 1 + .../Providers/Domain/Entities/Provider.cs | 6 +- .../Events/Mappers/ProviderEventMappers.cs | 6 +- .../Unit/Domain/Entities/ProviderTests.cs | 6 +- ...erProfileUpdatedDomainEventHandlerTests.cs | 11 +-- ...oviderRegisteredDomainEventHandlerTests.cs | 5 +- .../ModuleApi/SearchProvidersModuleApi.cs | 1 + .../Domain/Entities/SearchableProvider.cs | 2 +- .../SearchableProviderConfiguration.cs | 2 +- .../SearchProvidersIntegrationTestBase.cs | 5 +- .../SearchProvidersQueryHandlerTests.cs | 3 +- .../SearchProvidersModuleApiTests.cs | 1 + .../Entities/SearchableProviderTests.cs | 70 +++++++++++++++++++ .../Unit/Domain/Models/SearchResultTests.cs | 3 +- .../SearchableProviderRepositoryTests.cs | 2 +- .../ProviderProfileUpdatedIntegrationEvent.cs | 3 +- .../ProviderRegisteredIntegrationEvent.cs | 3 +- src/Shared/Utilities/SlugHelper.cs | 9 +++ .../Unit/Utilities/SlugHelperTests.cs | 1 + 20 files changed, 118 insertions(+), 24 deletions(-) diff --git a/prompts/design-react-project.md b/prompts/design-react-project.md index 232907102..b114b027f 100644 --- a/prompts/design-react-project.md +++ b/prompts/design-react-project.md @@ -135,7 +135,7 @@ export function CardContent({ className, ...props }: ComponentProps<"div">) { ## Cores (CSS Variables) -``` +```css bg-surface, bg-surface-raised → fundos bg-primary, bg-secondary, bg-muted → ações/estados bg-destructive → erros/danger diff --git a/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs b/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs index b6fa8ae9d..d887104e8 100644 --- a/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs +++ b/src/Contracts/Contracts/Modules/SearchProviders/DTOs/ModuleSearchableProviderDto.cs @@ -8,6 +8,7 @@ namespace MeAjudaAi.Contracts.Modules.SearchProviders.DTOs; public sealed record ModuleSearchableProviderDto( Guid ProviderId, string Name, + string Slug, ModuleLocationDto Location, decimal AverageRating, int TotalReviews, diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 76a1cbe40..211489924 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -137,7 +137,7 @@ public Provider( UserId = userId; Name = name.Trim(); - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -172,7 +172,7 @@ public Provider( UserId = userId; Name = name.Trim(); - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -213,7 +213,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? { updatedFields.Add("Name"); Name = newName; - Slug = SlugHelper.Generate(Name); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); updatedFields.Add("Slug"); } diff --git a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs index 723d0618a..e4449e8bf 100644 --- a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs +++ b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs @@ -22,7 +22,8 @@ public static ProviderRegisteredIntegrationEvent ToIntegrationEvent(this Provide Name: domainEvent.Name, ProviderType: domainEvent.Type.ToString(), Email: domainEvent.Email, - RegisteredAt: DateTime.UtcNow + RegisteredAt: DateTime.UtcNow, + Slug: domainEvent.Slug ); } @@ -70,7 +71,8 @@ public static ProviderProfileUpdatedIntegrationEvent ToIntegrationEvent(this Pro Name: domainEvent.Name, UpdatedFields: updatedFields, UpdatedBy: domainEvent.UpdatedBy, - NewEmail: domainEvent.Email + NewEmail: domainEvent.Email, + Slug: domainEvent.Slug ); } diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index bb3b6f535..273729cc9 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -65,7 +65,7 @@ public void Constructor_WithValidParameters_ShouldCreateProvider() provider.Type.Should().Be(type); provider.BusinessProfile.Should().Be(businessProfile); provider.VerificationStatus.Should().Be(EVerificationStatus.Pending); - provider.Slug.Should().Be("john-provider"); + provider.Slug.Should().StartWith("john-provider-"); provider.IsDeleted.Should().BeFalse(); provider.DeletedAt.Should().BeNull(); provider.Documents.Should().BeEmpty(); @@ -96,7 +96,7 @@ public void Constructor_ShouldRaiseProviderRegisteredDomainEvent() registeredEvent.Name.Should().Be(name); registeredEvent.Type.Should().Be(type); registeredEvent.Email.Should().Be(businessProfile.ContactInfo.Email); - registeredEvent.Slug.Should().Be("john-provider"); + registeredEvent.Slug.Should().StartWith("john-provider-"); } [Theory] @@ -168,7 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); - profileUpdatedEvent.Slug.Should().Be("updated-provider-name"); + profileUpdatedEvent.Slug.Should().StartWith("updated-provider-name-"); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs index 7aa7d7f71..090da17ba 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderProfileUpdatedDomainEventHandlerTests.cs @@ -41,8 +41,8 @@ public ProviderProfileUpdatedDomainEventHandlerTests() } /// - /// Verifies that HandleAsync publishes a ProviderProfileUpdatedIntegrationEvent with correct provider details - /// when processing a valid profile update domain event. + /// Verifica que HandleAsync publica um ProviderProfileUpdatedIntegrationEvent + /// com os detalhes corretos do provider ao processar um evento de atualização de perfil válido. /// [Fact] public async Task HandleAsync_ShouldPublishIntegrationEvent() @@ -73,15 +73,16 @@ public async Task HandleAsync_ShouldPublishIntegrationEvent() x => x.PublishAsync( It.Is(e => e.ProviderId == providerId.Value && - e.Name == "Updated Name"), + e.Name == "Updated Name" && + e.Slug == "updated-name"), It.IsAny(), It.IsAny()), Times.Once); } /// - /// Verifies that when message bus publishing fails, the handler logs an error containing - /// "Error handling ProviderProfileUpdatedDomainEvent" and re-throws the exception. + /// Verifica que quando a publicação no message bus falha, o handler registra + /// um erro contendo "Error handling ProviderProfileUpdatedDomainEvent" e relança a exceção. /// [Fact] public async Task HandleAsync_WhenMessageBusFails_ShouldThrowException() diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs index 7ba1f642d..6f67198a0 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Events/Handlers/ProviderRegisteredDomainEventHandlerTests.cs @@ -5,6 +5,7 @@ using MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.Providers; using MeAjudaAi.Shared.Utilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -77,7 +78,9 @@ public async Task HandleAsync_WithValidEvent_ShouldPublishIntegrationEvent() // Assert _messageBusMock.Verify( x => x.PublishAsync( - It.Is(e => e.GetType().Name == "ProviderRegisteredIntegrationEvent"), + It.Is(e => + e.Slug == "provider-test" && + e.Name == "Provider Test"), It.IsAny(), It.IsAny()), Times.Once); diff --git a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs index 32cbb54c6..9c8d1ac8d 100644 --- a/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs +++ b/src/Modules/SearchProviders/Application/ModuleApi/SearchProvidersModuleApi.cs @@ -114,6 +114,7 @@ public async Task> SearchProvidersAsync( Items: result.Value!.Items.Select(p => new ModuleSearchableProviderDto( ProviderId: p.ProviderId, Name: p.Name, + Slug: p.Slug, Location: new ModuleLocationDto( Latitude: p.Location.Latitude, Longitude: p.Location.Longitude), diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 3a73986c1..614f719ce 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -166,7 +166,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = slug, + Slug = slug?.Trim().ToLowerInvariant() ?? string.Empty, Description = description, City = city, State = state, diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs index e1d6eaff7..02b9b3ece 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs @@ -43,7 +43,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(p => p.Slug) .IsRequired() - .HasMaxLength(200) + .HasMaxLength(120) .HasColumnName("slug"); builder.Property(p => p.Description) diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 43aafce06..52be74493 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using MeAjudaAi.Shared.Utilities; using Testcontainers.PostgreSql; namespace MeAjudaAi.Modules.SearchProviders.Tests.Integration; @@ -164,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: location, subscriptionTier: tier, description: description, @@ -192,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: location, subscriptionTier: tier, description: description, diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs index 658335b31..df9a4573b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs @@ -8,6 +8,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.Repositories; using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; using Microsoft.Extensions.Logging; using Moq; @@ -397,7 +398,7 @@ private static List CreateTestProviders(int count) providers.Add(SearchableProvider.Create( providerId: Guid.NewGuid(), name: name, - slug: name.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(name), location: new GeoPoint(-23.5505 + i * 0.01, -46.6333 + i * 0.01), subscriptionTier: (ESubscriptionTier)(i % 4), description: $"Description {i + 1}", diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs index 0b85a999b..52269ed71 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Application/ModuleApi/SearchProvidersModuleApiTests.cs @@ -206,6 +206,7 @@ public async Task SearchProvidersAsync_WithValidParameters_ShouldReturnResults() var provider = result.Value.Items[0]; provider.ProviderId.Should().Be(providerId); provider.Name.Should().Be("Provider 1"); + provider.Slug.Should().Be("provider-1"); provider.Description.Should().Be("Test provider"); provider.AverageRating.Should().Be(4.5m); provider.TotalReviews.Should().Be(10); diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 06a6a84ca..0c2bb431b 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -77,6 +77,76 @@ public void Create_WithWhitespaceName_ShouldThrowArgumentException() act.Should().Throw(); } + [Fact] + public void Create_WithEmptySlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", "", location); + + // Assert + act.Should().Throw() + .WithMessage("*Provider slug cannot be empty*"); + } + + [Fact] + public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", " ", location); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithUppercaseSlug_ShouldNormalizeToLowercase() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var provider = SearchableProvider.Create(providerId, "Valid Name", "My-SLUG", location); + + // Assert + provider.Slug.Should().Be("my-slug"); + } + + [Fact] + public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() + { + // Arrange + var provider = CreateValidProvider(); + + // Act + var act = () => provider.UpdateBasicInfo("Valid Name", "", "desc", "city", "ST"); + + // Assert + act.Should().Throw() + .WithMessage("*Provider slug cannot be empty*"); + } + + [Fact] + public void UpdateBasicInfo_WithUppercaseSlug_ShouldNormalizeToLowercase() + { + // Arrange + var provider = CreateValidProvider(); + + // Act + provider.UpdateBasicInfo("Valid Name", "MY-SLUG", "desc", "city", "ST"); + + // Assert + provider.Slug.Should().Be("my-slug"); + } + [Fact] public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() { diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs index 4f5bf7a55..62a505062 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Models/SearchResultTests.cs @@ -5,6 +5,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.Models; using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; namespace MeAjudaAi.Modules.SearchProviders.Tests.Unit.Domain.Models; @@ -189,7 +190,7 @@ private IReadOnlyList CreateProviders(int count) var provider = SearchableProvider.Create( providerId: providerId, name: providerName, - slug: providerName.ToLower().Replace(" ", "-"), + slug: SlugHelper.Generate(providerName), location: location, subscriptionTier: _faker.Random.Enum(), description: _faker.Lorem.Sentence(), diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs index fcc3d4088..8f23024a8 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Repositories/SearchableProviderRepositoryTests.cs @@ -52,7 +52,7 @@ private SearchableProvider CreateTestProvider( var actualProviderId = providerId ?? UuidGenerator.NewId(); var actualName = name ?? _faker.Company.CompanyName(); - var actualSlug = actualName.ToLower().Replace(" ", "-"); + var actualSlug = SlugHelper.Generate(actualName); var actualSubscriptionTier = tier ?? _faker.Random.Enum(); return SearchableProvider.Create( diff --git a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs index b0adeeac0..e869e2357 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderProfileUpdatedIntegrationEvent.cs @@ -22,5 +22,6 @@ public sealed record ProviderProfileUpdatedIntegrationEvent( string? UpdatedBy = null, string? PreviousName = null, string? NewEmail = null, - string? NewPhoneNumber = null + string? NewPhoneNumber = null, + string? Slug = null ) : IntegrationEvent(Source); diff --git a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs index d7e08d2f9..5af7e8c0e 100644 --- a/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs +++ b/src/Shared/Messaging/Messages/Providers/ProviderRegisteredIntegrationEvent.cs @@ -23,5 +23,6 @@ public sealed record ProviderRegisteredIntegrationEvent( string? PhoneNumber = null, string? City = null, string? State = null, - DateTime? RegisteredAt = null + DateTime? RegisteredAt = null, + string? Slug = null ) : IntegrationEvent(Source); diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 8cab9d559..7833dad3a 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -47,6 +47,15 @@ public static string Generate(string text) return slug.Trim('-'); } + /// + /// Gera um slug a partir de um texto com sufixo diferenciador. + /// + /// Texto original + /// Sufixo a ser anexado (ex.: primeiros 8 chars do ID) + /// Slug formatado com sufixo único + public static string GenerateWithSuffix(string text, string suffix) + => $"{Generate(text)}-{suffix}"; + private static string RemoveDiacritics(string text) { var normalizedString = text.Normalize(NormalizationForm.FormD); diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs index d6cfca5cf..e273d8b13 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs @@ -3,6 +3,7 @@ namespace MeAjudaAi.Shared.Tests.Unit.Utilities; +[Trait("Category", "Unit")] public class SlugHelperTests { [Theory] From 9cf226af12be749298e6aa5dee3aee15b0820850 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 18:05:59 -0300 Subject: [PATCH 03/23] feat: Implement public provider retrieval by ID or slug, including new API endpoints, queries, and tests. --- .../API/Endpoints/ProvidersModuleEndpoints.cs | 2 +- ...=> GetPublicProviderByIdOrSlugEndpoint.cs} | 16 +- .../Providers/Application/Extensions.cs | 2 +- ...etPublicProviderByIdOrSlugQueryHandler.cs} | 13 +- .../GetPublicProviderByIdOrSlugQuery.cs | 26 +++ .../Queries/GetPublicProviderByIdQuery.cs | 20 --- .../Providers/Domain/Entities/Provider.cs | 3 +- .../TestInfrastructureExtensions.cs | 2 +- .../GetPublicProviderByIdIntegrationTests.cs | 12 +- .../GetPublicProviderByIdQueryHandlerTests.cs | 155 ------------------ .../GetPublicProviderByIdQueryHandlerTests.cs | 12 +- .../Unit/Domain/Entities/ProviderTests.cs | 2 +- .../ProviderConfigurationTests.cs | 3 +- .../Domain/Entities/SearchableProvider.cs | 14 +- .../Infrastructure/Extensions.cs | 31 ++-- .../SearchProvidersDbContextModelSnapshot.cs | 10 +- ...54_AddSlugToSearchableProvider.Designer.cs | 136 +++++++++++++++ ...60317151054_AddSlugToSearchableProvider.cs | 53 ++++++ .../SearchProvidersIntegrationTestBase.cs | 2 +- .../Utilities/Constants/ApiEndpoints.cs | 2 +- src/Shared/Utilities/SlugHelper.cs | 5 +- 21 files changed, 293 insertions(+), 228 deletions(-) rename src/Modules/Providers/API/Endpoints/Public/{GetPublicProviderByIdEndpoint.cs => GetPublicProviderByIdOrSlugEndpoint.cs} (80%) rename src/Modules/Providers/Application/Handlers/Queries/{GetPublicProviderByIdQueryHandler.cs => GetPublicProviderByIdOrSlugQueryHandler.cs} (85%) create mode 100644 src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs delete mode 100644 src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs delete mode 100644 src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs diff --git a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs index 5c3b4d2a9..a10a5d28a 100644 --- a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs +++ b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs @@ -54,7 +54,7 @@ public static void MapProvidersEndpoints(this WebApplication app) endpoints.MapEndpoint() .MapEndpoint() .MapEndpoint() - .MapEndpoint() // Novo endpoint público + .MapEndpoint() // Endpoint público por ID ou slug .MapEndpoint() // Endpoint para usuário autenticado virar prestador .MapEndpoint() .MapEndpoint() diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs similarity index 80% rename from src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs rename to src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs index ef073e8a4..86031a7f0 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs @@ -14,17 +14,17 @@ namespace MeAjudaAi.Modules.Providers.API.Endpoints.Public; /// -/// Endpoint público para consulta de detalhes básicos do prestador. +/// Endpoint público para consulta de detalhes básicos do prestador por ID ou slug. /// -public class GetPublicProviderByIdEndpoint : BaseEndpoint, IEndpoint +public class GetPublicProviderByIdOrSlugEndpoint : BaseEndpoint, IEndpoint { public static void Map(IEndpointRouteBuilder app) - => app.MapGet(ApiEndpoints.Providers.GetPublicById, GetPublicProviderAsync) - .WithName("GetPublicProviderById") + => app.MapGet(ApiEndpoints.Providers.GetPublicByIdOrSlug, GetPublicProviderAsync) + .WithName("GetPublicProviderByIdOrSlug") .WithSummary("Consultar perfil público do prestador") .WithDescription(""" Recupera dados públicos e seguros de um prestador para exibição no site. - Não requer autenticação. + Não requer autenticação. Aceita ID (GUID) ou slug amigável (ex.: "joao-silva-a1b2c3d4"). **Dados Retornados:** - Informações básicas (Nome, Fantasia, Descrição) @@ -43,14 +43,14 @@ Não requer autenticação. .Produces(StatusCodes.Status404NotFound); private static async Task GetPublicProviderAsync( - Guid id, + string idOrSlug, IQueryDispatcher queryDispatcher, HttpContext httpContext, CancellationToken cancellationToken) { var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false; - var query = new GetPublicProviderByIdQuery(id, isAuthenticated); - var result = await queryDispatcher.QueryAsync>( + var query = new GetPublicProviderByIdOrSlugQuery(idOrSlug, isAuthenticated); + var result = await queryDispatcher.QueryAsync>( query, cancellationToken); return Handle(result); diff --git a/src/Modules/Providers/Application/Extensions.cs b/src/Modules/Providers/Application/Extensions.cs index 770010b80..6feb06fad 100644 --- a/src/Modules/Providers/Application/Extensions.cs +++ b/src/Modules/Providers/Application/Extensions.cs @@ -29,7 +29,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped>>, GetProvidersByStateQueryHandler>(); services.AddScoped>>, GetProvidersByTypeQueryHandler>(); services.AddScoped>>, GetProvidersByVerificationStatusQueryHandler>(); - services.AddScoped>, GetPublicProviderByIdQueryHandler>(); + services.AddScoped>, GetPublicProviderByIdOrSlugQueryHandler>(); // Command Handlers - registro manual para garantir disponibilidade services.AddScoped>, CreateProviderCommandHandler>(); diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs similarity index 85% rename from src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs rename to src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 9b7414f37..5c25cfae0 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -15,24 +15,27 @@ namespace MeAjudaAi.Modules.Providers.Application.Handlers.Queries; /// -/// Handler responsável por processar a query de busca de prestador público. +/// Handler responsável por processar a query de busca de prestador público por ID ou slug. /// -public sealed class GetPublicProviderByIdQueryHandler : IQueryHandler> +public sealed class GetPublicProviderByIdOrSlugQueryHandler : IQueryHandler> { private readonly IProviderRepository _providerRepository; private readonly IFeatureManager _featureManager; - public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, IFeatureManager featureManager) + public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepository, IFeatureManager featureManager) { _providerRepository = providerRepository; _featureManager = featureManager; } public async Task> HandleAsync( - GetPublicProviderByIdQuery query, + GetPublicProviderByIdOrSlugQuery query, CancellationToken cancellationToken) { - var provider = await _providerRepository.GetByIdAsync(query.Id, cancellationToken); + // Resolve por ID (GUID) ou slug — mesma lógica do padrão de filmes + var provider = Guid.TryParse(query.IdOrSlug, out var id) + ? await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) + : await _providerRepository.GetBySlugAsync(query.IdOrSlug, cancellationToken); if (provider is null) { diff --git a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs new file mode 100644 index 000000000..a97b42a2e --- /dev/null +++ b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdOrSlugQuery.cs @@ -0,0 +1,26 @@ +using MeAjudaAi.Contracts; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Providers.Application.Queries; + +/// +/// Query para buscar dados públicos de um prestador por ID (GUID) ou slug. +/// Acessível sem autenticação. +/// +public sealed record GetPublicProviderByIdOrSlugQuery(string IdOrSlug, bool IsAuthenticated = false) : Query>, ICacheableQuery +{ + public string GetCacheKey() => $"provider:public:{IdOrSlug}:{(IsAuthenticated ? "auth" : "anon")}"; + + // Cache de 10 minutos para dados públicos (bom para SEO e performance) + public TimeSpan GetCacheExpiration() => TimeSpan.FromMinutes(10); + + public IReadOnlyCollection? GetCacheTags() + { + if (Guid.TryParse(IdOrSlug, out var id)) + return ["providers", $"provider:{id}"]; + + return ["providers", $"provider-slug:{IdOrSlug}"]; + } +} diff --git a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs deleted file mode 100644 index 47ee4901f..000000000 --- a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs +++ /dev/null @@ -1,20 +0,0 @@ -using MeAjudaAi.Contracts; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Modules.Providers.Application.DTOs; -using MeAjudaAi.Shared.Queries; - -namespace MeAjudaAi.Modules.Providers.Application.Queries; - -/// -/// Query para buscar dados públicos de um prestador pelo ID. -/// Acessível sem autenticação. -/// -public sealed record GetPublicProviderByIdQuery(Guid Id, bool IsAuthenticated = false) : Query>, ICacheableQuery -{ - public string GetCacheKey() => $"provider:public:{Id}:{(IsAuthenticated ? "auth" : "anon")}"; - - // Cache de 10 minutos para dados públicos (bom para SEO e performance) - public TimeSpan GetCacheExpiration() => TimeSpan.FromMinutes(10); - - public IReadOnlyCollection? GetCacheTags() => ["providers", $"provider:{Id}"]; -} diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 211489924..aaa707819 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -213,8 +213,7 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? { updatedFields.Add("Name"); Name = newName; - Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); - updatedFields.Add("Slug"); + // Slug é imutável após a criação — não regenerar para preservar URLs públicas } if (!BusinessProfile.ContactInfo.Email.Equals(businessProfile.ContactInfo.Email, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs index 8b56b805d..dd973b38a 100644 --- a/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs +++ b/src/Modules/Providers/Tests/Infrastructure/TestInfrastructureExtensions.cs @@ -112,7 +112,7 @@ public static IServiceCollection AddProvidersTestInfrastructure( services.AddScoped(); // Registrar handlers de teste explicitamente - services.AddScoped>, GetPublicProviderByIdQueryHandler>(); + services.AddScoped>, GetPublicProviderByIdOrSlugQueryHandler>(); services.AddScoped>, GetProviderByUserIdQueryHandler>(); services.AddScoped>, UpdateProviderProfileCommandHandler>(); diff --git a/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs b/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs index b581d71da..514e5f134 100644 --- a/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs +++ b/src/Modules/Providers/Tests/Integration/GetPublicProviderByIdIntegrationTests.cs @@ -34,10 +34,10 @@ public async Task GetPublicProviderById_ActiveProvider_ShouldReturnDto() await DbContext.SaveChangesAsync(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); @@ -68,10 +68,10 @@ public async Task GetPublicProviderById_InactiveProvider_ShouldReturnNotFound() await DbContext.SaveChangesAsync(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); @@ -84,10 +84,10 @@ public async Task GetPublicProviderById_NonExistentProvider_ShouldReturnNotFound // Arrange await CleanupDatabase(); var dispatcher = GetService(); - var query = new GetPublicProviderByIdQuery(Guid.NewGuid()); + var query = new GetPublicProviderByIdOrSlugQuery(Guid.NewGuid().ToString()); // Act - var result = await dispatcher.QueryAsync>(query, CancellationToken.None); + var result = await dispatcher.QueryAsync>(query, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs deleted file mode 100644 index 9620d5530..000000000 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/GetPublicProviderByIdQueryHandlerTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Contracts.Functional; -using MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Entities; -using MeAjudaAi.Modules.Providers.Domain.Repositories; -using Moq; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Modules.Providers.Domain.ValueObjects; - -using Microsoft.FeatureManagement; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers; - -public class GetPublicProviderByIdQueryHandlerTests -{ - private readonly Mock _providerRepositoryMock; - private readonly Mock _featureManagerMock; - private readonly GetPublicProviderByIdQueryHandler _handler; - - public GetPublicProviderByIdQueryHandlerTests() - { - _providerRepositoryMock = new Mock(); - _featureManagerMock = new Mock(); - _handler = new GetPublicProviderByIdQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); - } - - [Fact] - public async Task Handle_Should_Return_Provider_When_Found_And_Active() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Status property should be available on Provider for tests"); - statusProperty!.SetValue(provider, EProviderStatus.Active); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.Id.Should().Be(providerId); - result.Value.Name.Should().Be("Test Provider"); - result.Value.FantasyName.Should().Be("Fantasy Name"); - - // Assert non-restricted fields are populated - result.Value.Services.Should().NotBeNull(); - result.Value.PhoneNumbers.Should().NotBeNull(); - } - - [Fact] - public async Task Handle_Should_Return_NotFound_When_Provider_Does_Not_Exist() - { - // Arrange - var providerId = Guid.NewGuid(); - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync((Provider?)null); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.Error.StatusCode.Should().Be(404); - } - - [Fact] - public async Task Handle_Should_Return_NotFound_When_Provider_Is_Not_Active() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - // Set status to Suspended using reflection to ensure test isolation from default state - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Status property should be available on Provider for tests"); - statusProperty!.SetValue(provider, EProviderStatus.Suspended); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.Error.StatusCode.Should().Be(404); // Public endpoint hides inactive providers as Not Found - } - - [Fact] - public async Task Handle_Should_Return_Restricted_Provider_When_Privacy_Flag_Is_Enabled() - { - // Arrange - var providerId = Guid.NewGuid(); - var provider = CreateTestProvider(providerId); - - var statusProperty = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProperty.Should().NotBeNull("Provider.Status property must exist"); - statusProperty!.SetValue(provider, EProviderStatus.Active); - - _providerRepositoryMock.Setup(x => x.GetByIdAsync(It.Is(id => id.Value == providerId), It.IsAny())) - .ReturnsAsync(provider); - - // Enable privacy flag - _featureManagerMock.Setup(x => x.IsEnabledAsync(MeAjudaAi.Shared.Utilities.Constants.FeatureFlags.PublicProfilePrivacy)) - .ReturnsAsync(true); - - var query = new GetPublicProviderByIdQuery(providerId); - - // Act - var result = await _handler.HandleAsync(query, CancellationToken.None); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - - // Verify restricted fields are empty - result.Value!.Services.Should().BeEmpty(); - result.Value.PhoneNumbers.Should().BeEmpty(); - result.Value.Email.Should().BeNull(); - - // Verify non-restricted fields are still present - result.Value.Name.Should().Be("Test Provider"); - result.Value.FantasyName.Should().Be("Fantasy Name"); - } - - private static Provider CreateTestProvider(Guid? providerId = null, Guid? userId = null) - { - return new Provider( - new ProviderId(providerId ?? Guid.NewGuid()), - userId ?? Guid.NewGuid(), - "Test Provider", - EProviderType.Individual, - new BusinessProfile( - "Legal Name", - new ContactInfo("test@example.com", "123456789"), - new Address("Street", "123", "Neighborhood", "City", "State", "12345678"), - "Fantasy Name", - "Description" - ) - ); - } -} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 0a4c0c46c..226eb88f8 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -18,13 +18,13 @@ public class GetPublicProviderByIdQueryHandlerTests { private readonly Mock _providerRepositoryMock; private readonly Mock _featureManagerMock; - private readonly GetPublicProviderByIdQueryHandler _handler; + private readonly GetPublicProviderByIdOrSlugQueryHandler _handler; public GetPublicProviderByIdQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); - _handler = new GetPublicProviderByIdQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); + _handler = new GetPublicProviderByIdOrSlugQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); } [Fact] @@ -45,7 +45,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -70,7 +70,7 @@ public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -90,7 +90,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() .Setup(x => x.GetByIdAsync(providerId, It.IsAny())) .ReturnsAsync((Provider?)null); - var query = new GetPublicProviderByIdQuery(providerId); + var query = new GetPublicProviderByIdOrSlugQuery(providerId.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -125,7 +125,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) .ReturnsAsync(true); - var query = new GetPublicProviderByIdQuery(provider.Id); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); // Act var result = await _handler.HandleAsync(query, CancellationToken.None); diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index 273729cc9..cbab7b39e 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -168,7 +168,7 @@ public void UpdateProfile_WithValidParameters_ShouldUpdateProvider() var profileUpdatedEvent = (ProviderProfileUpdatedDomainEvent)updateEvent; profileUpdatedEvent.Name.Should().Be(newName); - profileUpdatedEvent.Slug.Should().StartWith("updated-provider-name-"); + profileUpdatedEvent.Slug.Should().Be(provider.Slug); profileUpdatedEvent.UpdatedBy.Should().Be(updatedBy); } diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs index bdac1955c..a1c7cb2fe 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs @@ -832,7 +832,8 @@ public void Configure_ShouldHaveSixIndexes() // 4. Status // 5. VerificationStatus // 6. IsDeleted - _entityType.GetIndexes().Should().HaveCount(6); + // 7. Slug (unique) + _entityType.GetIndexes().Should().HaveCount(7); } #endregion diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 614f719ce..a5d4dea85 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -126,7 +126,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = slug.Trim().ToLowerInvariant(), + Slug = NormalizeSlug(slug), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -135,6 +135,14 @@ public static SearchableProvider Create( return searchableProvider; } + private static string NormalizeSlug(string slug) + { + var normalized = slug.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(normalized)) + throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); + return normalized; + } + /// /// Reconstitui uma entidade existente do banco de dados. /// Usado internamente pela camada de infraestrutura (Dapper queries) para reconstruir @@ -166,7 +174,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = slug?.Trim().ToLowerInvariant() ?? string.Empty, + Slug = NormalizeSlug(slug ?? string.Empty), Description = description, City = city, State = state, @@ -195,7 +203,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin } Name = name.Trim(); - Slug = slug.Trim().ToLowerInvariant(); + Slug = NormalizeSlug(slug); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Infrastructure/Extensions.cs b/src/Modules/SearchProviders/Infrastructure/Extensions.cs index 01edf0f8b..18f1d80a3 100644 --- a/src/Modules/SearchProviders/Infrastructure/Extensions.cs +++ b/src/Modules/SearchProviders/Infrastructure/Extensions.cs @@ -41,27 +41,32 @@ public static IServiceCollection AddSearchProvidersInfrastructure( // Em ambiente de teste, permitir inicialização sem connection string // (útil para testes unitários que não acessam o banco) // Só fazemos bypass se estivermos explicitamente em Desenvolvimento e NÃO for um live environment - var isTesting = environment.IsDevelopment() && MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment); + var isTesting = MeAjudaAi.Shared.Utilities.EnvironmentHelpers.IsSecurityBypassEnvironment(environment); - if (string.IsNullOrEmpty(connectionString) && !isTesting) + if (string.IsNullOrEmpty(connectionString)) { - throw new InvalidOperationException( - "Database connection string not found. Tried: 'DefaultConnection', 'Search', 'meajudaai-db'. " + - "Please configure one of these connection strings in appsettings.json or environment variables."); + if (isTesting) + { +#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential + connectionString = MeAjudaAi.Shared.Database.DatabaseConstants.DefaultTestConnectionString; +#pragma warning restore S2068 + } + else + { + var env1 = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var env2 = Environment.GetEnvironmentVariable("INTEGRATION_TESTS"); + var env3 = environment?.EnvironmentName; + throw new InvalidOperationException( + $"DEBUG: isTesting={isTesting}, ASPNETCORE={env1}, INTEGRATION_TESTS={env2}, EnvName={env3} " + + "Database connection string not found. Tried: 'DefaultConnection', 'Search', 'meajudaai-db'."); + } } // Sempre registrar DbContext (mesmo que connection string seja vazia em testes unitários) // Em E2E tests, a connection string será fornecida via configuração services.AddDbContext((serviceProvider, options) => { - // Se não houver connection string, usar uma default para testes unitários -#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential - var connStr = !string.IsNullOrEmpty(connectionString) - ? connectionString - : MeAjudaAi.Shared.Database.DatabaseConstants.DefaultTestConnectionString; -#pragma warning restore S2068 - - options.UseNpgsql(connStr, npgsqlOptions => + options.UseNpgsql(connectionString, npgsqlOptions => { npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "search_providers"); npgsqlOptions.UseNetTopologySuite(); // Habilitar suporte PostGIS/geoespacial diff --git a/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs b/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs index 2ae7a3667..8f07f0724 100644 --- a/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs +++ b/src/Modules/SearchProviders/Infrastructure/Migrations/SearchProvidersDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("search_providers") - .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); @@ -76,6 +76,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid[]") .HasColumnName("service_ids"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + b.Property("State") .HasMaxLength(2) .HasColumnType("character varying(2)") diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs new file mode 100644 index 000000000..dbdf5e786 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SearchProvidersDbContext))] + [Migration("20260317151054_AddSlugToSearchableProvider")] + partial class AddSlugToSearchableProvider + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("search_providers") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("numeric(3,2)") + .HasColumnName("average_rating"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography(Point, 4326)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.PrimitiveCollection("ServiceIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("service_ids"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("state"); + + b.Property("SubscriptionTier") + .HasColumnType("integer") + .HasColumnName("subscription_tier"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_searchable_providers"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_searchable_providers_is_active"); + + b.HasIndex("Location") + .HasDatabaseName("ix_searchable_providers_location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.HasIndex("ProviderId") + .IsUnique() + .HasDatabaseName("ix_searchable_providers_provider_id"); + + b.HasIndex("ServiceIds") + .HasDatabaseName("ix_searchable_providers_service_ids"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("ServiceIds"), "gin"); + + b.HasIndex("SubscriptionTier") + .HasDatabaseName("ix_searchable_providers_subscription_tier"); + + b.HasIndex("IsActive", "SubscriptionTier", "AverageRating") + .HasDatabaseName("ix_searchable_providers_search_ranking"); + + b.ToTable("searchable_providers", "search_providers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs new file mode 100644 index 000000000..1fd9ddda0 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260317151054_AddSlugToSearchableProvider.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSlugToSearchableProvider : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. Adicionar a coluna como NULLABLE + migrationBuilder.AddColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + // 2. Executar um backfill UPDATE + // Cria um slug rudimentar a partir do nome e anexa os primeiros 8 caracteres do ProviderId (no formato "N" / sem hífens) + migrationBuilder.Sql(@" + UPDATE search_providers.searchable_providers + SET slug = LOWER(REPLACE(name, ' ', '-')) || '-' || SUBSTRING(REPLACE(provider_id::text, '-', ''), 1, 8) + WHERE slug IS NULL OR slug = ''; + "); + + // 3. Alterar a coluna para NOT NULL após o preenchimento dos dados + migrationBuilder.AlterColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers", + type: "character varying(120)", + maxLength: 120, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(120)", + oldMaxLength: 120, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "slug", + schema: "search_providers", + table: "searchable_providers"); + } + } +} diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 52be74493..884bcc404 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -165,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.Generate(name), + slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index a7de4845c..b0c31a189 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicById = "/{id:guid}/public"; // GET GetPublicProviderByIdEndpoint + public const string GetPublicByIdOrSlug = "/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint (aceita GUID ou slug) } /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 7833dad3a..0263a003e 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -54,7 +54,10 @@ public static string Generate(string text) /// Sufixo a ser anexado (ex.: primeiros 8 chars do ID) /// Slug formatado com sufixo único public static string GenerateWithSuffix(string text, string suffix) - => $"{Generate(text)}-{suffix}"; + { + var baseSlug = Generate(text); + return string.IsNullOrEmpty(baseSlug) ? suffix : $"{baseSlug}-{suffix}"; + } private static string RemoveDiacritics(string text) { From 848fda86f6df6860f31f888fd704fb9d8b128aa0 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 19:51:01 -0300 Subject: [PATCH 04/23] feat: Implement public provider profiles accessible by ID or slug, supported by a new searchable provider entity and slug utility. --- .../GetPublicProviderByIdOrSlugEndpoint.cs | 2 +- .../Configurations/ProviderConfiguration.cs | 1 - .../GetPublicProviderByIdQueryHandlerTests.cs | 28 +++++++++++++++++++ .../ProviderConfigurationTests.cs | 6 ++-- .../Domain/Entities/SearchableProvider.cs | 15 +++------- .../SearchProvidersIntegrationTestBase.cs | 2 +- .../Utilities/Constants/ApiEndpoints.cs | 2 +- src/Shared/Utilities/SlugHelper.cs | 9 ++++-- .../app/(main)/buscar/page.tsx | 8 +++--- 9 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs index 86031a7f0..487107c0e 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs @@ -30,7 +30,7 @@ Não requer autenticação. Aceita ID (GUID) ou slug amigável (ex.: "joao-silva - Informações básicas (Nome, Fantasia, Descrição) - Localização aproximada (Cidade/Estado) - Avaliação média e contagem de reviews - - Lista de serviços oferecidos + - Lista de serviços oferecidos (Nota: esta lista será vazia se a configuração PublicProfilePrivacy do provedor estiver ativa e o solicitante for anônimo) **Dados Ocultados (Privacidade):** - Documentos (CPF/CNPJ) diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index 2d5c2450a..d506142e9 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -248,7 +248,6 @@ public void Configure(EntityTypeBuilder builder) .HasDatabaseName("ix_providers_user_id"); builder.HasIndex(p => p.Slug) - .IsUnique() .HasDatabaseName("ix_providers_slug"); builder.HasIndex(p => p.Name) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 226eb88f8..01946cef6 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -58,6 +58,34 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati result.Value.VerificationStatus.Should().Be(EVerificationStatus.Verified); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp!.SetValue(provider, EProviderStatus.Active); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + } + [Fact] public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() { diff --git a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs index a1c7cb2fe..b475c2ecb 100644 --- a/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs +++ b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs @@ -823,16 +823,16 @@ public void Configure_ShouldHaveIndexOnIsDeleted() } [Fact] - public void Configure_ShouldHaveSixIndexes() + public void Configure_ShouldHaveSevenIndexes() { - // Assert - 6 indexes on Provider entity itself: + // Assert - 7 indexes on Provider entity itself: // 1. UserId (unique) // 2. Name // 3. Type // 4. Status // 5. VerificationStatus // 6. IsDeleted - // 7. Slug (unique) + // 7. Slug (no longer unique) _entityType.GetIndexes().Should().HaveCount(7); } diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index a5d4dea85..26408b00b 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -2,6 +2,7 @@ using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; using MeAjudaAi.Shared.Domain; using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Utilities; namespace MeAjudaAi.Modules.SearchProviders.Domain.Entities; @@ -126,7 +127,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = NormalizeSlug(slug), + Slug = SlugHelper.Generate(slug), Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -135,14 +136,6 @@ public static SearchableProvider Create( return searchableProvider; } - private static string NormalizeSlug(string slug) - { - var normalized = slug.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(normalized)) - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - return normalized; - } - /// /// Reconstitui uma entidade existente do banco de dados. /// Usado internamente pela camada de infraestrutura (Dapper queries) para reconstruir @@ -174,7 +167,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = NormalizeSlug(slug ?? string.Empty), + Slug = SlugHelper.Generate(slug ?? string.Empty), Description = description, City = city, State = state, @@ -203,7 +196,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin } Name = name.Trim(); - Slug = NormalizeSlug(slug); + Slug = SlugHelper.Generate(slug); Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 884bcc404..217116a08 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -193,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.Generate(name), + slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index b0c31a189..b44dbfede 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicByIdOrSlug = "/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint (aceita GUID ou slug) + public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint } /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs index 0263a003e..c4122bdc5 100644 --- a/src/Shared/Utilities/SlugHelper.cs +++ b/src/Shared/Utilities/SlugHelper.cs @@ -55,8 +55,13 @@ public static string Generate(string text) /// Slug formatado com sufixo único public static string GenerateWithSuffix(string text, string suffix) { - var baseSlug = Generate(text); - return string.IsNullOrEmpty(baseSlug) ? suffix : $"{baseSlug}-{suffix}"; + var baseSlug = Generate(text ?? string.Empty); + var suffixSlug = Generate((suffix ?? string.Empty).Trim()); + + if (string.IsNullOrEmpty(suffixSlug)) return baseSlug; + if (string.IsNullOrEmpty(baseSlug)) return suffixSlug; + + return $"{baseSlug}-{suffixSlug}"; } private static string RemoveDiacritics(string text) diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx index 476399a3d..1864978f6 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx @@ -6,8 +6,8 @@ import { AdCard } from "@/components/search/ad-card"; import { ServiceTags } from "@/components/search/service-tags"; import { SearchFilters } from "@/components/search/search-filters"; -import { apiProvidersGet4, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; -import type { ApiProvidersGet4Data } from "@/lib/api/generated"; +import { apiProvidersGet5, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; +import type { ApiProvidersGet5Data } from "@/lib/api/generated"; import { mapSearchableProviderToProvider } from "@/lib/api/mappers"; import { geocodeCity } from "@/lib/services/geocoding"; import { getAuthHeaders } from "@/lib/api/auth-headers"; @@ -75,7 +75,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { // Fetch providers from API // TODO: Implement pagination controls. Currently hardcoded to page 1. const headers = await getAuthHeaders(); - const { data, error } = await apiProvidersGet4({ + const { data, error } = await apiProvidersGet5({ query: { latitude, longitude, @@ -85,7 +85,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { minRating: minRatingVal, page: 1, pageSize: 20, - } as ApiProvidersGet4Data["query"], + } as ApiProvidersGet5Data["query"], headers, }); From 8fdf42c75b2ec7aa61204ccc93ea12ed73c68e1c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 20:02:09 -0300 Subject: [PATCH 05/23] feat: implement provider search page with filtering, geocoding, and results display. --- src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx index 1864978f6..476399a3d 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(main)/buscar/page.tsx @@ -6,8 +6,8 @@ import { AdCard } from "@/components/search/ad-card"; import { ServiceTags } from "@/components/search/service-tags"; import { SearchFilters } from "@/components/search/search-filters"; -import { apiProvidersGet5, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; -import type { ApiProvidersGet5Data } from "@/lib/api/generated"; +import { apiProvidersGet4, apiCategoryGet } from "@/lib/api/generated/sdk.gen"; +import type { ApiProvidersGet4Data } from "@/lib/api/generated"; import { mapSearchableProviderToProvider } from "@/lib/api/mappers"; import { geocodeCity } from "@/lib/services/geocoding"; import { getAuthHeaders } from "@/lib/api/auth-headers"; @@ -75,7 +75,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { // Fetch providers from API // TODO: Implement pagination controls. Currently hardcoded to page 1. const headers = await getAuthHeaders(); - const { data, error } = await apiProvidersGet5({ + const { data, error } = await apiProvidersGet4({ query: { latitude, longitude, @@ -85,7 +85,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { minRating: minRatingVal, page: 1, pageSize: 20, - } as ApiProvidersGet5Data["query"], + } as ApiProvidersGet4Data["query"], headers, }); From 04b8b9a7a38741dc4eda0e1a32d401d047750b34 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Tue, 17 Mar 2026 21:03:17 -0300 Subject: [PATCH 06/23] feat: Introduce `SearchableProvider` entity for optimized search and add unit tests for public provider retrieval by ID or slug. --- .../GetPublicProviderByIdQueryHandlerTests.cs | 1 + .../Domain/Entities/SearchableProvider.cs | 27 +++++++++++-------- .../SearchProvidersIntegrationTestBase.cs | 4 +-- .../Utilities/Constants/ApiEndpoints.cs | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs index 01946cef6..a2cfe92ef 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs @@ -84,6 +84,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); } [Fact] diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 26408b00b..be6ff2a62 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -113,10 +113,7 @@ public static SearchableProvider Create( throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - } + slug = NormalizeAndValidateSlug(slug); ArgumentNullException.ThrowIfNull(location); @@ -127,7 +124,7 @@ public static SearchableProvider Create( location, subscriptionTier) { - Slug = SlugHelper.Generate(slug), + Slug = slug, Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -167,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = SlugHelper.Generate(slug ?? string.Empty), + Slug = NormalizeAndValidateSlug(slug), Description = description, City = city, State = state, @@ -190,13 +187,10 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin throw new ArgumentException("Provider name cannot be empty.", nameof(name)); } - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Provider slug cannot be empty.", nameof(slug)); - } + slug = NormalizeAndValidateSlug(slug); Name = name.Trim(); - Slug = SlugHelper.Generate(slug); + Slug = slug; Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); @@ -281,4 +275,15 @@ public double CalculateDistanceToInKm(GeoPoint targetLocation) return Location.DistanceTo(targetLocation); } + + private static string NormalizeAndValidateSlug(string? slug) + { + var normalized = SlugHelper.Generate(slug ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); + } + + return normalized; + } } diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 217116a08..352d8ce9b 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -165,7 +165,7 @@ protected SearchableProvider CreateTestSearchableProvider( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + slug: BuildTestSlug(name, providerId), location: location, subscriptionTier: tier, description: description, @@ -193,7 +193,7 @@ protected SearchableProvider CreateTestSearchableProviderWithProviderId( var provider = SearchableProvider.Create( providerId: providerId, name: name, - slug: SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + slug: BuildTestSlug(name, providerId), location: location, subscriptionTier: tier, description: description, diff --git a/src/Shared/Utilities/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index b44dbfede..1d4b9b92f 100644 --- a/src/Shared/Utilities/Constants/ApiEndpoints.cs +++ b/src/Shared/Utilities/Constants/ApiEndpoints.cs @@ -49,7 +49,7 @@ public static class Providers public const string AddDocument = "/{id:guid}/documents"; // POST AddDocumentEndpoint public const string RemoveDocument = "/{id:guid}/documents/{documentType}"; // DELETE RemoveDocumentEndpoint public const string RequireBasicInfoCorrection = "/{id:guid}/require-basic-info-correction"; // POST RequireBasicInfoCorrectionEndpoint - public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdEndpoint + public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdOrSlugEndpoint } /// From a6285c6017fd0bbcb2fad51d4546133069e9afce Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 08:32:16 -0300 Subject: [PATCH 07/23] feat: Add SearchProviders integration test base with Testcontainers and unit tests for GetPublicProviderByIdOrSlug query handler. --- ...cs => GetPublicProviderByIdOrSlugQueryHandlerTests.cs} | 4 ++-- .../Integration/SearchProvidersIntegrationTestBase.cs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) rename src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/{GetPublicProviderByIdQueryHandlerTests.cs => GetPublicProviderByIdOrSlugQueryHandlerTests.cs} (98%) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs similarity index 98% rename from src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs rename to src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index a2cfe92ef..d705d85f9 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -14,13 +14,13 @@ namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers.Queries; [Trait("Category", "Unit")] -public class GetPublicProviderByIdQueryHandlerTests +public class GetPublicProviderByIdOrSlugQueryHandlerTests { private readonly Mock _providerRepositoryMock; private readonly Mock _featureManagerMock; private readonly GetPublicProviderByIdOrSlugQueryHandler _handler; - public GetPublicProviderByIdQueryHandlerTests() + public GetPublicProviderByIdOrSlugQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 352d8ce9b..1f6d9f354 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -273,4 +273,12 @@ protected IServiceScope CreateScope() throw new InvalidOperationException("Service provider not initialized"); return _serviceProvider.CreateScope(); } + + /// + /// Constrói o slug usado nos testes a partir do nome e do ID do provedor. + /// + private static string BuildTestSlug(string name, Guid providerId) + { + return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + } } From 850f2a6550bc4fd0e7c099a94cf41822c591ded9 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 09:36:04 -0300 Subject: [PATCH 08/23] feat: Add SearchProviders integration test base with Testcontainers PostgreSQL/PostGIS, new unit tests for SearchableProvider and API extensions, and a new GetPublicProviderByIdOrSlug query handler with its tests. --- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 18 ++++- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 77 ++++++++++++++++++- .../SearchProvidersIntegrationTestBase.cs | 7 +- .../Entities/SearchableProviderTests.cs | 4 +- .../Unit/API/Extensions/APIExtensionsTests.cs | 15 ++-- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 5c25cfae0..2fab0248e 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -32,10 +32,20 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos GetPublicProviderByIdOrSlugQuery query, CancellationToken cancellationToken) { - // Resolve por ID (GUID) ou slug — mesma lógica do padrão de filmes - var provider = Guid.TryParse(query.IdOrSlug, out var id) - ? await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) - : await _providerRepository.GetBySlugAsync(query.IdOrSlug, cancellationToken); + var normalizedValue = query.IdOrSlug.Trim().ToLowerInvariant(); + + // Tenta resolver por ID (GUID); se não encontrar, faz fallback para slug. + // Isso cobre o caso em que um slug tem formato de GUID válido. + Domain.Entities.Provider? provider; + if (Guid.TryParse(normalizedValue, out var id)) + { + provider = await _providerRepository.GetByIdAsync(new ProviderId(id), cancellationToken) + ?? await _providerRepository.GetBySlugAsync(normalizedValue, cancellationToken); + } + else + { + provider = await _providerRepository.GetBySlugAsync(normalizedValue, cancellationToken); + } if (provider is null) { diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index d705d85f9..f75c58605 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -69,10 +69,13 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() // Bypass domain transitions to set Active status directly for test var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); statusProp!.SetValue(provider, EProviderStatus.Active); + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + _providerRepositoryMock - .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) .ReturnsAsync(provider); var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); @@ -87,6 +90,78 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() result.Value.Slug.Should().Be(provider.Slug); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalizeAndReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, EProviderStatus.Active); + + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + var upperSlug = provider.Slug.ToUpperInvariant(); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) + .ReturnsAsync(provider); + + // Query uses uppercase slug — handler must normalize before calling GetBySlugAsync + var query = new GetPublicProviderByIdOrSlugQuery(upperSlug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); + } + + [Fact] + public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() + { + // Arrange — slug that happens to be a valid GUID format (Guid.TryParse returns true) + var slugGuid = Guid.NewGuid(); + var slugValue = slugGuid.ToString().ToLowerInvariant(); // e.g. "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + .Build(); + + // Bypass domain transitions to set Active status directly for test + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, EProviderStatus.Active); + + // GetByIdAsync returns null (no provider with that ID) + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Provider?)null); + + // Fallback to slug lookup returns the provider + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(slugValue, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(slugValue); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + } + [Fact] public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() { diff --git a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs index 1f6d9f354..4686e0fd3 100644 --- a/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs +++ b/src/Modules/SearchProviders/Tests/Integration/SearchProvidersIntegrationTestBase.cs @@ -274,11 +274,16 @@ protected IServiceScope CreateScope() return _serviceProvider.CreateScope(); } + /// + /// Número de caracteres do GUID usados como sufixo (deve coincidir com SlugHelper.GenerateWithSuffix). + /// + private const int GuidSuffixLength = 8; + /// /// Constrói o slug usado nos testes a partir do nome e do ID do provedor. /// private static string BuildTestSlug(string name, Guid providerId) { - return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + return SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..GuidSuffixLength]); } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index 0c2bb431b..deb375462 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -89,7 +89,7 @@ public void Create_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider slug cannot be empty*"); + .WithMessage("*O identificador do provedor não pode estar vazio.*"); } [Fact] @@ -131,7 +131,7 @@ public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider slug cannot be empty*"); + .WithMessage("*O identificador do provedor não pode estar vazio.*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs index 9345beaa0..fff478b58 100644 --- a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs @@ -1,4 +1,5 @@ using MeAjudaAi.Modules.Users.API; +using MeAjudaAi.Shared.Database; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +23,7 @@ public void AddUsersModule_ShouldRegisterServices() var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -47,7 +48,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithSchemaIsolationDisa var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -68,7 +69,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullPasswords_Shoul var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Database:EnableSchemaIsolation"] = "false" }) .Build(); @@ -109,7 +110,7 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrow() // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -145,7 +146,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullConfiguration_S // Act & Assert var act = async () => await services.AddUsersModuleWithSchemaIsolationAsync(configuration); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] @@ -166,7 +167,7 @@ public void AddUsersModule_WithValidConfiguration_ShouldReturnSameServiceCollect var services = new ServiceCollection(); var configData = new Dictionary { - ["Database:ConnectionString"] = "Server=localhost;Database=TestDb;Trusted_Connection=true;", + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString, ["Cache:RedisConnectionString"] = "localhost:6379" }; var configuration = new ConfigurationBuilder() @@ -191,7 +192,7 @@ public void AddUsersModule_CalledMultipleTimes_ShouldNotThrow() var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=user;Password=pass" + ["ConnectionStrings:Users"] = DatabaseConstants.DefaultTestConnectionString }) .Build(); From 71d3640d7040c93f6190ceb089d28b530301297f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:03:07 -0300 Subject: [PATCH 09/23] feat: Introduce `SearchableProvider` entity for search optimization and add `GetPublicProviderByIdOrSlugQueryHandler` with public profile privacy features. --- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 2 +- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 27 +++++++++---------- .../Domain/Entities/SearchableProvider.cs | 2 +- src/Modules/Users/API/Extensions.cs | 6 +++++ .../Unit/API/Extensions/APIExtensionsTests.cs | 4 +-- .../Users/Tests/Unit/API/ExtensionsTests.cs | 27 +++++++++++-------- 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 2fab0248e..5415b5c23 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -73,7 +73,7 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos ? businessProfile.ContactInfo.Email : null; - var services = !isPrivacyEnabled + var services = !shouldRedactContactInfo ? provider.Services.Select(s => s.ServiceName).ToList() : new List(); diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index f75c58605..cad2ca19b 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -37,9 +37,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -68,9 +66,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -100,9 +96,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); var upperSlug = provider.Slug.ToUpperInvariant(); @@ -137,9 +131,7 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); // GetByIdAsync returns null (no provider with that ID) _providerRepositoryMock @@ -217,9 +209,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Build(); // Bypass domain transitions to set Active status directly for test - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, EProviderStatus.Active); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -242,4 +232,11 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro result.Value.PhoneNumbers.Should().BeEmpty(); result.Value.Services.Should().BeEmpty(); } + + private static void SetProviderStatus(Provider provider, EProviderStatus status) + { + var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); + statusProp.Should().NotBeNull("Provider.Status property must exist"); + statusProp!.SetValue(provider, status); + } } diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index be6ff2a62..e39120d7b 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -164,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = NormalizeAndValidateSlug(slug), + Slug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), Description = description, City = city, State = state, diff --git a/src/Modules/Users/API/Extensions.cs b/src/Modules/Users/API/Extensions.cs index d650b5486..454ed503c 100644 --- a/src/Modules/Users/API/Extensions.cs +++ b/src/Modules/Users/API/Extensions.cs @@ -16,6 +16,9 @@ public static class Extensions { public static IServiceCollection AddUsersModule(this IServiceCollection services, IConfiguration configuration) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + services.AddApplication(); services.AddInfrastructure(configuration); @@ -35,6 +38,9 @@ public static async Task AddUsersModuleWithSchemaIsolationAs string? usersRolePassword = null, string? appRolePassword = null) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + // Configurar serviços do módulo services.AddUsersModule(configuration); diff --git a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs index fff478b58..f0f91e516 100644 --- a/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs @@ -110,7 +110,7 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrow() // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -146,7 +146,7 @@ public async Task AddUsersModuleWithSchemaIsolationAsync_WithNullConfiguration_S // Act & Assert var act = async () => await services.AddUsersModuleWithSchemaIsolationAsync(configuration); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 840612836..f16de251a 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -45,19 +45,17 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() } [Fact] - public void AddUsersModule_WithEmptyConfiguration_ShouldRegisterServices() + public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationException() { // Arrange var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act - Não deve lançar exceção durante o registro mesmo com configuração vazia - var result = services.AddUsersModule(configuration); - - // Assert - Assert.NotNull(result); - Assert.Same(services, result); - Assert.True(services.Count > 0, "Services should be registered even with empty configuration"); + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw() + .WithMessage("Connection for Users module not configured"); } [Fact] @@ -65,7 +63,12 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder().Build(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) + .Build(); // Act var result = services.AddUsersModule(configuration); @@ -109,7 +112,10 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() // Arrange var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary()) + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) .Build(); // Act @@ -126,7 +132,6 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() [Theory] [InlineData("Server=localhost;Database=test1;", "test-realm")] [InlineData("Server=localhost;Database=test2;", "another-realm")] - [InlineData("", "")] public void AddUsersModule_WithVariousConfigurations_ShouldRegisterServices(string connectionString, string realm) { // Arrange From 3658cafa1a9030631f724b95895b7dbef7cd04bd Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:06:58 -0300 Subject: [PATCH 10/23] feat: Introduce `SearchableProvider` entity for search optimization and add `GetPublicProviderByIdOrSlugQueryHandler` with public profile privacy features. --- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../ContentSecurityPolicyMiddleware.cs | 2 +- .../{Middleware => Middlewares}/InspectAuthMiddleware.cs | 2 +- .../Unit/Middlewares/InspectAuthMiddlewareTests.cs | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) rename src/Bootstrapper/MeAjudaAi.ApiService/{Middleware => Middlewares}/ContentSecurityPolicyMiddleware.cs (99%) rename src/Bootstrapper/MeAjudaAi.ApiService/{Middleware => Middlewares}/InspectAuthMiddleware.cs (98%) diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs index 424ade1fd..ded6230af 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using MeAjudaAi.ApiService.Endpoints; -using MeAjudaAi.ApiService.Middleware; using MeAjudaAi.ApiService.Middlewares; using MeAjudaAi.ApiService.Options; using MeAjudaAi.ApiService.Services.Authentication; diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs similarity index 99% rename from src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs rename to src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs index 88a026fcd..3be920f58 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/ContentSecurityPolicyMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/ContentSecurityPolicyMiddleware.cs @@ -1,4 +1,4 @@ -namespace MeAjudaAi.ApiService.Middleware; +namespace MeAjudaAi.ApiService.Middlewares; /// /// Middleware para adicionar Content Security Policy headers. diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs similarity index 98% rename from src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs rename to src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs index 1da7aed81..bb007e327 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middleware/InspectAuthMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/InspectAuthMiddleware.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; -namespace MeAjudaAi.ApiService.Middleware; +namespace MeAjudaAi.ApiService.Middlewares; /// diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs index 88bab8706..267e93214 100644 --- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs +++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/InspectAuthMiddlewareTests.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using FluentAssertions; -using MeAjudaAi.ApiService.Middleware; +using MeAjudaAi.ApiService.Middlewares; using MeAjudaAi.Shared.Utilities.Constants; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; From d7a0a8a0eb5cb23d6a8d133df6bc83cceb418d56 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 11:17:25 -0300 Subject: [PATCH 11/23] feat: Implement user management module with Keycloak integration and add a RabbitMQ dead letter service. --- src/Aspire/MeAjudaAi.AppHost/Program.cs | 6 +++++ .../MeAjudaAi.AppHost/packages.lock.json | 8 +++---- .../Middlewares/SecurityHeadersMiddleware.cs | 7 ++---- .../Commands/CreateUserCommandHandler.cs | 6 ++--- .../Commands/DeleteUserCommandHandler.cs | 2 +- .../RegisterCustomerCommandHandler.cs | 10 ++++---- .../UpdateUserProfileCommandHandler.cs | 2 +- .../Identity/Keycloak/KeycloakService.cs | 2 +- .../Services/KeycloakUserDomainService.cs | 4 ++-- .../LocalDevelopmentUserDomainService.cs | 2 +- .../Users/Tests/Builders/UserBuilder.cs | 2 +- .../Queries/GetUsersQueryHandlerTests.cs | 2 +- .../Tests/Unit/Domain/Entities/UserTests.cs | 2 +- src/Shared/Database/DatabaseConstants.cs | 2 ++ .../DeadLetter/RabbitMqDeadLetterService.cs | 4 ++-- .../MeAjudaAi.Web.Admin/packages.lock.json | 24 +++++++++---------- tests/MeAjudaAi.E2E.Tests/packages.lock.json | 12 +++++----- .../packages.lock.json | 12 +++++----- .../Unit/Functional/ResultTests.cs | 2 +- 19 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index 263b77626..a9af552b8 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -68,7 +68,9 @@ private static void ConfigureTestingEnvironment(IDistributedApplicationBuilder b Console.Error.WriteLine("Please set MEAJUDAAI_DB_PASS to the database password in your CI environment."); Environment.Exit(1); } +#pragma warning disable S2068 testDbPassword = "test123"; +#pragma warning restore S2068 } var postgresql = builder.AddMeAjudaAiPostgreSQL(options => @@ -109,7 +111,9 @@ private static void ConfigureDevelopmentEnvironment(IDistributedApplicationBuild Console.Error.WriteLine("Please set DB_PASSWORD to the database password in your CI environment."); Environment.Exit(1); } +#pragma warning disable S2068 dbPassword = "test123"; +#pragma warning restore S2068 } var includePgAdminStr = Environment.GetEnvironmentVariable("INCLUDE_PGADMIN") ?? "true"; var includePgAdmin = !bool.TryParse(includePgAdminStr, out var pgAdminResult) || pgAdminResult; @@ -133,7 +137,9 @@ private static void ConfigureDevelopmentEnvironment(IDistributedApplicationBuild var keycloakSettings = new MeAjudaAi.AppHost.Options.MeAjudaAiKeycloakOptions { AdminUsername = "admin", +#pragma warning disable S2068 AdminPassword = "admin123", +#pragma warning restore S2068 DatabaseHost = "postgres-local", DatabasePort = "5432", DatabaseName = mainDatabase, diff --git a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json index 35fbb0a84..f448206e8 100644 --- a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json +++ b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json @@ -2,11 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Direct", "requested": "[13.1.0, )", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting.AppHost": { "type": "Direct", @@ -199,11 +199,11 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Direct", "requested": "[13.1.0, )", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "Aspire.Hosting.PostgreSQL": { "type": "Direct", diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 02e86332f..2dd19f2ee 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -42,12 +42,9 @@ public Task InvokeAsync(HttpContext context) var headers = ctx.Response.Headers; // Adiciona cabeçalhos de segurança estáticos eficientemente - foreach (var header in StaticHeaders) + foreach (var header in StaticHeaders.Where(h => !headers.ContainsKey(h.Key))) { - if (!headers.ContainsKey(header.Key)) - { - headers.Append(header.Key, header.Value); - } + headers.Append(header.Key, header.Value); } // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache diff --git a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs index c6cd36b2c..a59880c5f 100644 --- a/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/CreateUserCommandHandler.cs @@ -75,13 +75,13 @@ public async Task> HandleAsync( return Result.Failure(userResult.Error); // Persistir usuário no repositório - await PersistUserAsync(userResult.Value, stopwatch, cancellationToken); + await PersistUserAsync(userResult.Value!, stopwatch, cancellationToken); stopwatch.Stop(); logger.LogInformation("User {UserId} created successfully for email {Email} in {ElapsedMs}ms", - userResult.Value.Id, command.Email, stopwatch.ElapsedMilliseconds); + userResult.Value!.Id, command.Email, stopwatch.ElapsedMilliseconds); - return Result.Success(userResult.Value.ToDto()); + return Result.Success(userResult.Value!.ToDto()); } catch (ArgumentException) { diff --git a/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs index f488487a2..442343153 100644 --- a/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/DeleteUserCommandHandler.cs @@ -66,7 +66,7 @@ public async Task HandleAsync( if (userResult.IsFailure) return Result.Failure(userResult.Error); - var user = userResult.Value; + var user = userResult.Value!; // Sincronizar com Keycloak var syncResult = await SyncWithKeycloakAsync(user, cancellationToken); diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index e91cd10cd..48b5bc9e5 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -116,7 +116,7 @@ public async Task> HandleAsync(RegisterCustomerCommand command, try { - await userRepository.AddAsync(userResult.Value, cancellationToken); + await userRepository.AddAsync(userResult.Value!, cancellationToken); } catch (Exception ex) { @@ -127,12 +127,12 @@ public async Task> HandleAsync(RegisterCustomerCommand command, else { logger.LogError(ex, "Failed to persist customer {Email} ({Id}) to repository. Attempting Keycloak compensation.", - maskedEmail, userResult.Value.Id); + maskedEmail, userResult.Value!.Id); } // Verifica se o usuário realmente não foi salvo no repositório antes da compensação // Usamos CancellationToken.None para garantir que a compensação ocorra mesmo se o request original foi cancelado - var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value.Id, CancellationToken.None); + var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value!.Id, CancellationToken.None); if (persistenceCheck == null) { // Compensação: desativar o usuário criado no Keycloak para evitar usuário órfão "fantasma" que pode logar mas não tem dados locais @@ -166,8 +166,8 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(Error.Internal("Falha ao salvar o cadastro. Tente novamente mais tarde.")); } - logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value.Id); + logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value!.Id); - return Result.Success(userResult.Value.ToDto()); + return Result.Success(userResult.Value!.ToDto()); } } diff --git a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs index e04137924..c831db7ae 100644 --- a/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/UpdateUserProfileCommandHandler.cs @@ -62,7 +62,7 @@ public async Task> HandleAsync( if (userResult.IsFailure) return Result.Failure(userResult.Error); - var user = userResult.Value; + var user = userResult.Value!; // Aplicar atualização do perfil ApplyProfileUpdate(command, user); diff --git a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs index 196715b23..b904a40fb 100644 --- a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs +++ b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs @@ -95,7 +95,7 @@ public async Task> CreateUserAsync( // Atribui papéis se fornecidos if (roles.Any()) { - var roleAssignResult = await AssignRolesToUserAsync(keycloakUserId, roles, adminToken.Value, cancellationToken); + var roleAssignResult = await AssignRolesToUserAsync(keycloakUserId, roles, adminToken.Value!, cancellationToken); if (roleAssignResult.IsFailure) { logger.LogWarning("User created but role assignment failed: {Error}", roleAssignResult.Error); diff --git a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs index 0f76ff230..353625d33 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -63,7 +63,7 @@ public async Task> CreateUserAsync( return Result.Failure(keycloakResult.Error); // Cria a entidade User local com o ID retornado pelo Keycloak - var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value, phoneNumber); + var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value!, phoneNumber); if (userResult.IsFailure) { var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakResult.Value, CancellationToken.None); @@ -75,7 +75,7 @@ public async Task> CreateUserAsync( return Result.Failure(userResult.Error); } - return Result.Success(userResult.Value); + return Result.Success(userResult.Value!); } /// diff --git a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs index 7a4bfd926..d5c9448d8 100644 --- a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs @@ -31,7 +31,7 @@ public Task> CreateUserAsync( // Using UuidGenerator.NewId() for better time-based ordering and performance var userResult = User.Create(username, email, firstName, lastName, UuidGenerator.NewId().ToString(), phoneNumber); if (userResult.IsFailure) return Task.FromResult(Result.Failure(userResult.Error)); - return Task.FromResult(Result.Success(userResult.Value)); + return Task.FromResult(Result.Success(userResult.Value!)); } /// diff --git a/src/Modules/Users/Tests/Builders/UserBuilder.cs b/src/Modules/Users/Tests/Builders/UserBuilder.cs index 48308cbbe..e7a29419d 100644 --- a/src/Modules/Users/Tests/Builders/UserBuilder.cs +++ b/src/Modules/Users/Tests/Builders/UserBuilder.cs @@ -42,7 +42,7 @@ public UserBuilder() user.SetIdForTesting(new UserId(_id.Value)); } - return user; + return user!; }); } diff --git a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs index ad5909d6a..bf23bcb19 100644 --- a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs +++ b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs @@ -245,6 +245,6 @@ private static User CreateTestUser(string username, string email, string firstNa firstName: firstName, lastName: lastName, keycloakId: Guid.NewGuid().ToString() - ).Value; + ).Value!; } } diff --git a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs index 4883e68d1..ea51bdac9 100644 --- a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs +++ b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs @@ -409,6 +409,6 @@ private static User CreateTestUser(string firstName = "John", string lastName = firstName, lastName, Guid.NewGuid().ToString() - ).Value; + ).Value!; } } diff --git a/src/Shared/Database/DatabaseConstants.cs b/src/Shared/Database/DatabaseConstants.cs index a15944cfe..a2328375d 100644 --- a/src/Shared/Database/DatabaseConstants.cs +++ b/src/Shared/Database/DatabaseConstants.cs @@ -5,5 +5,7 @@ public static class DatabaseConstants /// /// String de conexão padrão para ambientes de teste e desenvolvimento local (fallback). /// +#pragma warning disable S2068 // "password" detected here, make sure this is not a hard-coded credential public const string DefaultTestConnectionString = "Host=localhost;Database=test;Username=test;Password=test"; +#pragma warning restore S2068 // "password" detected here, make sure this is not a hard-coded credential } diff --git a/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs b/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs index 070333bae..2438a5f06 100644 --- a/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs +++ b/src/Shared/Messaging/DeadLetter/RabbitMqDeadLetterService.cs @@ -294,14 +294,14 @@ public async Task GetDeadLetterStatisticsAsync(Cancellatio private async Task EnsureConnectionAsync() { - if (_disposed) throw new ObjectDisposedException(nameof(RabbitMqDeadLetterService)); + ObjectDisposedException.ThrowIf(_disposed, this); if (_connection?.IsOpen == true && _channel?.IsOpen == true) return; await _connectionSemaphore.WaitAsync(_disposeCts.Token); try { - if (_disposed) throw new ObjectDisposedException(nameof(RabbitMqDeadLetterService)); + ObjectDisposedException.ThrowIf(_disposed, this); if (_connection?.IsOpen == true && _channel?.IsOpen == true) return; diff --git a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json index c915cb485..8e99a3310 100644 --- a/src/Web/MeAjudaAi.Web.Admin/packages.lock.json +++ b/src/Web/MeAjudaAi.Web.Admin/packages.lock.json @@ -52,9 +52,9 @@ }, "Microsoft.AspNetCore.App.Internal.Assets": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mr3Zn+ht8lijYvlMIasftw9opU9hsLKDdnOgQMmYI3RjWPJLOF9l8+YHDseRkTs97wOrULmJgo/NDCmzL/EGDg==" + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "M942X5Vy726SlvFBuoAC4cDczEMlPAFt1mmyFlrkw/QcpdVwVU0DkF4P9JabxX6eWNm9RvaYZHe25FN7oXoxpQ==" }, "Microsoft.AspNetCore.Components.WebAssembly": { "type": "Direct", @@ -87,9 +87,9 @@ }, "Microsoft.DotNet.HotReload.WebAssembly.Browser": { "type": "Direct", - "requested": "[10.0.103, )", - "resolved": "10.0.103", - "contentHash": "tIq6T4oyBMjxxxllslJu2fOJqEj8SOW29IfWqHYxubreOVlFWnxPN2PjDMuwoHjkrOnPKyuqoNcVYh5YOZcs0Q==" + "requested": "[10.0.104, )", + "resolved": "10.0.104", + "contentHash": "OkWFygvFdm9emwxRW5ahcsU6saKO9EOYCFVNEXWl+23A4ssZy40VCQuqPhyurU7IyaPEhgA4iXh0/o7fhyNngA==" }, "Microsoft.Extensions.Http.Resilience": { "type": "Direct", @@ -104,15 +104,15 @@ }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.4, )", + "resolved": "10.0.4", + "contentHash": "CCx8ojW3mOL150/LnP0DK7qpMrJEt6xxNCmJFKoX89v1h0FwpsEHqennowGPYDxp6zIkIO4f9PxynjOeLF+1zw==" }, "Microsoft.NET.Sdk.WebAssembly.Pack": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "Wv9ugg75HxuYeBxJSoK2UPN6lpqsASkyZwd90bdRyVKOtaXZFaKg7R+dYqbZSHJXAIxC1HOcYR0imuIcKpI1yw==" + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "8XSP0Wu0xpj3xr88WwW43GiklXXiM27n6V8CXk35wT1MjxCfmudQJYmLbirWiPiqU1vCIWTIczPpI40ivC3ruQ==" }, "MudBlazor": { "type": "Direct", diff --git a/tests/MeAjudaAi.E2E.Tests/packages.lock.json b/tests/MeAjudaAi.E2E.Tests/packages.lock.json index afda9b7ea..40f847229 100644 --- a/tests/MeAjudaAi.E2E.Tests/packages.lock.json +++ b/tests/MeAjudaAi.E2E.Tests/packages.lock.json @@ -144,10 +144,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting": { "type": "Transitive", @@ -374,10 +374,10 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -1609,13 +1609,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.1.0, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.1.0, )", "Aspire.Hosting.AppHost": "[13.1.0, )", "Aspire.Hosting.Azure.AppContainers": "[13.1.2, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.JavaScript": "[13.1.2, )", "Aspire.Hosting.Keycloak": "[13.1.0-preview.1.25616.3, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.1.0, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.1.0, )", "Aspire.Hosting.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.RabbitMQ": "[13.1.2, )", "Aspire.Hosting.Redis": "[13.1.2, )", diff --git a/tests/MeAjudaAi.Integration.Tests/packages.lock.json b/tests/MeAjudaAi.Integration.Tests/packages.lock.json index 332c263f6..60dabdae8 100644 --- a/tests/MeAjudaAi.Integration.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Integration.Tests/packages.lock.json @@ -200,10 +200,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "gIvMR7NdVGw+mUpK9qZsGuuYfDp4CHvBDLlykSSP9pCh5XGlMgDNR3uOMfvKVlqH1hAsOuecvxEcfQ9kjufrWw==" + "contentHash": "rrcsI8cankYCiUlj4Ev+os9uKcutUCv+9kvHQt85RiUX/ewXsloFZy0/depKWrzdJkdJuoTbYFRlSe43TKq6aQ==" }, "Aspire.Hosting": { "type": "Transitive", @@ -430,10 +430,10 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.1.0", - "contentHash": "V8u8ukncoflImciXeHG03x1pWbOiR4z1bIl2lwOKgdy7/JshFrlEaabaNuGmAIZFdWEM/fsc/hVDjecQyHF2aQ==" + "contentHash": "3w2UahEauTq719LPJ/BCySh31kz26sfjuOkRF5E4VYy1Q3xLRV43+OIGI3C5sy8feUHjrYz+DDq3DQn/2fu+4g==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -2528,13 +2528,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.1.0, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.1.0, )", "Aspire.Hosting.AppHost": "[13.1.0, )", "Aspire.Hosting.Azure.AppContainers": "[13.1.2, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.JavaScript": "[13.1.2, )", "Aspire.Hosting.Keycloak": "[13.1.0-preview.1.25616.3, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.1.0, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.1.0, )", "Aspire.Hosting.PostgreSQL": "[13.1.2, )", "Aspire.Hosting.RabbitMQ": "[13.1.2, )", "Aspire.Hosting.Redis": "[13.1.2, )", diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs index a5c22d992..534aa410a 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Functional/ResultTests.cs @@ -148,7 +148,7 @@ public void Constructor_WithValidParameters_ShouldCreateResultCorrectly() // Act var successResult = new Result(true, value, null); - var failureResult = new Result(false, default, error); + var failureResult = new Result(false, default!, error); // Assert successResult.IsSuccess.Should().BeTrue(); From 6647285735ccaa92120363181b00f86403eacc6f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 12:57:16 -0300 Subject: [PATCH 12/23] feat: Implement customer registration with Keycloak integration, add provider query handlers, a searchable provider entity, and security headers middleware. --- src/Aspire/MeAjudaAi.AppHost/Program.cs | 1 + .../Middlewares/SecurityHeadersMiddleware.cs | 9 ++- .../Queries/GetProviderByIdQueryHandler.cs | 3 +- ...GetPublicProviderByIdOrSlugQueryHandler.cs | 9 +-- .../Domain/Constants/ProviderErrors.cs | 6 ++ ...blicProviderByIdOrSlugQueryHandlerTests.cs | 38 +++++++++- .../Domain/Entities/SearchableProvider.cs | 2 +- .../RegisterCustomerCommandHandler.cs | 26 ++++--- .../Services/KeycloakUserDomainService.cs | 7 +- .../Users/Tests/Unit/API/ExtensionsTests.cs | 71 ++++++++++++++----- 10 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 src/Modules/Providers/Domain/Constants/ProviderErrors.cs diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index a9af552b8..567891c9d 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -68,6 +68,7 @@ private static void ConfigureTestingEnvironment(IDistributedApplicationBuilder b Console.Error.WriteLine("Please set MEAJUDAAI_DB_PASS to the database password in your CI environment."); Environment.Exit(1); } +// Suppress S2068: intentional hardcoded dev/test default credential, only used in local/dev scenarios; CI requires env var #pragma warning disable S2068 testDbPassword = "test123"; #pragma warning restore S2068 diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 2dd19f2ee..09241510f 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -42,10 +42,15 @@ public Task InvokeAsync(HttpContext context) var headers = ctx.Response.Headers; // Adiciona cabeçalhos de segurança estáticos eficientemente - foreach (var header in StaticHeaders.Where(h => !headers.ContainsKey(h.Key))) +#pragma warning disable S3267 // Loops should be simplified with "Where" LINQ method - avoiding LINQ allocations on hot path as requested + foreach (var header in StaticHeaders) { - headers.Append(header.Key, header.Value); + if (!headers.ContainsKey(header.Key)) + { + headers.Append(header.Key, header.Value); + } } +#pragma warning restore S3267 // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache if (ctx.Request.IsHttps && !_isDevelopment && !headers.ContainsKey(HstsHeaderName)) diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs index 1fa38393c..9fd515522 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetProviderByIdQueryHandler.cs @@ -3,6 +3,7 @@ using MeAjudaAi.Modules.Providers.Application.Queries; using MeAjudaAi.Modules.Providers.Domain.Repositories; using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Modules.Providers.Domain.Constants; using MeAjudaAi.Contracts.Functional; using MeAjudaAi.Shared.Queries; using Microsoft.Extensions.Logging; @@ -34,7 +35,7 @@ ILogger logger if (provider == null) { logger.LogWarning("Provider {ProviderId} not found", query.ProviderId); - return Result.Failure(Error.NotFound("Prestador não encontrado")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } logger.LogInformation("Provider {ProviderId} found successfully", query.ProviderId); diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 5415b5c23..59d1ed176 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs @@ -8,6 +8,7 @@ using MeAjudaAi.Modules.Providers.Domain.ValueObjects; using MeAjudaAi.Shared.Queries; using MeAjudaAi.Shared.Utilities.Constants; +using MeAjudaAi.Modules.Providers.Domain.Constants; using Microsoft.FeatureManagement; using System.Collections.Generic; using System.Linq; @@ -49,14 +50,14 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos if (provider is null) { - return Result.Failure(Error.NotFound("Prestador não encontrado.")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } // Validação adicional: Apenas prestadores ativos devem ser consultados publicamente // Se estiver suspenso ou rejeitado, retornamos NotFound por segurança/privacidade if (provider.Status != EProviderStatus.Active) { - return Result.Failure(Error.NotFound("Prestador não encontrado.")); + return Result.Failure(Error.NotFound(ProviderErrors.ProviderNotFound)); } var businessProfile = provider.BusinessProfile; @@ -103,9 +104,9 @@ public GetPublicProviderByIdOrSlugQueryHandler(IProviderRepository providerRepos return Result.Success(dto); } - private static IEnumerable ResolvePhoneNumbers(bool isPrivacyEnabled, BusinessProfile profile) + private static IEnumerable ResolvePhoneNumbers(bool shouldRedactContactInfo, BusinessProfile profile) { - if (isPrivacyEnabled || profile.ContactInfo is null) + if (shouldRedactContactInfo || profile.ContactInfo is null) return Array.Empty(); if (string.IsNullOrWhiteSpace(profile.ContactInfo.PhoneNumber)) diff --git a/src/Modules/Providers/Domain/Constants/ProviderErrors.cs b/src/Modules/Providers/Domain/Constants/ProviderErrors.cs new file mode 100644 index 000000000..853e584ef --- /dev/null +++ b/src/Modules/Providers/Domain/Constants/ProviderErrors.cs @@ -0,0 +1,6 @@ +namespace MeAjudaAi.Modules.Providers.Domain.Constants; + +public static class ProviderErrors +{ + public const string ProviderNotFound = "Prestador não encontrado."; +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index cad2ca19b..de25a88db 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -219,7 +219,7 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) .ReturnsAsync(true); - var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()) { IsAuthenticated = true }; // Act var result = await _handler.HandleAsync(query, CancellationToken.None); @@ -233,6 +233,42 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro result.Value.Services.Should().BeEmpty(); } + [Fact] + public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldReturnFullContactInfo() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithBusinessProfile(new BusinessProfile( + "Restricted Legal", + new ContactInfo("privacy@test.com", "11999999999"), + new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), + "Restricted Fantasy", + "Description")) + .Build(); + + SetProviderStatus(provider, EProviderStatus.Active); + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) + .ReturnsAsync(provider); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(false); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()) { IsAuthenticated = true }; + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Email.Should().Be("privacy@test.com"); + result.Value.PhoneNumbers.Should().NotBeEmpty(); + } + private static void SetProviderStatus(Provider provider, EProviderStatus status) { var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index e39120d7b..990f3ad72 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -164,7 +164,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + Slug = !string.IsNullOrWhiteSpace(slug) ? slug : SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), Description = description, City = city, State = state, diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index 48b5bc9e5..1cbac30f9 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -114,9 +114,17 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(userResult.Error); } + if (userResult.Value is null) + { + logger.LogCritical("User returned null from success result for {Email}", maskedEmail); + return Result.Failure(Error.Internal("Falha crítica ao criar o usuário. Dados nulos retornados.")); + } + + var user = userResult.Value; + try { - await userRepository.AddAsync(userResult.Value!, cancellationToken); + await userRepository.AddAsync(user, cancellationToken); } catch (Exception ex) { @@ -127,37 +135,37 @@ public async Task> HandleAsync(RegisterCustomerCommand command, else { logger.LogError(ex, "Failed to persist customer {Email} ({Id}) to repository. Attempting Keycloak compensation.", - maskedEmail, userResult.Value!.Id); + maskedEmail, user.Id); } // Verifica se o usuário realmente não foi salvo no repositório antes da compensação // Usamos CancellationToken.None para garantir que a compensação ocorra mesmo se o request original foi cancelado - var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value!.Id, CancellationToken.None); + var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(user.Id, CancellationToken.None); if (persistenceCheck == null) { // Compensação: desativar o usuário criado no Keycloak para evitar usuário órfão "fantasma" que pode logar mas não tem dados locais try { - var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(userResult.Value.Id, CancellationToken.None); + var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(user.Id, CancellationToken.None); if (compensationResult.IsFailure) { logger.LogError("Compensation failed for user {UserId}: {Error}", userResult.Value.Id, compensationResult.Error); } else { - logger.LogInformation("Keycloak user {UserId} deactivated successfully as compensation.", userResult.Value.Id); + logger.LogInformation("Keycloak user {UserId} deactivated successfully as compensation.", user.Id); } } catch (Exception compensationEx) { logger.LogCritical(compensationEx, "CRITICAL: Failed to compensate Keycloak user {UserId} after repository failure. Manual cleanup required.", - userResult.Value.Id); + user.Id); } } else { - logger.LogWarning("Repository write failure reported but user {UserId} was found in DB. Skipping Keycloak compensation.", userResult.Value.Id); + logger.LogWarning("Repository write failure reported but user {UserId} was found in DB. Skipping Keycloak compensation.", user.Id); } if (ex is OperationCanceledException) @@ -166,8 +174,8 @@ public async Task> HandleAsync(RegisterCustomerCommand command, return Result.Failure(Error.Internal("Falha ao salvar o cadastro. Tente novamente mais tarde.")); } - logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value!.Id); + logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, user.Id); - return Result.Success(userResult.Value!.ToDto()); + return Result.Success(user.ToDto()); } } diff --git a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs index 353625d33..3e828f8d3 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -63,13 +63,14 @@ public async Task> CreateUserAsync( return Result.Failure(keycloakResult.Error); // Cria a entidade User local com o ID retornado pelo Keycloak - var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value!, phoneNumber); + var keycloakId = keycloakResult.Value!; + var userResult = User.Create(username, email, firstName, lastName, keycloakId, phoneNumber); if (userResult.IsFailure) { - var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakResult.Value, CancellationToken.None); + var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakId, CancellationToken.None); if (deactivationResult.IsFailure) { - logger.LogWarning("Failed to deactivate Keycloak user {KeycloakId} during compensation for local user creation failure. Error: {Error}", keycloakResult.Value, deactivationResult.Error.Message); + logger.LogWarning("Failed to deactivate Keycloak user {KeycloakId} during compensation for local user creation failure. Error: {Error}", keycloakId, deactivationResult.Error.Message); // Silenciar falhas de compensação para evitar mascarar o erro de validação original } return Result.Failure(userResult.Error); diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index f16de251a..db09de8af 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -13,6 +13,42 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; [Trait("Layer", "API")] public class ExtensionsTests { + private static IConfiguration BuildTestConfiguration() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + }) + .Build(); + } + + [Fact] + public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + var configuration = BuildTestConfiguration(); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw().WithParameterName("services"); + } + + [Fact] + public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + IConfiguration configuration = null!; + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw().WithParameterName("configuration"); + } + [Fact] public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() { @@ -51,11 +87,22 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw() - .WithMessage("Connection for Users module not configured"); + var originalEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + // Força ambiente não teste/dev para testar o fallback de exception + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw() + .WithMessage("Connection for Users module not configured"); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnv); + } } [Fact] @@ -63,12 +110,7 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" - }) - .Build(); + var configuration = BuildTestConfiguration(); // Act var result = services.AddUsersModule(configuration); @@ -111,12 +153,7 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" - }) - .Build(); + var configuration = BuildTestConfiguration(); // Act var result = services.AddUsersModule(configuration); From b3337f16868305385db7836f5217fd4916614787 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 13:47:10 -0300 Subject: [PATCH 13/23] feat: Add unit tests for GetPublicProviderByIdOrSlugQueryHandler, introduce SearchableProvider entity, and implement RegisterCustomerCommandHandler with related API extension tests. --- ...tPublicProviderByIdOrSlugQueryHandlerTests.cs | 16 ++++++++++------ .../Domain/Entities/SearchableProvider.cs | 16 +++++++++++++--- .../Commands/RegisterCustomerCommandHandler.cs | 2 +- .../Users/Tests/Unit/API/ExtensionsTests.cs | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index de25a88db..45f20dc04 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -36,7 +36,7 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status como Active diretamente no teste SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock @@ -105,7 +105,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) .ReturnsAsync(provider); - // Query uses uppercase slug — handler must normalize before calling GetBySlugAsync + // Query usa slug em maiúsculas — o handler deve normalizar antes de chamar GetBySlugAsync var query = new GetPublicProviderByIdOrSlugQuery(upperSlug); // Act @@ -121,7 +121,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize [Fact] public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() { - // Arrange — slug that happens to be a valid GUID format (Guid.TryParse returns true) + // Arrange — slug que possui um formato de GUID válido (Guid.TryParse retorna true) var slugGuid = Guid.NewGuid(); var slugValue = slugGuid.ToString().ToLowerInvariant(); // e.g. "3fa85f64-5717-4562-b3fc-2c963f66afa6" @@ -133,12 +133,12 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook // Bypass domain transitions to set Active status directly for test SetProviderStatus(provider, EProviderStatus.Active); - // GetByIdAsync returns null (no provider with that ID) + // GetByIdAsync retorna null (nenhum provedor com esse ID) _providerRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((Provider?)null); - // Fallback to slug lookup returns the provider + // Fallback para a busca por slug retorna o provedor _providerRepositoryMock .Setup(x => x.GetBySlugAsync(slugValue, It.IsAny())) .ReturnsAsync(provider); @@ -160,7 +160,7 @@ public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() // Arrange var provider = ProviderBuilder.Create() .Build(); - // Default builder status is PendingBasicInfo (not Active) + // O status padrão do builder é PendingBasicInfo (não Active) _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -246,6 +246,9 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu "Description")) .Build(); + var expectedServiceId = Guid.NewGuid(); + provider.AddService(expectedServiceId, "Known Service"); + SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock @@ -267,6 +270,7 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value!.Id.Should().Be(provider.Id); result.Value.Email.Should().Be("privacy@test.com"); result.Value.PhoneNumbers.Should().NotBeEmpty(); + result.Value.Services.Should().Contain(s => s.ServiceId == expectedServiceId); } private static void SetProviderStatus(Provider provider, EProviderStatus status) diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 990f3ad72..8a7109cb4 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -110,7 +110,7 @@ public static SearchableProvider Create( { if (string.IsNullOrWhiteSpace(name)) { - throw new ArgumentException("Provider name cannot be empty.", nameof(name)); + throw new ArgumentException("O nome do provedor não pode ficar vazio.", nameof(name)); } slug = NormalizeAndValidateSlug(slug); @@ -157,6 +157,16 @@ internal static SearchableProvider Reconstitute( string? city = null, string? state = null) { + string normalizedSlug; + try + { + normalizedSlug = NormalizeAndValidateSlug(slug); + } + catch (ArgumentException) + { + normalizedSlug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + } + var searchableProvider = new SearchableProvider( new SearchableProviderId(id), providerId, @@ -164,7 +174,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { - Slug = !string.IsNullOrWhiteSpace(slug) ? slug : SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]), + Slug = normalizedSlug, Description = description, City = city, State = state, @@ -184,7 +194,7 @@ public void UpdateBasicInfo(string name, string slug, string? description, strin { if (string.IsNullOrWhiteSpace(name)) { - throw new ArgumentException("Provider name cannot be empty.", nameof(name)); + throw new ArgumentException("O nome do provedor não pode ficar vazio.", nameof(name)); } slug = NormalizeAndValidateSlug(slug); diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs index 1cbac30f9..647fcd3ef 100644 --- a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -149,7 +149,7 @@ public async Task> HandleAsync(RegisterCustomerCommand command, var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(user.Id, CancellationToken.None); if (compensationResult.IsFailure) { - logger.LogError("Compensation failed for user {UserId}: {Error}", userResult.Value.Id, compensationResult.Error); + logger.LogError("Compensation failed for user {UserId}: {Error}", user.Id, compensationResult.Error); } else { diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index db09de8af..92dbd65ab 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -8,7 +8,7 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// Testes de integração dos métodos de extensão do módulo Users /// Foca em cenários de integração e configuração completa /// -[Trait("Category", "Integration")] +[Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] public class ExtensionsTests From 76d4ae91e1727f5b382cace4254f411561f9cac2 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 13:53:20 -0300 Subject: [PATCH 14/23] feat: Add slug field to Provider and SearchableProvider entities. --- ...20260318164815_AddProviderSlug.Designer.cs | 429 ++++++++++++++++++ .../20260318164815_AddProviderSlug.cs | 87 ++++ .../ProvidersDbContextModelSnapshot.cs | 17 +- ...4839_AddSearchableProviderSlug.Designer.cs | 136 ++++++ ...0260318164839_AddSearchableProviderSlug.cs | 22 + 5 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs create mode 100644 src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs create mode 100644 src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs new file mode 100644 index 000000000..7257ba8f7 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.Designer.cs @@ -0,0 +1,429 @@ +// +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("20260318164815_AddProviderSlug")] + partial class AddProviderSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "10.0.5") + .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("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + + b.Property("Tier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Standard") + .HasColumnName("tier"); + + 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") + .HasName("pk_providers"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Slug") + .HasDatabaseName("ix_providers_slug"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + 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.ProviderService", b => + { + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.HasKey("ProviderId", "ServiceId") + .HasName("pk_provider_services"); + + b.HasIndex("ServiceId") + .HasDatabaseName("ix_provider_services_service_id"); + + b.ToTable("provider_services", "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") + .HasColumnName("id"); + + 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") + .HasConstraintName("fk_providers_providers_id"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + 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") + .HasConstraintName("fk_providers_providers_id"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("AdditionalPhoneNumbers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_phone_numbers"); + + 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") + .HasConstraintName("fk_providers_providers_id"); + }); + + 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("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b1.Property("FileUrl") + .HasColumnType("text") + .HasColumnName("file_url"); + + 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") + .HasName("pk_document"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique() + .HasDatabaseName("ix_document_provider_id_document_type"); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_document_providers_provider_id"); + }); + + 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") + .HasName("pk_qualification"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualification_providers_provider_id"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.HasOne("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", "Provider") + .WithMany("Services") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_provider_services_providers_provider_id"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Navigation("Services"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs new file mode 100644 index 000000000..16cb6d5fc --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260318164815_AddProviderSlug.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProviderSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "slug", + schema: "providers", + table: "providers", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AlterColumn( + name: "file_url", + schema: "providers", + table: "document", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(2048)", + oldMaxLength: 2048, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "file_name", + schema: "providers", + table: "document", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255, + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "ix_providers_slug", + schema: "providers", + table: "providers", + column: "slug"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_providers_slug", + schema: "providers", + table: "providers"); + + migrationBuilder.DropColumn( + name: "slug", + schema: "providers", + table: "providers"); + + migrationBuilder.AlterColumn( + name: "file_url", + schema: "providers", + table: "document", + type: "character varying(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "file_name", + schema: "providers", + table: "document", + type: "character varying(255)", + maxLength: 255, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs index 12aaba786..cb25f63d8 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("providers") - .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -52,6 +52,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(1000)") .HasColumnName("rejection_reason"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + b.Property("Status") .IsRequired() .HasMaxLength(30) @@ -100,6 +106,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .HasDatabaseName("ix_providers_name"); + b.HasIndex("Slug") + .HasDatabaseName("ix_providers_slug"); + b.HasIndex("Status") .HasDatabaseName("ix_providers_status"); @@ -302,13 +311,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("document_type"); b1.Property("FileName") - .HasMaxLength(255) - .HasColumnType("character varying(255)") + .HasColumnType("text") .HasColumnName("file_name"); b1.Property("FileUrl") - .HasMaxLength(2048) - .HasColumnType("character varying(2048)") + .HasColumnType("text") .HasColumnName("file_url"); b1.Property("IsPrimary") diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs new file mode 100644 index 000000000..996e03544 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SearchProvidersDbContext))] + [Migration("20260318164839_AddSearchableProviderSlug")] + partial class AddSearchableProviderSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("search_providers") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("numeric(3,2)") + .HasColumnName("average_rating"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography(Point, 4326)") + .HasColumnName("location"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.PrimitiveCollection("ServiceIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("service_ids"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("slug"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("state"); + + b.Property("SubscriptionTier") + .HasColumnType("integer") + .HasColumnName("subscription_tier"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_searchable_providers"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_searchable_providers_is_active"); + + b.HasIndex("Location") + .HasDatabaseName("ix_searchable_providers_location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.HasIndex("ProviderId") + .IsUnique() + .HasDatabaseName("ix_searchable_providers_provider_id"); + + b.HasIndex("ServiceIds") + .HasDatabaseName("ix_searchable_providers_service_ids"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("ServiceIds"), "gin"); + + b.HasIndex("SubscriptionTier") + .HasDatabaseName("ix_searchable_providers_subscription_tier"); + + b.HasIndex("IsActive", "SubscriptionTier", "AverageRating") + .HasDatabaseName("ix_searchable_providers_search_ranking"); + + b.ToTable("searchable_providers", "search_providers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs new file mode 100644 index 000000000..f27041e4c --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Migrations/20260318164839_AddSearchableProviderSlug.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSearchableProviderSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} From 0dea38861f3fd37157689f62a464c21de013a64f Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 14:00:22 -0300 Subject: [PATCH 15/23] feat: Introduce `SearchableProvider` entity and enhance public provider retrieval by ID or slug with privacy controls. --- ...tPublicProviderByIdOrSlugQueryHandlerTests.cs | 2 +- .../Domain/Entities/SearchableProvider.cs | 16 ++++++++-------- .../Users/Tests/Unit/API/ExtensionsTests.cs | 11 ++++++++--- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 45f20dc04..6d67d9dd9 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -183,7 +183,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() var providerId = Guid.NewGuid(); _providerRepositoryMock - .Setup(x => x.GetByIdAsync(providerId, It.IsAny())) + .Setup(x => x.GetByIdAsync(new ProviderId(providerId), It.IsAny())) .ReturnsAsync((Provider?)null); var query = new GetPublicProviderByIdOrSlugQuery(providerId.ToString()); diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 8a7109cb4..7809fbde8 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -157,12 +157,7 @@ internal static SearchableProvider Reconstitute( string? city = null, string? state = null) { - string normalizedSlug; - try - { - normalizedSlug = NormalizeAndValidateSlug(slug); - } - catch (ArgumentException) + if (!TryNormalizeSlug(slug, out var normalizedSlug)) { normalizedSlug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); } @@ -288,12 +283,17 @@ public double CalculateDistanceToInKm(GeoPoint targetLocation) private static string NormalizeAndValidateSlug(string? slug) { - var normalized = SlugHelper.Generate(slug ?? string.Empty); - if (string.IsNullOrWhiteSpace(normalized)) + if (!TryNormalizeSlug(slug, out var normalized)) { throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); } return normalized; } + + private static bool TryNormalizeSlug(string? slug, out string normalizedSlug) + { + normalizedSlug = SlugHelper.Generate(slug ?? string.Empty); + return !string.IsNullOrWhiteSpace(normalizedSlug); + } } diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 92dbd65ab..3a749591a 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -5,12 +5,13 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// -/// Testes de integração dos métodos de extensão do módulo Users +/// Testes unitários dos métodos de extensão do módulo Users /// Foca em cenários de integração e configuração completa /// [Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] +[Collection("NonParallel Environment Tests")] public class ExtensionsTests { private static IConfiguration BuildTestConfiguration() @@ -87,11 +88,14 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalDotnetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try { // Força ambiente não teste/dev para testar o fallback de exception Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -101,7 +105,8 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc } finally { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnv); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotnetEnv); } } From 22aecb75bc5e6d9daa857beac393cc58787f3537 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 14:08:31 -0300 Subject: [PATCH 16/23] test: add unit tests for GetPublicProviderByIdOrSlugQueryHandler covering active/inactive providers, ID/slug queries, and privacy feature flag scenarios. --- .../Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 6d67d9dd9..b70040e70 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -270,7 +270,7 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value!.Id.Should().Be(provider.Id); result.Value.Email.Should().Be("privacy@test.com"); result.Value.PhoneNumbers.Should().NotBeEmpty(); - result.Value.Services.Should().Contain(s => s.ServiceId == expectedServiceId); + result.Value.Services.Should().Contain("Known Service"); } private static void SetProviderStatus(Provider provider, EProviderStatus status) From 0830eaa137a6c8836960d937df13c46aa2dd4516 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 18:14:12 -0300 Subject: [PATCH 17/23] feat: Introduce SearchableProvider entity and add comprehensive unit tests for public provider retrieval, including privacy feature handling. --- .../GetPublicProviderByIdOrSlugQueryHandlerTests.cs | 9 ++++++--- .../Domain/Entities/SearchableProvider.cs | 2 +- .../Unit/Domain/Entities/SearchableProviderTests.cs | 6 +++--- src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs | 5 ++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index b70040e70..39c6c5d62 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -65,7 +65,7 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -95,7 +95,7 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); @@ -130,7 +130,7 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook .WithVerificationStatus(EVerificationStatus.Verified) .Build(); - // Bypass domain transitions to set Active status directly for test + // Ignora transições de domínio para definir o status Active diretamente para o teste SetProviderStatus(provider, EProviderStatus.Active); // GetByIdAsync retorna null (nenhum provedor com esse ID) @@ -152,6 +152,9 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.Id.Should().Be(provider.Id); + + _providerRepositoryMock.Verify(x => x.GetByIdAsync(It.Is(p => p.Value == slugGuid), It.IsAny()), Times.Once); + _providerRepositoryMock.Verify(x => x.GetBySlugAsync(slugValue, It.IsAny()), Times.Once); } [Fact] diff --git a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs index 7809fbde8..ab95fefb9 100644 --- a/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs +++ b/src/Modules/SearchProviders/Domain/Entities/SearchableProvider.cs @@ -285,7 +285,7 @@ private static string NormalizeAndValidateSlug(string? slug) { if (!TryNormalizeSlug(slug, out var normalized)) { - throw new ArgumentException("O identificador do provedor não pode estar vazio.", nameof(slug)); + throw new ArgumentException("O identificador do provedor não pode estar vazio nem em formato inválido.", nameof(slug)); } return normalized; diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index deb375462..c3d22b2bf 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -60,7 +60,7 @@ public void Create_WithEmptyName_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*Provider name cannot be empty*"); + .WithMessage("*O nome do provedor não pode ficar vazio*"); } [Fact] @@ -89,7 +89,7 @@ public void Create_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*O identificador do provedor não pode estar vazio.*"); + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] @@ -131,7 +131,7 @@ public void UpdateBasicInfo_WithEmptySlug_ShouldThrowArgumentException() // Assert act.Should().Throw() - .WithMessage("*O identificador do provedor não pode estar vazio.*"); + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 3a749591a..00f4dc818 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; /// /// Testes unitários dos métodos de extensão do módulo Users -/// Foca em cenários de integração e configuração completa +/// Foca em cenários unitários e configuração completa /// [Trait("Category", "Unit")] [Trait("Module", "Users")] @@ -227,3 +227,6 @@ public void AddUsersModule_WithCompleteConfiguration_ShouldBuildServiceProvider( Assert.NotNull(serviceProvider); } } + +[CollectionDefinition("NonParallel Environment Tests", DisableParallelization = true)] +public class NonParallelEnvironmentTestsCollection { } From 6dc9cb0d8f9d50583fb5128f3232ab13d6bd64ad Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Wed, 18 Mar 2026 18:37:50 -0300 Subject: [PATCH 18/23] test: add ProviderBuilder and unit tests for GetPublicProviderByIdOrSlugQueryHandler and SearchableProvider entity mapping --- .../Tests/Builders/ProviderBuilder.cs | 18 +++++ ...blicProviderByIdOrSlugQueryHandlerTests.cs | 67 ++++++++++++------- .../Entities/SearchableProviderTests.cs | 15 +++++ 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index d87d5ab01..0aa3d00da 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -16,6 +16,7 @@ public class ProviderBuilder : BaseBuilder private ProviderId? _providerId; private EVerificationStatus? _verificationStatus; private EProviderTier? _tier; + private EProviderStatus? _status; private readonly List _documents = new(); private readonly List _qualifications = new(); @@ -80,6 +81,17 @@ public ProviderBuilder() prop.SetValue(provider, _tier.Value); } + // Define status se especificado + if (_status.HasValue) + { + var prop = typeof(Provider).GetProperty(nameof(Provider.Status)); + if (prop == null) + { + throw new InvalidOperationException($"Property '{nameof(Provider.Status)}' was not found on class {nameof(Provider)}."); + } + prop.SetValue(provider, _status.Value); + } + return provider; }); } @@ -90,6 +102,12 @@ public ProviderBuilder WithTier(EProviderTier tier) return this; } + public ProviderBuilder WithStatus(EProviderStatus status) + { + _status = status; + return this; + } + public ProviderBuilder WithUserId(Guid userId) { _userId = userId; diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 39c6c5d62..82354d4b8 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -34,11 +34,10 @@ public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificati var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status como Active diretamente no teste - SetProviderStatus(provider, EProviderStatus.Active); - _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); @@ -63,11 +62,10 @@ public async Task HandleAsync_WhenProviderQueriedBySlug_ShouldReturnDto() var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); _providerRepositoryMock @@ -93,11 +91,10 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); var upperSlug = provider.Slug.ToUpperInvariant(); @@ -118,6 +115,37 @@ public async Task HandleAsync_WhenProviderQueriedByUpperCaseSlug_ShouldNormalize result.Value.Slug.Should().Be(provider.Slug); } + [Fact] + public async Task HandleAsync_WhenProviderQueriedBySlugWithLeadingTrailingSpaces_ShouldNormalizeAndReturnDto() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithType(EProviderType.Individual) + .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) + .Build(); + + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + var dirtySlug = $" {provider.Slug.ToUpperInvariant()} "; + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) + .ReturnsAsync(provider); + + // Query usa slug com espaços em claro e em maiúsculas — o handler deve normalizar (Trim e ToLower) antes de buscar + var query = new GetPublicProviderByIdOrSlugQuery(dirtySlug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(provider.Id); + result.Value.Slug.Should().Be(provider.Slug); + } + [Fact] public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLookup() { @@ -128,11 +156,10 @@ public async Task HandleAsync_WhenSlugIsValidGuidString_ShouldFallbackToSlugLook var provider = ProviderBuilder.Create() .WithType(EProviderType.Individual) .WithVerificationStatus(EVerificationStatus.Verified) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Ignora transições de domínio para definir o status Active diretamente para o teste - SetProviderStatus(provider, EProviderStatus.Active); - // GetByIdAsync retorna null (nenhum provedor com esse ID) _providerRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) @@ -209,11 +236,10 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), "Restricted Fantasy", "Description")) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); - // Bypass domain transitions to set Active status directly for test - SetProviderStatus(provider, EProviderStatus.Active); - _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); @@ -247,12 +273,12 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu new Address("Street", "1", "Neighborhood", "City", "ST", "00000-000", "Country"), "Restricted Fantasy", "Description")) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) .Build(); var expectedServiceId = Guid.NewGuid(); provider.AddService(expectedServiceId, "Known Service"); - - SetProviderStatus(provider, EProviderStatus.Active); _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) @@ -275,11 +301,4 @@ public async Task HandleAsync_WhenAuthenticatedAndPrivacyFlagDisabled_ShouldRetu result.Value.PhoneNumbers.Should().NotBeEmpty(); result.Value.Services.Should().Contain("Known Service"); } - - private static void SetProviderStatus(Provider provider, EProviderStatus status) - { - var statusProp = typeof(Provider).GetProperty(nameof(Provider.Status)); - statusProp.Should().NotBeNull("Provider.Status property must exist"); - statusProp!.SetValue(provider, status); - } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index c3d22b2bf..dd5f76d38 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -106,6 +106,21 @@ public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() act.Should().Throw(); } + [Fact] + public void Create_WithSpecialCharactersSlug_ShouldThrowArgumentException() + { + // Arrange + var providerId = Guid.NewGuid(); + var location = new GeoPoint(-23.5505, -46.6333); + + // Act + var act = () => SearchableProvider.Create(providerId, "Valid Name", "!!!", location); + + // Assert + act.Should().Throw() + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); + } + [Fact] public void Create_WithUppercaseSlug_ShouldNormalizeToLowercase() { From 5abf6211dc1f730188375e2317437fbd11ffd104 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 09:36:46 -0300 Subject: [PATCH 19/23] test: Add unit and end-to-end tests for Users, Providers, and SearchProviders modules, including cross-module workflows. --- .../Tests/Builders/ProviderBuilder.cs | 24 ++++----- ...blicProviderByIdOrSlugQueryHandlerTests.cs | 49 +++++++++++++++++++ .../Users/Tests/Unit/API/ExtensionsTests.cs | 3 +- ...oviderServiceCatalogSearchWorkflowTests.cs | 24 ++++++--- .../SearchProvidersEndToEndTests.cs | 6 ++- 5 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index 0aa3d00da..cc372879c 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -73,23 +73,13 @@ public ProviderBuilder() // Define tier se especificado if (_tier.HasValue) { - var prop = typeof(Provider).GetProperty(nameof(Provider.Tier)); - if (prop == null) - { - throw new InvalidOperationException($"Property '{nameof(Provider.Tier)}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, _tier.Value); + SetPropertyValue(provider, nameof(Provider.Tier), _tier.Value); } // Define status se especificado if (_status.HasValue) { - var prop = typeof(Provider).GetProperty(nameof(Provider.Status)); - if (prop == null) - { - throw new InvalidOperationException($"Property '{nameof(Provider.Status)}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, _status.Value); + SetPropertyValue(provider, nameof(Provider.Status), _status.Value); } return provider; @@ -234,4 +224,14 @@ private static BusinessProfile CreateDefaultBusinessProfile(Faker faker) contactInfo: contactInfo, primaryAddress: address); } + + private static void SetPropertyValue(Provider provider, string propertyName, object value) + { + var prop = typeof(Provider).GetProperty(propertyName); + if (prop == null) + { + throw new InvalidOperationException($"Property '{propertyName}' was not found on class {nameof(Provider)}."); + } + prop.SetValue(provider, value); + } } diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs index 82354d4b8..3ef8d2427 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -24,6 +24,11 @@ public GetPublicProviderByIdOrSlugQueryHandlerTests() { _providerRepositoryMock = new Mock(); _featureManagerMock = new Mock(); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(false); + _handler = new GetPublicProviderByIdOrSlugQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); } @@ -225,6 +230,48 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnNotFound() result.IsSuccess.Should().BeFalse(); result.Error.StatusCode.Should().Be(404); } + + [Fact] + public async Task HandleAsync_WhenProviderIsNotActive_BySlug_ShouldReturnNotFound() + { + // Arrange + var provider = ProviderBuilder.Create() + .Build(); + // O status padrão do builder é PendingBasicInfo (não Active) + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(provider.Slug, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.StatusCode.Should().Be(404); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_BySlug_ShouldReturnNotFound() + { + // Arrange + var slug = "non-existent-slug"; + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(slug, It.IsAny())) + .ReturnsAsync((Provider?)null); + + var query = new GetPublicProviderByIdOrSlugQuery(slug); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.StatusCode.Should().Be(404); + } [Fact] public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedProvider() { @@ -240,6 +287,8 @@ public async Task HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedPro .WithStatus(EProviderStatus.Active) .Build(); + provider.AddService(Guid.NewGuid(), "Restricted Service"); + _providerRepositoryMock .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) .ReturnsAsync(provider); diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 00f4dc818..dc7baccf0 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -100,8 +100,7 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc // Act & Assert var act = () => services.AddUsersModule(configuration); - act.Should().Throw() - .WithMessage("Connection for Users module not configured"); + act.Should().Throw(); } finally { diff --git a/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs b/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs index e51dcec08..9be7b3947 100644 --- a/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs +++ b/tests/MeAjudaAi.E2E.Tests/CrossModule/ProviderServiceCatalogSearchWorkflowTests.cs @@ -190,11 +190,12 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; @@ -202,6 +203,7 @@ DO UPDATE SET { Id = Guid.NewGuid(), ProviderId = providerId, + Slug = providerRequest.Name!.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), Name = providerRequest.Name, Latitude = latitude, Longitude = longitude, @@ -436,19 +438,22 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; + var name = $"MultiService_{uniqueId}"; await dapper.ExecuteAsync(sql, new { Id = Guid.NewGuid(), ProviderId = providerId1, - Name = $"MultiService_{uniqueId}", + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), + Name = name, Latitude = -23.550520, Longitude = -46.633308, AvgRating = 0.0m, @@ -512,19 +517,22 @@ await _fixture.WithServiceScopeAsync(async sp => var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) + (id, provider_id, slug, name, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at) VALUES - (@Id, @ProviderId, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) + (@Id, @ProviderId, @Slug, @Name, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, service_ids = EXCLUDED.service_ids, updated_at = CURRENT_TIMESTAMP"; + var name = $"SingleService_{uniqueId}"; await dapper.ExecuteAsync(sql, new { Id = Guid.NewGuid(), ProviderId = providerId2, - Name = $"SingleService_{uniqueId}", + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), + Name = name, Latitude = -23.551000, Longitude = -46.634000, AvgRating = 0.0m, diff --git a/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs b/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs index bc56e6f49..6bb3ff35d 100644 --- a/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs +++ b/tests/MeAjudaAi.E2E.Tests/Modules/SearchProviders/SearchProvidersEndToEndTests.cs @@ -461,11 +461,12 @@ await _fixture.WithServiceScopeAsync(async sp => // We need to ensure we set the SRID to 4326 for the geography column var sql = @" INSERT INTO search_providers.searchable_providers - (id, provider_id, name, description, city, state, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at, updated_at) + (id, provider_id, slug, name, description, city, state, location, average_rating, total_reviews, subscription_tier, service_ids, is_active, created_at, updated_at) VALUES - (@Id, @ProviderId, @Name, @Description, @City, @State, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt, @UpdatedAt) + (@Id, @ProviderId, @Slug, @Name, @Description, @City, @State, ST_SetSRID(ST_MakePoint(@Longitude, @Latitude), 4326)::geography, @AvgRating, @TotalReviews, @SubscriptionTier, @ServiceIds, @IsActive, @CreatedAt, @UpdatedAt) ON CONFLICT (provider_id) DO UPDATE SET + slug = EXCLUDED.slug, name = EXCLUDED.name, location = EXCLUDED.location, service_ids = EXCLUDED.service_ids, @@ -476,6 +477,7 @@ DO UPDATE SET { Id = Guid.NewGuid(), ProviderId = providerId, + Slug = name.ToLowerInvariant().Replace(" ", "-").Replace("_", "-"), Name = name, Description = $"Test Provider {name}", City = "São Paulo", From 85406d7ebe49a19c19a97ea3124e821732c80220 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 10:31:53 -0300 Subject: [PATCH 20/23] feat: add unit tests for SearchableProvider entity, API extensions, and a Provider test builder. --- .../Tests/Builders/ProviderBuilder.cs | 39 ++++++++++++------- .../Entities/SearchableProviderTests.cs | 2 + .../Users/Tests/Unit/API/ExtensionsTests.cs | 2 +- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index cc372879c..5d1d4005d 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -71,15 +71,35 @@ public ProviderBuilder() } // Define tier se especificado - if (_tier.HasValue) + if (_tier.HasValue && _tier.Value != EProviderTier.Standard) { - SetPropertyValue(provider, nameof(Provider.Tier), _tier.Value); + provider.PromoteTier(_tier.Value, "test-builder"); } - // Define status se especificado - if (_status.HasValue) + // Define status se especificado seguindo a máquina de estados do domínio + if (_status.HasValue && _status.Value != EProviderStatus.PendingBasicInfo) { - SetPropertyValue(provider, nameof(Provider.Status), _status.Value); + switch (_status.Value) + { + case EProviderStatus.PendingDocumentVerification: + provider.CompleteBasicInfo("test-builder"); + break; + + case EProviderStatus.Active: + provider.CompleteBasicInfo("test-builder"); + provider.Activate("test-builder"); + break; + + case EProviderStatus.Suspended: + provider.CompleteBasicInfo("test-builder"); + provider.Activate("test-builder"); + provider.Suspend("Test suspension", "test-builder"); + break; + + case EProviderStatus.Rejected: + provider.Reject("Test rejection", "test-builder"); + break; + } } return provider; @@ -225,13 +245,4 @@ private static BusinessProfile CreateDefaultBusinessProfile(Faker faker) primaryAddress: address); } - private static void SetPropertyValue(Provider provider, string propertyName, object value) - { - var prop = typeof(Provider).GetProperty(propertyName); - if (prop == null) - { - throw new InvalidOperationException($"Property '{propertyName}' was not found on class {nameof(Provider)}."); - } - prop.SetValue(provider, value); - } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index dd5f76d38..ff0e3f0e7 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -46,6 +46,7 @@ public void Create_WithValidData_ShouldCreateSearchableProvider() provider.AverageRating.Should().Be(0); provider.TotalReviews.Should().Be(0); provider.ServiceIds.Should().BeEmpty(); + provider.Slug.Should().Be("test-provider"); } [Fact] @@ -181,6 +182,7 @@ public void UpdateBasicInfo_WithValidData_ShouldUpdateFields() provider.City.Should().Be(newCity); provider.State.Should().Be(newState); provider.UpdatedAt.Should().NotBeNull(); + provider.Slug.Should().Be("updated-provider"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index dc7baccf0..9d3871bd4 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -19,7 +19,7 @@ private static IConfiguration BuildTestConfiguration() return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["ConnectionStrings:Users"] = "Server=localhost;Database=test;" + ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;" }) .Build(); } From a49c3dca2fb32a7bef56188c59daba6b568ba95c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 10:50:06 -0300 Subject: [PATCH 21/23] feat: add unit tests for the Users module API extensions and SearchProviders domain entity. --- .../Entities/SearchableProviderTests.cs | 3 ++- .../Users/Tests/Unit/API/ExtensionsTests.cs | 21 +++---------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs index ff0e3f0e7..5fa468dcd 100644 --- a/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs +++ b/src/Modules/SearchProviders/Tests/Unit/Domain/Entities/SearchableProviderTests.cs @@ -104,7 +104,8 @@ public void Create_WithWhitespaceSlug_ShouldThrowArgumentException() var act = () => SearchableProvider.Create(providerId, "Valid Name", " ", location); // Assert - act.Should().Throw(); + act.Should().Throw() + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); } [Fact] diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 9d3871bd4..ce0211d21 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -88,25 +88,10 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var originalDotnetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + // Act & Assert + var act = () => services.AddUsersModule(configuration); - try - { - // Força ambiente não teste/dev para testar o fallback de exception - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); - Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); - - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw(); - } - finally - { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); - Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotnetEnv); - } + act.Should().Throw(); } [Fact] From f5199f2f9f819d280f41706be851c010476783e4 Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 11:08:14 -0300 Subject: [PATCH 22/23] test(Users/API): add unit tests for AddUsersModule extension method --- .../Users/Tests/Unit/API/ExtensionsTests.cs | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index ce0211d21..8bc4f91ba 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -1,6 +1,9 @@ using MeAjudaAi.Modules.Users.API; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] namespace MeAjudaAi.Modules.Users.Tests.Unit.API; @@ -11,10 +14,9 @@ namespace MeAjudaAi.Modules.Users.Tests.Unit.API; [Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] -[Collection("NonParallel Environment Tests")] public class ExtensionsTests { - private static IConfiguration BuildTestConfiguration() + private static IConfiguration BuildTestConfiguration_Minimal() { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -29,7 +31,7 @@ public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() { // Arrange IServiceCollection services = null!; - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -50,12 +52,9 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullExceptio act.Should().Throw().WithParameterName("configuration"); } - [Fact] - public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() + private static IConfiguration BuildTestConfiguration_Full() { - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() + return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", @@ -65,6 +64,14 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() ["Keycloak:ClientSecret"] = "test-secret" }) .Build(); + } + + [Fact] + public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() + { + // Arrange + var services = new ServiceCollection(); + var configuration = BuildTestConfiguration_Full(); // Act var result = services.AddUsersModule(configuration); @@ -88,10 +95,20 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - // Act & Assert - var act = () => services.AddUsersModule(configuration); - - act.Should().Throw(); + var originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + // Act & Assert + var act = () => services.AddUsersModule(configuration); + + act.Should().Throw(); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); + } } [Fact] @@ -99,7 +116,7 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -113,16 +130,7 @@ public void AddUsersModule_ShouldConfigureServicesForDependencyInjection() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", - ["Keycloak:BaseUrl"] = "http://localhost:8080", - ["Keycloak:Realm"] = "test-realm", - ["Keycloak:ClientId"] = "test-client", - ["Keycloak:ClientSecret"] = "test-secret" - }) - .Build(); + var configuration = BuildTestConfiguration_Full(); // Act services.AddUsersModule(configuration); @@ -142,7 +150,7 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() { // Arrange var services = new ServiceCollection(); - var configuration = BuildTestConfiguration(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -211,6 +219,3 @@ public void AddUsersModule_WithCompleteConfiguration_ShouldBuildServiceProvider( Assert.NotNull(serviceProvider); } } - -[CollectionDefinition("NonParallel Environment Tests", DisableParallelization = true)] -public class NonParallelEnvironmentTestsCollection { } From c26cd5387b4fe2c565343b2e154904a3cb1f1a7c Mon Sep 17 00:00:00 2001 From: Filipe Frigini Date: Thu, 19 Mar 2026 11:22:39 -0300 Subject: [PATCH 23/23] test: add unit tests for Users module API extension methods --- .../Users/Tests/Unit/API/ExtensionsTests.cs | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 8bc4f91ba..02669b12d 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -26,6 +26,20 @@ private static IConfiguration BuildTestConfiguration_Minimal() .Build(); } + private static IConfiguration BuildTestConfiguration_Full() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", + ["Keycloak:BaseUrl"] = "http://localhost:8080", + ["Keycloak:Realm"] = "test-realm", + ["Keycloak:ClientId"] = "test-client", + ["Keycloak:ClientSecret"] = "test-secret" + }) + .Build(); + } + [Fact] public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() { @@ -52,20 +66,6 @@ public void AddUsersModule_WithNullConfiguration_ShouldThrowArgumentNullExceptio act.Should().Throw().WithParameterName("configuration"); } - private static IConfiguration BuildTestConfiguration_Full() - { - return new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", - ["Keycloak:BaseUrl"] = "http://localhost:8080", - ["Keycloak:Realm"] = "test-realm", - ["Keycloak:ClientId"] = "test-client", - ["Keycloak:ClientSecret"] = "test-secret" - }) - .Build(); - } - [Fact] public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() { @@ -95,10 +95,12 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().Build(); - var originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalAspNetCoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var originalDotNetEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); try { Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Production"); // Act & Assert var act = () => services.AddUsersModule(configuration); @@ -107,7 +109,8 @@ public void AddUsersModule_WithEmptyConfiguration_ShouldThrowInvalidOperationExc } finally { - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotNetEnv); } }