diff --git a/prompts/design-react-project.md b/prompts/design-react-project.md index e69de29bb..b114b027f 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) + +```css +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/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index 263b77626..567891c9d 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -68,7 +68,10 @@ 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 } var postgresql = builder.AddMeAjudaAiPostgreSQL(options => @@ -109,7 +112,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 +138,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/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/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 02e86332f..09241510f 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -42,6 +42,7 @@ public Task InvokeAsync(HttpContext context) var headers = ctx.Response.Headers; // Adiciona cabeçalhos de segurança estáticos eficientemente +#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) { if (!headers.ContainsKey(header.Key)) @@ -49,6 +50,7 @@ public Task InvokeAsync(HttpContext context) 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/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/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/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 74% rename from src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs rename to src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs index ef073e8a4..487107c0e 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdOrSlugEndpoint.cs @@ -14,23 +14,23 @@ 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) - 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) @@ -43,14 +43,14 @@ Recupera dados públicos e seguros de um prestador para exibição no site. .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/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/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/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/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs similarity index 72% rename from src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs rename to src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandler.cs index 043809b11..59d1ed176 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.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; @@ -15,35 +16,48 @@ 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); + 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) { - 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; @@ -60,7 +74,7 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, ? businessProfile.ContactInfo.Email : null; - var services = !isPrivacyEnabled + var services = !shouldRedactContactInfo ? provider.Services.Select(s => s.ServiceName).ToList() : new List(); @@ -68,6 +82,7 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, var dto = new PublicProviderDto( provider.Id, provider.Name, + provider.Slug, provider.Type, businessProfile.FantasyName, businessProfile.Description, @@ -89,9 +104,9 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, 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/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/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/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/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index d40b3bf6a..aaa707819 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.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); Type = type; BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; @@ -163,6 +172,7 @@ public Provider( UserId = userId; Name = name.Trim(); + Slug = SlugHelper.GenerateWithSuffix(Name, Id.Value.ToString("N")[..8]); 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,11 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? var newName = name.Trim(); if (Name != newName) + { updatedFields.Add("Name"); + Name = newName; + // 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)) updatedFields.Add("Email"); @@ -213,7 +228,6 @@ public void UpdateProfile(string name, BusinessProfile businessProfile, string? if (BusinessProfile.Description != businessProfile.Description) updatedFields.Add("Description"); - Name = newName; BusinessProfile = businessProfile; MarkAsUpdated(); @@ -222,6 +236,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/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/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index a24e92fe9..d506142e9 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,9 @@ public void Configure(EntityTypeBuilder builder) .IsUnique() .HasDatabaseName("ix_providers_user_id"); + builder.HasIndex(p => p.Slug) + .HasDatabaseName("ix_providers_slug"); + builder.HasIndex(p => p.Name) .HasDatabaseName("ix_providers_name"); 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/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/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index d87d5ab01..5d1d4005d 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(); @@ -70,14 +71,35 @@ public ProviderBuilder() } // Define tier se especificado - if (_tier.HasValue) + if (_tier.HasValue && _tier.Value != EProviderTier.Standard) { - var prop = typeof(Provider).GetProperty(nameof(Provider.Tier)); - if (prop == null) + provider.PromoteTier(_tier.Value, "test-builder"); + } + + // Define status se especificado seguindo a máquina de estados do domínio + if (_status.HasValue && _status.Value != EProviderStatus.PendingBasicInfo) + { + switch (_status.Value) { - throw new InvalidOperationException($"Property '{nameof(Provider.Tier)}' was not found on class {nameof(Provider)}."); + 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; } - prop.SetValue(provider, _tier.Value); } return provider; @@ -90,6 +112,12 @@ public ProviderBuilder WithTier(EProviderTier tier) return this; } + public ProviderBuilder WithStatus(EProviderStatus status) + { + _status = status; + return this; + } + public ProviderBuilder WithUserId(Guid userId) { _userId = userId; @@ -216,4 +244,5 @@ private static BusinessProfile CreateDefaultBusinessProfile(Faker faker) contactInfo: contactInfo, primaryAddress: address); } + } 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/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/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/GetPublicProviderByIdOrSlugQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs new file mode 100644 index 000000000..3ef8d2427 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdOrSlugQueryHandlerTests.cs @@ -0,0 +1,353 @@ +using FluentAssertions; +using MeAjudaAi.Modules.Providers.Application.Handlers.Queries; +using MeAjudaAi.Modules.Providers.Application.Queries; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Modules.Providers.Tests.Builders; +using MeAjudaAi.Shared.Utilities.Constants; +using Microsoft.FeatureManagement; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +public class GetPublicProviderByIdOrSlugQueryHandlerTests +{ + private readonly Mock _providerRepositoryMock; + private readonly Mock _featureManagerMock; + private readonly GetPublicProviderByIdOrSlugQueryHandler _handler; + + public GetPublicProviderByIdOrSlugQueryHandlerTests() + { + _providerRepositoryMock = new Mock(); + _featureManagerMock = new Mock(); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(false); + + _handler = new GetPublicProviderByIdOrSlugQueryHandler(_providerRepositoryMock.Object, _featureManagerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificationStatus() + { + // 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(); + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); + + // 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.Name.Should().Be(provider.Name); + 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) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) + .Build(); + + var normalizedSlug = provider.Slug.Trim().ToLowerInvariant(); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, 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); + 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) + // 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 upperSlug = provider.Slug.ToUpperInvariant(); + + _providerRepositoryMock + .Setup(x => x.GetBySlugAsync(normalizedSlug, It.IsAny())) + .ReturnsAsync(provider); + + // Query usa slug em maiúsculas — o handler deve normalizar antes de chamar 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_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() + { + // 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" + + 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(); + + // GetByIdAsync retorna null (nenhum provedor com esse ID) + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Provider?)null); + + // Fallback para a busca por slug retorna o provedor + _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); + + _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] + public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() + { + // Arrange + var provider = ProviderBuilder.Create() + .Build(); + // O status padrão do builder é PendingBasicInfo (não Active) + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) + .ReturnsAsync(provider); + + var query = new GetPublicProviderByIdOrSlugQuery(provider.Id.Value.ToString()); + + // 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_ShouldReturnNotFound() + { + // Arrange + var providerId = Guid.NewGuid(); + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(new ProviderId(providerId), It.IsAny())) + .ReturnsAsync((Provider?)null); + + var query = new GetPublicProviderByIdOrSlugQuery(providerId.ToString()); + + // 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_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() + { + // 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")) + // Ignora transições de domínio para definir o status Active diretamente no teste + .WithStatus(EProviderStatus.Active) + .Build(); + + provider.AddService(Guid.NewGuid(), "Restricted Service"); + + _providerRepositoryMock + .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) + .ReturnsAsync(provider); + + _featureManagerMock + .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) + .ReturnsAsync(true); + + 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().BeNull(); + result.Value.PhoneNumbers.Should().BeEmpty(); + 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")) + // 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"); + + _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(); + result.Value.Services.Should().Contain("Known Service"); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs deleted file mode 100644 index 0a4c0c46c..000000000 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Queries/GetPublicProviderByIdQueryHandlerTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using FluentAssertions; -using MeAjudaAi.Modules.Providers.Application.Handlers.Queries; -using MeAjudaAi.Modules.Providers.Application.Queries; -using MeAjudaAi.Modules.Providers.Domain.Entities; -using MeAjudaAi.Modules.Providers.Domain.Enums; -using MeAjudaAi.Modules.Providers.Domain.Repositories; -using MeAjudaAi.Modules.Providers.Domain.ValueObjects; -using MeAjudaAi.Modules.Providers.Tests.Builders; -using MeAjudaAi.Shared.Utilities.Constants; -using Microsoft.FeatureManagement; -using Moq; -using Xunit; - -namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Handlers.Queries; - -[Trait("Category", "Unit")] -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 HandleAsync_WhenProviderIsActive_ShouldReturnDtoWithVerificationStatus() - { - // 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); - - _providerRepositoryMock - .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(provider.Id); - - // 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.Name.Should().Be(provider.Name); - result.Value.VerificationStatus.Should().Be(EVerificationStatus.Verified); - } - - [Fact] - public async Task HandleAsync_WhenProviderIsNotActive_ShouldReturnNotFound() - { - // Arrange - var provider = ProviderBuilder.Create() - .Build(); - // Default builder status is PendingBasicInfo (not Active) - - _providerRepositoryMock - .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) - .ReturnsAsync(provider); - - var query = new GetPublicProviderByIdQuery(provider.Id); - - // 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_ShouldReturnNotFound() - { - // Arrange - var providerId = Guid.NewGuid(); - - _providerRepositoryMock - .Setup(x => x.GetByIdAsync(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 HandleAsync_WhenPrivacyFlagIsEnabled_ShouldReturnRestrictedProvider() - { - // 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(); - - // 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); - - _providerRepositoryMock - .Setup(x => x.GetByIdAsync(provider.Id, It.IsAny())) - .ReturnsAsync(provider); - - _featureManagerMock - .Setup(x => x.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy)) - .ReturnsAsync(true); - - var query = new GetPublicProviderByIdQuery(provider.Id); - - // 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().BeNull(); - result.Value.PhoneNumbers.Should().BeEmpty(); - result.Value.Services.Should().BeEmpty(); - } -} 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..cbab7b39e 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().StartWith("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().StartWith("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(provider.Slug); 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..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() @@ -60,6 +60,7 @@ public async Task HandleAsync_ShouldPublishIntegrationEvent() 1, "Updated Name", "updated@test.com", + "updated-name", null, new[] { "Name", "Email" } ); @@ -72,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() @@ -98,6 +100,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..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; @@ -67,7 +68,8 @@ public async Task HandleAsync_WithValidEvent_ShouldPublishIntegrationEvent() userId, "Provider Test", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "provider-test" ); // Act @@ -76,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); @@ -92,7 +96,8 @@ public async Task HandleAsync_WithMissingProvider_ShouldNotPublishEvent() UuidGenerator.NewId(), "Nonexistent Provider", EProviderType.Individual, - "test@provider.com" + "test@provider.com", + "nonexistent-provider" ); // Act @@ -118,7 +123,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/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs b/src/Modules/Providers/Tests/Unit/Infrastructure/Persistence/Configurations/ProviderConfigurationTests.cs index bdac1955c..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,17 @@ 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 - _entityType.GetIndexes().Should().HaveCount(6); + // 7. Slug (no longer unique) + _entityType.GetIndexes().Should().HaveCount(7); } #endregion 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..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), @@ -165,7 +166,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 +188,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..ab95fefb9 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; @@ -21,6 +22,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 +101,7 @@ private SearchableProvider( public static SearchableProvider Create( Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier = ESubscriptionTier.Free, string? description = null, @@ -103,9 +110,11 @@ 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); + ArgumentNullException.ThrowIfNull(location); var searchableProvider = new SearchableProvider( @@ -115,6 +124,7 @@ public static SearchableProvider Create( location, subscriptionTier) { + Slug = slug, Description = description?.Trim(), City = city?.Trim(), State = state?.Trim() @@ -136,6 +146,7 @@ internal static SearchableProvider Reconstitute( Guid id, Guid providerId, string name, + string slug, GeoPoint location, ESubscriptionTier subscriptionTier, decimal averageRating, @@ -146,6 +157,11 @@ internal static SearchableProvider Reconstitute( string? city = null, string? state = null) { + if (!TryNormalizeSlug(slug, out var normalizedSlug)) + { + normalizedSlug = SlugHelper.GenerateWithSuffix(name, providerId.ToString("N")[..8]); + } + var searchableProvider = new SearchableProvider( new SearchableProviderId(id), providerId, @@ -153,6 +169,7 @@ internal static SearchableProvider Reconstitute( location, subscriptionTier) { + Slug = normalizedSlug, Description = description, City = city, State = state, @@ -168,14 +185,17 @@ 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)); + throw new ArgumentException("O nome do provedor não pode ficar vazio.", nameof(name)); } + slug = NormalizeAndValidateSlug(slug); + Name = name.Trim(); + Slug = slug; Description = description?.Trim(); City = city?.Trim(); State = state?.Trim(); @@ -260,4 +280,20 @@ public double CalculateDistanceToInKm(GeoPoint targetLocation) return Location.DistanceTo(targetLocation); } + + private static string NormalizeAndValidateSlug(string? slug) + { + if (!TryNormalizeSlug(slug, out var normalized)) + { + throw new ArgumentException("O identificador do provedor não pode estar vazio nem em formato inválido.", 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/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/Configurations/SearchableProviderConfiguration.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Configurations/SearchableProviderConfiguration.cs index 04120a7de..02b9b3ece 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(120) + .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/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/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) + { + + } + } +} 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..4686e0fd3 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; @@ -162,13 +163,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: BuildTestSlug(name, providerId), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } @@ -189,13 +191,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: BuildTestSlug(name, providerId), + location: location, + subscriptionTier: tier, + description: description, + city: city, + state: state); return provider; } @@ -270,4 +273,17 @@ protected IServiceScope CreateScope() throw new InvalidOperationException("Service provider not initialized"); 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")[..GuidSuffixLength]); + } } diff --git a/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Application/Handlers/SearchProvidersQueryHandlerTests.cs index 2faf1494c..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; @@ -355,10 +356,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 +394,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: SlugHelper.Generate(name), + 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..52269ed71 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), @@ -205,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); @@ -323,23 +325,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 +364,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 +474,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 +537,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..5fa468dcd 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", @@ -45,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] @@ -55,11 +57,11 @@ 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() - .WithMessage("*Provider name cannot be empty*"); + .WithMessage("*O nome do provedor não pode ficar vazio*"); } [Fact] @@ -70,12 +72,98 @@ 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(); } + [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("*O identificador do provedor não pode estar vazio nem em formato inválido*"); + } + + [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() + .WithMessage("*O identificador do provedor não pode estar vazio nem em formato inválido*"); + } + + [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() + { + // 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("*O identificador do provedor não pode estar vazio nem em formato inválido*"); + } + + [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() { @@ -87,7 +175,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); @@ -95,6 +183,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] @@ -104,7 +193,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 +385,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 +399,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..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; @@ -185,14 +186,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: SlugHelper.Generate(providerName), + 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..8f23024a8 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 = SlugHelper.Generate(actualName); + 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/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/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..647fcd3ef 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); + logger.LogError("Compensation failed for user {UserId}: {Error}", user.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/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..3e828f8d3 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -63,19 +63,20 @@ 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); } - 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/API/Extensions/APIExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/Extensions/APIExtensionsTests.cs index 9345beaa0..f0f91e516 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(); diff --git a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs index 840612836..02669b12d 100644 --- a/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs +++ b/src/Modules/Users/Tests/Unit/API/ExtensionsTests.cs @@ -1,24 +1,34 @@ 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; /// -/// 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 +/// Testes unitários dos métodos de extensão do módulo Users +/// Foca em cenários unitários e configuração completa /// -[Trait("Category", "Integration")] +[Trait("Category", "Unit")] [Trait("Module", "Users")] [Trait("Layer", "API")] public class ExtensionsTests { - [Fact] - public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() + private static IConfiguration BuildTestConfiguration_Minimal() { - // Arrange - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;" + }) + .Build(); + } + + private static IConfiguration BuildTestConfiguration_Full() + { + return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = "Server=localhost;Database=test;User Id=test;Password=test;", @@ -28,6 +38,40 @@ public void AddUsersModule_ShouldAddApplicationAndInfrastructureServices() ["Keycloak:ClientSecret"] = "test-secret" }) .Build(); + } + + [Fact] + public void AddUsersModule_WithNullServices_ShouldThrowArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + var configuration = BuildTestConfiguration_Minimal(); + + // 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() + { + // Arrange + var services = new ServiceCollection(); + var configuration = BuildTestConfiguration_Full(); // Act var result = services.AddUsersModule(configuration); @@ -45,19 +89,29 @@ 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"); + 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); + + act.Should().Throw(); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalAspNetCoreEnv); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", originalDotNetEnv); + } } [Fact] @@ -65,7 +119,7 @@ public void AddUsersModule_ShouldReturnSameServiceCollectionInstance() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder().Build(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -79,16 +133,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); @@ -108,9 +153,7 @@ public void AddUsersModule_WithMinimalConfiguration_ShouldRegisterServices() { // Arrange var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary()) - .Build(); + var configuration = BuildTestConfiguration_Minimal(); // Act var result = services.AddUsersModule(configuration); @@ -126,7 +169,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 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/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/Constants/ApiEndpoints.cs b/src/Shared/Utilities/Constants/ApiEndpoints.cs index a7de4845c..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 GetPublicById = "/{id:guid}/public"; // GET GetPublicProviderByIdEndpoint + public const string GetPublicByIdOrSlug = "/public/{idOrSlug}"; // GET GetPublicProviderByIdOrSlugEndpoint } /// diff --git a/src/Shared/Utilities/SlugHelper.cs b/src/Shared/Utilities/SlugHelper.cs new file mode 100644 index 000000000..c4122bdc5 --- /dev/null +++ b/src/Shared/Utilities/SlugHelper.cs @@ -0,0 +1,83 @@ +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('-'); + } + + /// + /// 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) + { + 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) + { + 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 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.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; 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", 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(); 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..e273d8b13 --- /dev/null +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Utilities/SlugHelperTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using MeAjudaAi.Shared.Utilities; + +namespace MeAjudaAi.Shared.Tests.Unit.Utilities; + +[Trait("Category", "Unit")] +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 f68c4af3e..4bf5c2a06 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, )",