diff --git a/.gitignore b/.gitignore index 04a0bd25e..fad50d47e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ _NCrunch_* *.userosscache *.sln.docstates +# Launch settings (IDE-specific, auto-generated) +**/Properties/launchSettings.json + # ReSharper _ReSharper*/ *.[Rr]e[Ss]harper diff --git a/MeAjudaAi.sln b/MeAjudaAi.sln index d922dd3c8..2c4a098f9 100644 --- a/MeAjudaAi.sln +++ b/MeAjudaAi.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject @@ -145,6 +145,28 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{4726175B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Search.Tests", "src\Modules\Search\Tests\MeAjudaAi.Modules.Search.Tests.csproj", "{C7F6B6F4-4F9C-C844-500C-87E3802A6C4B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalogs", "Catalogs", "{8B551008-B254-EBAF-1B6D-AB7C420234EA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{B346CC0B-427A-E442-6F5D-8AAE1AB081D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Catalogs.Domain", "src\Modules\Catalogs\Domain\MeAjudaAi.Modules.Catalogs.Domain.csproj", "{DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{1510B873-F5F8-8A20-05CA-B70BA1F93C8F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Catalogs.Application", "src\Modules\Catalogs\Application\MeAjudaAi.Modules.Catalogs.Application.csproj", "{44577491-2FC0-4F52-AF5C-2BC9B323CDB7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{8D23D6D3-2B2E-7F09-866F-FA51CC0FC081}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Catalogs.Infrastructure", "src\Modules\Catalogs\Infrastructure\MeAjudaAi.Modules.Catalogs.Infrastructure.csproj", "{3B6D6C13-1E04-47B9-B44E-36D25DF913C7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "API", "API", "{A63FE417-CEAA-2A64-637A-6EABC61CE16D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Catalogs.API", "src\Modules\Catalogs\API\MeAjudaAi.Modules.Catalogs.API.csproj", "{30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{BDD25844-1435-F5BA-1F9B-EFB3B12C916F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeAjudaAi.Modules.Catalogs.Tests", "src\Modules\Catalogs\Tests\MeAjudaAi.Modules.Catalogs.Tests.csproj", "{2C85E336-66A2-4B4F-845A-DBA2A6520162}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -551,6 +573,66 @@ Global {C7F6B6F4-4F9C-C844-500C-87E3802A6C4B}.Release|x64.Build.0 = Release|Any CPU {C7F6B6F4-4F9C-C844-500C-87E3802A6C4B}.Release|x86.ActiveCfg = Release|Any CPU {C7F6B6F4-4F9C-C844-500C-87E3802A6C4B}.Release|x86.Build.0 = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|x64.Build.0 = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Debug|x86.Build.0 = Debug|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|Any CPU.Build.0 = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x64.ActiveCfg = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x64.Build.0 = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x86.ActiveCfg = Release|Any CPU + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A}.Release|x86.Build.0 = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x64.Build.0 = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Debug|x86.Build.0 = Debug|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|Any CPU.Build.0 = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x64.ActiveCfg = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x64.Build.0 = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x86.ActiveCfg = Release|Any CPU + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7}.Release|x86.Build.0 = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|x64.Build.0 = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Debug|x86.Build.0 = Debug|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|Any CPU.Build.0 = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x64.ActiveCfg = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x64.Build.0 = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x86.ActiveCfg = Release|Any CPU + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7}.Release|x86.Build.0 = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|x64.ActiveCfg = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|x64.Build.0 = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Debug|x86.Build.0 = Debug|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|Any CPU.Build.0 = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|x64.ActiveCfg = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|x64.Build.0 = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|x86.ActiveCfg = Release|Any CPU + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8}.Release|x86.Build.0 = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|x64.Build.0 = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Debug|x86.Build.0 = Debug|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|Any CPU.Build.0 = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|x64.ActiveCfg = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|x64.Build.0 = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|x86.ActiveCfg = Release|Any CPU + {2C85E336-66A2-4B4F-845A-DBA2A6520162}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -622,6 +704,17 @@ Global {0A64D976-2B75-C6F2-9C87-3A780C963FA3} = {9BC7D786-47F5-44BB-88A1-DDEB0022FF23} {4726175B-331E-49FA-A49A-EE5AC30B495A} = {6FF68FBA-C4AF-48EC-AFE2-E320F2195C79} {C7F6B6F4-4F9C-C844-500C-87E3802A6C4B} = {4726175B-331E-49FA-A49A-EE5AC30B495A} + {8B551008-B254-EBAF-1B6D-AB7C420234EA} = {D55DFAF4-45A1-4C45-AA54-8CE46F0AFB1F} + {B346CC0B-427A-E442-6F5D-8AAE1AB081D6} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {DC1D1ACD-A21E-4BA0-A22D-77450234BD2A} = {B346CC0B-427A-E442-6F5D-8AAE1AB081D6} + {1510B873-F5F8-8A20-05CA-B70BA1F93C8F} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {44577491-2FC0-4F52-AF5C-2BC9B323CDB7} = {1510B873-F5F8-8A20-05CA-B70BA1F93C8F} + {8D23D6D3-2B2E-7F09-866F-FA51CC0FC081} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {3B6D6C13-1E04-47B9-B44E-36D25DF913C7} = {8D23D6D3-2B2E-7F09-866F-FA51CC0FC081} + {A63FE417-CEAA-2A64-637A-6EABC61CE16D} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {30A2D3C4-AF98-40A1-AA90-ED7C5FE090F8} = {A63FE417-CEAA-2A64-637A-6EABC61CE16D} + {BDD25844-1435-F5BA-1F9B-EFB3B12C916F} = {8B551008-B254-EBAF-1B6D-AB7C420234EA} + {2C85E336-66A2-4B4F-845A-DBA2A6520162} = {BDD25844-1435-F5BA-1F9B-EFB3B12C916F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {391B5342-8EC5-4DF0-BCDA-6D73F87E8751} diff --git a/docs/architecture.md b/docs/architecture.md index 4c9f32cd3..41d119e1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,15 +115,29 @@ public class ProvidersContext - **Qualification**: Qualificações e habilitações profissionais - **VerificationStatus**: Status de verificação (Pending, Verified, Rejected, etc.) -#### 3. **Services Context** (Futuro) -**Responsabilidade**: Catálogo e gestão de serviços oferecidos +#### 3. **Catalogs Context** (Implementado) +**Responsabilidade**: Catálogo administrativo de categorias e serviços -**Conceitos Planejados**: -- **Service**: Serviço oferecido por prestadores -- **Category**: Categorização hierárquica de serviços -- **Pricing**: Modelos de precificação flexíveis +**Conceitos Implementados**: +- **ServiceCategory**: Categorias hierárquicas de serviços (aggregate root) +- **Service**: Serviços oferecidos vinculados a categorias (aggregate root) +- **DisplayOrder**: Ordenação customizada para apresentação +- **Activation/Deactivation**: Controle de visibilidade no catálogo + +**Schema**: `catalogs` (isolado no PostgreSQL) + +#### 4. **Location Context** (Implementado) +**Responsabilidade**: Geolocalização e lookup de CEP brasileiro + +**Conceitos Implementados**: +- **Cep**: Value object para CEP validado +- **Coordinates**: Latitude/Longitude para geolocalização +- **Address**: Endereço completo com dados estruturados +- **CepLookupService**: Integração com ViaCEP, BrasilAPI, OpenCEP (fallback) + +**Observação**: Módulo stateless (sem schema próprio), fornece serviços via Module API -#### 4. **Bookings Context** (Futuro) +#### 5. **Bookings Context** (Futuro) **Responsabilidade**: Agendamento e execução de serviços **Conceitos Planejados**: diff --git a/docs/modules/catalogs.md b/docs/modules/catalogs.md new file mode 100644 index 000000000..fa9e43104 --- /dev/null +++ b/docs/modules/catalogs.md @@ -0,0 +1,496 @@ +# 📋 Módulo Catalogs - Catálogo de Serviços + +> **✅ Status**: Módulo **implementado e funcional** (Novembro 2025) + +## 🎯 Visão Geral + +O módulo **Catalogs** é responsável pelo **catálogo administrativo de serviços** oferecidos na plataforma MeAjudaAi, implementando um Bounded Context dedicado para gestão hierárquica de categorias e serviços. + +### **Responsabilidades** +- ✅ **Catálogo hierárquico** de categorias de serviços +- ✅ **Gestão de serviços** por categoria +- ✅ **CRUD administrativo** de categorias e serviços +- ✅ **Ativação/desativação** de serviços +- ✅ **API pública** para consulta por outros módulos +- ✅ **Validação de serviços** em batch + +## 🏗️ Arquitetura Implementada + +### **Bounded Context: Catalogs** +- **Schema**: `catalogs` (isolado no PostgreSQL) +- **Padrão**: DDD + CQRS +- **Naming**: snake_case no banco, PascalCase no código + +### **Agregados de Domínio** + +#### **ServiceCategory (Aggregate Root)** +```csharp +public sealed class ServiceCategory : AggregateRoot +{ + public string Name { get; private set; } // Nome da categoria + public string? Description { get; private set; } // Descrição opcional + public bool IsActive { get; private set; } // Status ativo/inativo + public int DisplayOrder { get; private set; } // Ordem de exibição + + // Factory method + public static ServiceCategory Create(string name, string? description, int displayOrder); + + // Behavior + public void Update(string name, string? description, int displayOrder); + public void Activate(); + public void Deactivate(); +} +``` + +**Regras de Negócio:** +- Nome deve ser único +- DisplayOrder deve ser >= 0 +- Descrição é opcional (max 500 caracteres) +- Não pode ser deletada se tiver serviços vinculados + +#### **Service (Aggregate Root)** +```csharp +public sealed class Service : AggregateRoot +{ + public ServiceCategoryId CategoryId { get; private set; } // Categoria pai + public string Name { get; private set; } // Nome do serviço + public string? Description { get; private set; } // Descrição opcional + public bool IsActive { get; private set; } // Status ativo/inativo + public int DisplayOrder { get; private set; } // Ordem de exibição + public ServiceCategory? Category { get; private set; } // Navegação + + // Factory method + public static Service Create(ServiceCategoryId categoryId, string name, string? description, int displayOrder); + + // Behavior + public void Update(string name, string? description, int displayOrder); + public void ChangeCategory(ServiceCategoryId newCategoryId); + public void Activate(); + public void Deactivate(); +} +``` + +**Regras de Negócio:** +- Nome deve ser único +- DisplayOrder deve ser >= 0 +- Categoria deve estar ativa +- Descrição é opcional (max 1000 caracteres) + +### **Value Objects** + +```csharp +// Strongly-typed IDs +public sealed record ServiceCategoryId(Guid Value) : EntityId(Value); +public sealed record ServiceId(Guid Value) : EntityId(Value); +``` + +### **Constantes de Validação** + +```csharp +// Shared/Constants/ValidationConstants.cs +public static class CatalogLimits +{ + public const int ServiceCategoryNameMaxLength = 100; + public const int ServiceCategoryDescriptionMaxLength = 500; + public const int ServiceNameMaxLength = 150; + public const int ServiceDescriptionMaxLength = 1000; +} +``` + +## 🔄 Domain Events + +```csharp +// ServiceCategory Events +public sealed record ServiceCategoryCreatedDomainEvent(ServiceCategoryId CategoryId); +public sealed record ServiceCategoryUpdatedDomainEvent(ServiceCategoryId CategoryId); +public sealed record ServiceCategoryActivatedDomainEvent(ServiceCategoryId CategoryId); +public sealed record ServiceCategoryDeactivatedDomainEvent(ServiceCategoryId CategoryId); + +// Service Events +public sealed record ServiceCreatedDomainEvent(ServiceId ServiceId, ServiceCategoryId CategoryId); +public sealed record ServiceUpdatedDomainEvent(ServiceId ServiceId); +public sealed record ServiceActivatedDomainEvent(ServiceId ServiceId); +public sealed record ServiceDeactivatedDomainEvent(ServiceId ServiceId); +public sealed record ServiceCategoryChangedDomainEvent(ServiceId ServiceId, ServiceCategoryId OldCategoryId, ServiceCategoryId NewCategoryId); +``` + +## ⚡ CQRS Implementado + +### **Commands** + +#### **ServiceCategory Commands** +```csharp +// Commands/ServiceCategory/ +CreateServiceCategoryCommand(string Name, string? Description, int DisplayOrder) +UpdateServiceCategoryCommand(Guid Id, string Name, string? Description, int DisplayOrder) +DeleteServiceCategoryCommand(Guid Id) +ActivateServiceCategoryCommand(Guid Id) +DeactivateServiceCategoryCommand(Guid Id) +``` + +#### **Service Commands** +```csharp +// Commands/Service/ +CreateServiceCommand(Guid CategoryId, string Name, string? Description, int DisplayOrder) +UpdateServiceCommand(Guid Id, string Name, string? Description, int DisplayOrder) +DeleteServiceCommand(Guid Id) +ActivateServiceCommand(Guid Id) +DeactivateServiceCommand(Guid Id) +ChangeServiceCategoryCommand(Guid ServiceId, Guid NewCategoryId) +``` + +### **Queries** + +#### **ServiceCategory Queries** +```csharp +// Queries/ServiceCategory/ +GetServiceCategoryByIdQuery(Guid Id) +GetAllServiceCategoriesQuery(bool ActiveOnly = false) +GetServiceCategoriesWithCountQuery(bool ActiveOnly = false) +``` + +#### **Service Queries** +```csharp +// Queries/Service/ +GetServiceByIdQuery(Guid Id) +GetAllServicesQuery(bool ActiveOnly = false) +GetServicesByCategoryQuery(Guid CategoryId, bool ActiveOnly = false) +``` + +### **Command & Query Handlers** + +Handlers consolidados em: +- `Application/Handlers/Commands/CommandHandlers.cs` (11 handlers) +- `Application/Handlers/Queries/QueryHandlers.cs` (6 handlers) + +## 🌐 API REST Implementada + +### **ServiceCategory Endpoints** + +```http +GET /api/v1/catalogs/categories # Listar categorias +GET /api/v1/catalogs/categories/{id} # Buscar categoria +GET /api/v1/catalogs/categories/with-counts # Categorias com contagem de serviços +POST /api/v1/catalogs/categories # Criar categoria [Admin] +PUT /api/v1/catalogs/categories/{id} # Atualizar categoria [Admin] +DELETE /api/v1/catalogs/categories/{id} # Deletar categoria [Admin] +POST /api/v1/catalogs/categories/{id}/activate # Ativar [Admin] +POST /api/v1/catalogs/categories/{id}/deactivate # Desativar [Admin] +``` + +### **Service Endpoints** + +```http +GET /api/v1/catalogs/services # Listar serviços +GET /api/v1/catalogs/services/{id} # Buscar serviço +GET /api/v1/catalogs/services/category/{categoryId} # Por categoria +POST /api/v1/catalogs/services # Criar serviço [Admin] +PUT /api/v1/catalogs/services/{id} # Atualizar serviço [Admin] +DELETE /api/v1/catalogs/services/{id} # Deletar serviço [Admin] +POST /api/v1/catalogs/services/{id}/activate # Ativar [Admin] +POST /api/v1/catalogs/services/{id}/deactivate # Desativar [Admin] +POST /api/v1/catalogs/services/{id}/change-category # Mudar categoria [Admin] +POST /api/v1/catalogs/services/validate # Validar batch de serviços +``` + +**Autorização:** Todos os endpoints requerem role `Admin`, exceto `GET` e `validate`. + +## 🔌 Module API - Comunicação Inter-Módulos + +### **Interface ICatalogsModuleApi** + +```csharp +public interface ICatalogsModuleApi : IModuleApi +{ + // Service Categories + Task> GetServiceCategoryByIdAsync( + Guid categoryId, CancellationToken ct = default); + + Task>> GetAllServiceCategoriesAsync( + bool activeOnly = true, CancellationToken ct = default); + + // Services + Task> GetServiceByIdAsync( + Guid serviceId, CancellationToken ct = default); + + Task>> GetAllServicesAsync( + bool activeOnly = true, CancellationToken ct = default); + + Task>> GetServicesByCategoryAsync( + Guid categoryId, bool activeOnly = true, CancellationToken ct = default); + + Task> IsServiceActiveAsync( + Guid serviceId, CancellationToken ct = default); + + // Batch Validation + Task> ValidateServicesAsync( + IReadOnlyCollection serviceIds, CancellationToken ct = default); +} +``` + +### **DTOs Públicos** + +```csharp +public sealed record ModuleServiceCategoryDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder +); + +public sealed record ModuleServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + bool IsActive +); + +public sealed record ModuleServiceListDto( + Guid Id, + Guid CategoryId, + string Name, + bool IsActive +); + +public sealed record ModuleServiceValidationResultDto( + bool AllValid, + IReadOnlyList InvalidServiceIds, + IReadOnlyList InactiveServiceIds +); +``` + +### **Implementação** + +```csharp +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] +public sealed class CatalogsModuleApi : ICatalogsModuleApi +{ + private static class ModuleMetadata + { + public const string Name = "Catalogs"; + public const string Version = "1.0"; + } + + // Health check via query materialização + public async Task IsAvailableAsync(CancellationToken ct = default) + { + var categories = await categoryRepository.GetAllAsync(activeOnly: true, ct); + return true; // Se query executou, módulo está disponível + } +} +``` + +**Recursos:** +- ✅ Guid.Empty guards em todos os métodos +- ✅ Batch query otimizada em ValidateServicesAsync (evita N+1) +- ✅ GetByIdsAsync no repository para queries em lote +- ✅ Health check via database connectivity + +## 🗄️ Schema de Banco de Dados + +```sql +-- Schema: catalogs +CREATE SCHEMA IF NOT EXISTS catalogs; + +-- Tabela: service_categories +CREATE TABLE catalogs.service_categories ( + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(500), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT ck_service_categories_display_order CHECK (display_order >= 0) +); + +-- Tabela: services +CREATE TABLE catalogs.services ( + id UUID PRIMARY KEY, + category_id UUID NOT NULL REFERENCES catalogs.service_categories(id), + name VARCHAR(150) NOT NULL UNIQUE, + description VARCHAR(1000), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT ck_services_display_order CHECK (display_order >= 0) +); + +-- Índices +CREATE INDEX idx_services_category_id ON catalogs.services(category_id); +CREATE INDEX idx_services_is_active ON catalogs.services(is_active); +CREATE INDEX idx_service_categories_is_active ON catalogs.service_categories(is_active); +CREATE INDEX idx_service_categories_display_order ON catalogs.service_categories(display_order); +CREATE INDEX idx_services_display_order ON catalogs.services(display_order); +``` + +## 🔗 Integração com Outros Módulos + +### **Providers Module (Futuro)** +```csharp +// Providers poderá vincular serviços aos prestadores +public class Provider +{ + public IReadOnlyCollection Services { get; } +} + +public class ProviderService +{ + public Guid ServiceId { get; set; } // FK para Catalogs.Service + public decimal Price { get; set; } + public bool IsOffered { get; set; } +} +``` + +### **Search Module (Futuro)** +```csharp +// Search denormalizará serviços no SearchableProvider +public class SearchableProvider +{ + public Guid[] ServiceIds { get; set; } // Array de IDs de serviços +} +``` + +## 📊 Estrutura de Pastas + +```plaintext +src/Modules/Catalogs/ +├── API/ +│ ├── Endpoints/ +│ │ ├── ServiceCategoryEndpoints.cs +│ │ ├── ServiceEndpoints.cs +│ │ └── CatalogsModuleEndpoints.cs +│ └── MeAjudaAi.Modules.Catalogs.API.csproj +├── Application/ +│ ├── Commands/ +│ │ ├── Service/ # 6 commands +│ │ └── ServiceCategory/ # 5 commands +│ ├── Queries/ +│ │ ├── Service/ # 3 queries +│ │ └── ServiceCategory/ # 3 queries +│ ├── Handlers/ +│ │ ├── Commands/ +│ │ │ └── CommandHandlers.cs # 11 handlers consolidados +│ │ └── Queries/ +│ │ └── QueryHandlers.cs # 6 handlers consolidados +│ ├── DTOs/ # 5 DTOs +│ ├── ModuleApi/ +│ │ └── CatalogsModuleApi.cs +│ └── MeAjudaAi.Modules.Catalogs.Application.csproj +├── Domain/ +│ ├── Entities/ +│ │ ├── Service.cs +│ │ └── ServiceCategory.cs +│ ├── Events/ +│ │ ├── ServiceDomainEvents.cs +│ │ └── ServiceCategoryDomainEvents.cs +│ ├── Exceptions/ +│ │ └── CatalogDomainException.cs +│ ├── Repositories/ +│ │ ├── IServiceRepository.cs +│ │ └── IServiceCategoryRepository.cs +│ ├── ValueObjects/ +│ │ ├── ServiceId.cs +│ │ └── ServiceCategoryId.cs +│ └── MeAjudaAi.Modules.Catalogs.Domain.csproj +├── Infrastructure/ +│ ├── Persistence/ +│ │ ├── CatalogsDbContext.cs +│ │ ├── Configurations/ +│ │ │ ├── ServiceConfiguration.cs +│ │ │ └── ServiceCategoryConfiguration.cs +│ │ └── Repositories/ +│ │ ├── ServiceRepository.cs +│ │ └── ServiceCategoryRepository.cs +│ ├── Extensions.cs +│ └── MeAjudaAi.Modules.Catalogs.Infrastructure.csproj +└── Tests/ + ├── Builders/ + │ ├── ServiceBuilder.cs + │ └── ServiceCategoryBuilder.cs + └── Unit/ + ├── Application/ + │ └── Handlers/ # Testes de handlers + └── Domain/ + └── Entities/ + ├── ServiceTests.cs # 15+ testes + └── ServiceCategoryTests.cs # 102 testes +``` + +## 🧪 Testes Implementados + +### **Testes Unitários de Domínio** +- ✅ **ServiceCategoryTests**: 102 testes passando + - Criação, atualização, ativação/desativação + - Boundary testing (MaxLength, MaxLength+1) + - Trimming de name/description + - Timestamp verification + - Idempotent operations +- ✅ **ServiceTests**: 15+ testes + - CRUD completo + - ChangeCategory + - Domain events + +### **Testes de Integração** +- ✅ **CatalogsIntegrationTests**: 29 testes passando + - Endpoints REST completos + - Module API + - Repository operations + +### **Cobertura de Código** +- Domain: >95% +- Application: >85% +- Infrastructure: >70% + +## 📈 Métricas e Desempenho + +### **Otimizações Implementadas** +- ✅ Batch query em ValidateServicesAsync (Contains predicate) +- ✅ GetByIdsAsync para evitar N+1 +- ✅ AsNoTracking() em queries read-only +- ✅ Índices em is_active, category_id, display_order +- ✅ Health check via query materialização (não Count extra) + +### **SLAs Esperados** +- GetById: <50ms +- GetAll: <200ms +- Create/Update: <100ms +- ValidateServices (batch): <300ms + +## 🚀 Próximos Passos + +### **Fase 2 - Integração com Providers** +- [ ] Criar tabela `provider_services` linking +- [ ] Permitir prestadores vincularem serviços do catálogo +- [ ] Adicionar pricing customizado por prestador + +### **Fase 3 - Search Integration** +- [ ] Denormalizar services em SearchableProvider +- [ ] Worker para sincronizar alterações via Integration Events +- [ ] Filtros de busca por serviço + +### **Melhorias Futuras** +- [ ] Hierarquia de subcategorias (atualmente flat) +- [ ] Ícones para categorias +- [ ] Localização (i18n) de nomes/descrições +- [ ] Versionamento de catálogo +- [ ] Audit log de mudanças administrativas + +## 📚 Referências + +- **[Roadmap](../roadmap.md)** - Planejamento estratégico +- **[Architecture](../architecture.md)** - Padrões arquiteturais +- **[Providers Module](./providers.md)** - Integração futura +- **[Search Module](./search.md)** - Integração de busca + +--- + +*📅 Implementado: Novembro 2025* +*✅ Status: Produção Ready* +*🧪 Testes: 102 unit + 29 integration (100% passing)* diff --git a/docs/modules/location.md b/docs/modules/location.md new file mode 100644 index 000000000..8c32a9f7c --- /dev/null +++ b/docs/modules/location.md @@ -0,0 +1,476 @@ +# 🗺️ Módulo Location - Geolocalização e CEP + +> **✅ Status**: Módulo **implementado e funcional** (Novembro 2025) + +## 🎯 Visão Geral + +O módulo **Location** é responsável por abstrair funcionalidades de **geolocalização** e **lookup de CEP brasileiro**, fornecendo uma API unificada e resiliente para outros módulos consumirem dados de localização. + +### **Responsabilidades** +- ✅ **Lookup de CEP** com fallback automático entre APIs brasileiras +- ✅ **Geocoding** de endereços para coordenadas (planejado) +- ✅ **Value Objects** para CEP, Coordenadas e Endereço +- ✅ **Validação** de CEP brasileiro +- ✅ **Resiliência** com retry e circuit breaker +- ✅ **API pública** para comunicação inter-módulos + +## 🏗️ Arquitetura Implementada + +### **Bounded Context: Location** +- **Sem schema próprio** (stateless module) +- **Padrão**: Service Layer + Value Objects +- **Integrações**: ViaCEP, BrasilAPI, OpenCEP + +### **Value Objects** + +#### **Cep** +```csharp +public sealed class Cep +{ + private const string CepPattern = @"^\d{8}$"; + public string Value { get; } // 12345678 (apenas números) + public string Formatted => $"{Value.Substring(0, 5)}-{Value.Substring(5)}"; // 12345-678 + + public static Cep? Create(string value) + { + var cleaned = Regex.Replace(value, @"\D", ""); + return Regex.IsMatch(cleaned, CepPattern) ? new Cep(cleaned) : null; + } + + public static bool IsValid(string value) => Create(value) is not null; +} +``` + +**Validações:** +- ✅ Deve ter exatamente 8 dígitos + - ✅ Remove automaticamente formatação (-, . e outros caracteres especiais) +- ✅ Factory method seguro (retorna null se inválido) + +#### **Coordinates** +```csharp +public sealed class Coordinates +{ + public double Latitude { get; } + public double Longitude { get; } + + public Coordinates(double latitude, double longitude) + { + if (latitude < -90 || latitude > 90) + throw new ArgumentException("Latitude must be between -90 and 90"); + + if (longitude < -180 || longitude > 180) + throw new ArgumentException("Longitude must be between -180 and 180"); + + Latitude = latitude; + Longitude = longitude; + } +} +``` + +**Validações:** +- ✅ Latitude: -90 a +90 +- ✅ Longitude: -180 a +180 + +#### **Address** +```csharp +public sealed class Address +{ + public Cep Cep { get; } + public string Street { get; } + public string Neighborhood { get; } + public string City { get; } + public string State { get; } // Sigla UF (SP, RJ, etc.) + public string? Complement { get; } + public Coordinates? GeoPoint { get; } + + public Address( + Cep cep, + string street, + string neighborhood, + string city, + string state, + string? complement = null, + Coordinates? geoPoint = null) + { + // Validações... + Cep = cep; + Street = street; + Neighborhood = neighborhood; + City = city; + State = state; + Complement = complement; + GeoPoint = geoPoint; + } +} +``` + +**Validações:** +- ✅ CEP válido +- ✅ Campos obrigatórios não vazios +- ✅ State com 2 caracteres (UF) + +## 🔌 Serviços Implementados + +### **ICepLookupService** + +```csharp +public interface ICepLookupService +{ + Task LookupAsync(Cep cep, CancellationToken cancellationToken = default); +} +``` + +### Implementação: Chain of Responsibility com Fallback + +```csharp +public class CepLookupService : ICepLookupService +{ + private readonly IViaCepClient _viaCepClient; + private readonly IBrasilApiCepClient _brasilApiClient; + private readonly IOpenCepClient _openCepClient; + + public async Task LookupAsync(Cep cep, CancellationToken ct = default) + { + // 1ª tentativa: ViaCEP (principal) + try + { + var result = await _viaCepClient.GetAddressAsync(cep.Value, ct); + if (result != null) return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ViaCEP failed for {Cep}, trying BrasilAPI", cep.Value); + } + + // 2ª tentativa: BrasilAPI (fallback 1) + try + { + var result = await _brasilApiClient.GetAddressAsync(cep.Value, ct); + if (result != null) return result; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "BrasilAPI failed for {Cep}, trying OpenCEP", cep.Value); + } + + // 3ª tentativa: OpenCEP (fallback 2) + try + { + return await _openCepClient.GetAddressAsync(cep.Value, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "All CEP providers failed for {Cep}", cep.Value); + return null; + } + } +} +``` + +**Recursos:** +- ✅ Fallback automático entre 3 providers +- ✅ Logging detalhado de falhas +- ✅ Resiliência via Polly (retry, circuit breaker, timeout) +- ✅ Configurável via appsettings.json + +### **IGeocodingService (Stub)** + +```csharp +public interface IGeocodingService +{ + Task GetCoordinatesAsync(string address, CancellationToken ct = default); + Task ReverseGeocodeAsync(Coordinates coordinates, CancellationToken ct = default); +} +``` + +**Status:** Interface definida, implementação futura (Nominatim ou Google Maps API) + +## 🌐 API Pública - Module API + +### **Interface ILocationModuleApi** + +```csharp +public interface ILocationModuleApi : IModuleApi +{ + Task> GetAddressFromCepAsync( + string cep, CancellationToken ct = default); + + Task> GetCoordinatesFromAddressAsync( + string address, CancellationToken ct = default); +} +``` + +### **DTOs Públicos** + +```csharp +public sealed record ModuleAddressDto( + string Cep, + string Street, + string Neighborhood, + string City, + string State, + string? Complement, + ModuleCoordinatesDto? Coordinates +); + +public sealed record ModuleCoordinatesDto( + double Latitude, + double Longitude +); +``` + +### **Implementação** + +```csharp +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] +public sealed class LocationsModuleApi : ILocationModuleApi +{ + private static class ModuleMetadata + { + public const string Name = "Location"; + public const string Version = "1.0"; + } + + // Health check via CEP real + public async Task IsAvailableAsync(CancellationToken ct = default) + { + var testCep = Cep.Create("01310100"); // Av. Paulista, SP + if (testCep is not null) + { + var result = await cepLookupService.LookupAsync(testCep, ct); + return true; // Se conseguiu fazer request, módulo está disponível + } + return false; + } + + public async Task> GetAddressFromCepAsync( + string cep, CancellationToken ct = default) + { + var cepValueObject = Cep.Create(cep); + if (cepValueObject is null) + return Result.Failure($"CEP inválido: {cep}"); + + var address = await cepLookupService.LookupAsync(cepValueObject, ct); + if (address is null) + return Result.Failure($"CEP {cep} não encontrado"); + + var dto = new ModuleAddressDto( + address.Cep.Formatted, + address.Street, + address.Neighborhood, + address.City, + address.State, + address.Complement, + address.GeoPoint is not null + ? new ModuleCoordinatesDto(address.GeoPoint.Latitude, address.GeoPoint.Longitude) + : null); + + return Result.Success(dto); + } +} +``` + +**Recursos:** +- ✅ Validação de CEP antes de lookup +- ✅ Mensagens de erro claras +- ✅ Health check via API real (não mock) + +## 🔧 Integrações com APIs Externas + +### **ViaCEP** +- **URL**: `https://viacep.com.br/ws/{cep}/json/` +- **Prioridade**: 1ª escolha +- **Rate Limit**: Sem limite oficial +- **Timeout**: 5 segundos + +### **BrasilAPI** +- **URL**: `https://brasilapi.com.br/api/cep/v1/{cep}` +- **Prioridade**: Fallback 1 +- **Rate Limit**: Sem limite +- **Timeout**: 5 segundos + +### **OpenCEP** +- **URL**: `https://opencep.com/v1/{cep}` +- **Prioridade**: Fallback 2 +- **Rate Limit**: 200 req/min +- **Timeout**: 5 segundos + +### **Resiliência (Polly)** + +```csharp +// ServiceDefaults configurado para todos os HttpClients +services.AddHttpClient() + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.CircuitBreaker.FailureRatio = 0.5; + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10); + }); +``` + +**Políticas:** +- ✅ **Retry**: 3 tentativas com backoff exponencial +- ✅ **Circuit Breaker**: Abre após 50% de falhas em 30s +- ✅ **Timeout**: 10s total (5s por tentativa) + +## 📊 Estrutura de Pastas + +```plaintext +src/Modules/Location/ +├── API/ +│ └── MeAjudaAi.Modules.Location.API.csproj +├── Application/ +│ ├── ModuleApi/ +│ │ └── LocationsModuleApi.cs +│ ├── Services/ +│ │ ├── ICepLookupService.cs +│ │ ├── CepLookupService.cs +│ │ └── IGeocodingService.cs +│ └── MeAjudaAi.Modules.Location.Application.csproj +├── Domain/ +│ ├── ValueObjects/ +│ │ ├── Cep.cs +│ │ ├── Coordinates.cs +│ │ └── Address.cs +│ └── MeAjudaAi.Modules.Location.Domain.csproj +├── Infrastructure/ +│ ├── ExternalServices/ +│ │ ├── ViaCEP/ +│ │ │ ├── IViaCepClient.cs +│ │ │ └── ViaCepClient.cs +│ │ ├── BrasilAPI/ +│ │ │ ├── IBrasilApiCepClient.cs +│ │ │ └── BrasilApiCepClient.cs +│ │ └── OpenCEP/ +│ │ ├── IOpenCepClient.cs +│ │ └── OpenCepClient.cs +│ ├── Extensions.cs +│ └── MeAjudaAi.Modules.Location.Infrastructure.csproj +└── Tests/ + └── Unit/ + └── Domain/ + └── ValueObjects/ + ├── CepTests.cs # 20+ testes + ├── CoordinatesTests.cs # 15+ testes + └── AddressTests.cs # 17+ testes +``` + +## 🧪 Testes Implementados + +### **Testes Unitários de Value Objects** +- ✅ **CepTests**: 20+ testes + - Validação de formato + - Remoção de caracteres especiais + - CEPs válidos/inválidos + - Formatação +- ✅ **CoordinatesTests**: 15+ testes + - Limites de latitude/longitude + - Edge cases (polos, linha do equador) +- ✅ **AddressTests**: 17+ testes + - Validação de campos obrigatórios + - State UF validation + - GeoPoint opcional + +### **Cobertura de Código** +- Domain (Value Objects): 100% +- Application (Services): ~70% +- Infrastructure (Clients): ~60% + +### Total: 52 testes unitários passando + +## 🔗 Integração com Outros Módulos + +### **Providers Module** +```csharp +public class BusinessProfile +{ + public Address PrimaryAddress { get; private set; } + + // Usa Location.ModuleAPI para validar/enriquecer endereço + public async Task SetAddressFromCep(string cep) + { + var result = await _locationApi.GetAddressFromCepAsync(cep); + if (result.IsSuccess) + { + PrimaryAddress = result.Value.ToAddress(); + } + } +} +``` + +### **Search Module** +```csharp +public class SearchableProvider +{ + public GeoPoint Location { get; set; } // Latitude/Longitude + + // Location module fornece coordenadas para queries espaciais +} +``` + +## 📈 Métricas e Desempenho + +### **SLAs Esperados** +- Lookup de CEP: <500ms (com fallback) +- Geocoding: <1000ms (quando implementado) +- Health check: <200ms + +### **Otimizações Futuras** +- [ ] Cache Redis para CEPs (TTL: 24h) +- [ ] Warm-up de circuit breakers no startup + - [ ] Métricas customizadas (Polly telemetry) + +## 🚀 Próximos Passos + +### **Fase 2 - Geocoding** +- [ ] Implementar `GeocodingService` +- [ ] Integração com Nominatim (OpenStreetMap) ou Google Maps API +- [ ] Reverse geocoding (coordenadas → endereço) + +### **Fase 3 - Caching** +- [ ] Redis cache para CEPs +- [ ] Cache de coordenadas +- [ ] Invalidação por TTL + +### **Fase 4 - Enriquecimento** +- [ ] Integração com IBGE para municípios +- [ ] Validação de logradouros +- [ ] Distância entre pontos (Haversine) + +## ⚙️ Configuração + +### **appsettings.json** +```json +{ + "ExternalServices": { + "ViaCEP": { + "BaseUrl": "https://viacep.com.br/ws", + "Timeout": 5000 + }, + "BrasilAPI": { + "BaseUrl": "https://brasilapi.com.br/api/cep/v1", + "Timeout": 5000 + }, + "OpenCEP": { + "BaseUrl": "https://opencep.com/v1", + "Timeout": 5000 + } + } +} +``` + +## 📚 Referências + +- **[Roadmap](../roadmap.md)** - Planejamento estratégico +- **[Architecture](../architecture.md)** - Padrões arquiteturais +- **[Providers Module](./providers.md)** - Integração com endereços +- **[ViaCEP API](https://viacep.com.br)** - Documentação oficial +- **[BrasilAPI](https://brasilapi.com.br)** - Documentação oficial + +--- + +*📅 Implementado: Novembro 2025* +*✅ Status: Produção Ready (CEP lookup)* +*🔄 Geocoding: Planejado (Q1 2026)* +*🧪 Testes: 52 unit tests (100% passing)* diff --git a/docs/modules/services.md b/docs/modules/services.md deleted file mode 100644 index a647159aa..000000000 --- a/docs/modules/services.md +++ /dev/null @@ -1,557 +0,0 @@ -# 📋 Módulo Services - Catálogo de Serviços (Planejado) - -> **⚠️ Status**: Este módulo está **em planejamento** e será implementado na próxima fase do projeto. - -## 🎯 Visão Geral - -O módulo Services será responsável pelo **catálogo de serviços** oferecidos pelos prestadores na plataforma MeAjudaAi, implementando um Bounded Context dedicado para gestão de serviços e categorização. - -### **Responsabilidades Planejadas** -- 🔄 **Catálogo de serviços** hierárquico por categorias -- 🔄 **Gestão de preços** e modelos de precificação -- 🔄 **Disponibilidade** e configuração de horários -- 🔄 **Duração** e estimativas de tempo -- 🔄 **Requisitos** e pré-condições -- 🔄 **Avaliações** e feedback dos serviços - -## 🏗️ Arquitetura Planejada - -### **Domain Model (Conceitual)** - -#### **Agregado Principal: Service** -```csharp -/// -/// Agregado raiz para serviços oferecidos -/// -public sealed class Service : AggregateRoot -{ - public Guid ProviderId { get; private set; } // Prestador responsável - public string Name { get; private set; } // Nome do serviço - public string Description { get; private set; } // Descrição detalhada - public CategoryId CategoryId { get; private set; } // Categoria do serviço - public PricingModel Pricing { get; private set; } // Modelo de precificação - public ServiceDuration Duration { get; private set; } // Duração estimada - public ServiceArea ServiceArea { get; private set; } // Área de atendimento - public ServiceStatus Status { get; private set; } // Status do serviço - - // Coleções - public IReadOnlyCollection Requirements { get; } - public IReadOnlyCollection Reviews { get; } -} -``` - -#### **Agregado: Category** -```csharp -/// -/// Categoria hierárquica de serviços -/// -public sealed class Category : AggregateRoot -{ - public string Name { get; private set; } - public string Description { get; private set; } - public string IconUrl { get; private set; } - public CategoryId? ParentCategoryId { get; private set; } - public int SortOrder { get; private set; } - - // Navegação hierárquica - public IReadOnlyCollection SubCategories { get; } -} -``` - -### **Value Objects Planejados** - -#### **PricingModel** -```csharp -public class PricingModel : ValueObject -{ - public EPricingType Type { get; private set; } // Fixed, Hourly, Custom - public decimal BasePrice { get; private set; } - public decimal? MinPrice { get; private set; } - public decimal? MaxPrice { get; private set; } - public string Currency { get; private set; } - public IReadOnlyList Modifiers { get; private set; } -} -``` - -#### **ServiceDuration** -```csharp -public class ServiceDuration : ValueObject -{ - public TimeSpan EstimatedDuration { get; private set; } - public TimeSpan? MinDuration { get; private set; } - public TimeSpan? MaxDuration { get; private set; } - public EDurationType Type { get; private set; } // Fixed, Variable, Negotiable -} -``` - -#### **ServiceArea** -```csharp -public class ServiceArea : ValueObject -{ - public EServiceAreaType Type { get; private set; } // OnSite, Remote, Both - public decimal? MaxRadius { get; private set; } // Raio máximo (km) - public IReadOnlyList SupportedCities { get; private set; } - public IReadOnlyList SupportedStates { get; private set; } -} -``` - -### **Enumerações Planejadas** - -#### **EPricingType** -```csharp -public enum EPricingType -{ - Fixed = 0, // Preço fixo - Hourly = 1, // Por hora - Daily = 2, // Por dia - PerItem = 3, // Por item/unidade - Custom = 4, // Negociável - Package = 5 // Pacote de serviços -} -``` - -#### **EServiceStatus** -```csharp -public enum EServiceStatus -{ - Draft = 0, // Rascunho - Active = 1, // Ativo - Inactive = 2, // Inativo - Suspended = 3, // Suspenso - UnderReview = 4 // Em análise -} -``` - -#### **EDurationType** -```csharp -public enum EDurationType -{ - Fixed = 0, // Duração fixa - Variable = 1, // Duração variável - Negotiable = 2, // Negociável - Depends = 3 // Depende do escopo -} -``` - -## 🔄 Domain Events Planejados - -```csharp -// Eventos de serviços -public record ServiceCreatedDomainEvent(Guid ServiceId, Guid ProviderId, string Name); -public record ServicePriceUpdatedDomainEvent(Guid ServiceId, PricingModel OldPricing, PricingModel NewPricing); -public record ServiceActivatedDomainEvent(Guid ServiceId, DateTime ActivatedAt); -public record ServiceDeactivatedDomainEvent(Guid ServiceId, string Reason); - -// Eventos de categorias -public record CategoryCreatedDomainEvent(Guid CategoryId, string Name, Guid? ParentId); -public record CategoryReorganizedDomainEvent(Guid CategoryId, Guid? OldParentId, Guid? NewParentId); -``` - -## ⚡ CQRS Planejado - -### **Commands** -- 🔄 **CreateServiceCommand**: Criar novo serviço -- 🔄 **UpdateServiceCommand**: Atualizar serviço -- 🔄 **UpdateServicePricingCommand**: Atualizar preços -- 🔄 **ActivateServiceCommand**: Ativar serviço -- 🔄 **DeactivateServiceCommand**: Desativar serviço -- 🔄 **CreateCategoryCommand**: Criar categoria -- 🔄 **ReorganizeCategoryCommand**: Reorganizar hierarquia - -### **Queries** -- 🔄 **GetServiceByIdQuery**: Buscar serviço por ID -- 🔄 **GetServicesByProviderQuery**: Serviços de um prestador -- 🔄 **GetServicesByCategoryQuery**: Serviços por categoria -- 🔄 **SearchServicesQuery**: Busca com filtros -- 🔄 **GetCategoriesTreeQuery**: Árvore de categorias -- 🔄 **GetPopularServicesQuery**: Serviços populares - -## 🌐 API Endpoints Planejados - -### **Serviços** -- 🔄 `POST /api/v1/services` - Criar serviço -- 🔄 `GET /api/v1/services` - Listar serviços (com filtros) -- 🔄 `GET /api/v1/services/{id}` - Obter serviço -- 🔄 `PUT /api/v1/services/{id}` - Atualizar serviço -- 🔄 `DELETE /api/v1/services/{id}` - Excluir serviço -- 🔄 `GET /api/v1/services/search` - Buscar serviços -- 🔄 `GET /api/v1/providers/{providerId}/services` - Serviços do prestador - -### **Categorias** -- 🔄 `GET /api/v1/categories` - Listar categorias -- 🔄 `GET /api/v1/categories/tree` - Árvore hierárquica -- 🔄 `GET /api/v1/categories/{id}/services` - Serviços da categoria -- 🔄 `POST /api/v1/categories` - Criar categoria (admin) -- 🔄 `PUT /api/v1/categories/{id}` - Atualizar categoria (admin) - -## 🔌 Module API Planejada - -### **Interface IServicesModuleApi** -```csharp -public interface IServicesModuleApi : IModuleApi -{ - Task> GetServiceByIdAsync(Guid serviceId, CancellationToken cancellationToken = default); - Task>> GetServicesByProviderAsync(Guid providerId, CancellationToken cancellationToken = default); - Task>> SearchServicesAsync(SearchServicesRequest request, CancellationToken cancellationToken = default); - Task> ServiceExistsAsync(Guid serviceId, CancellationToken cancellationToken = default); - Task> GetCategoryByIdAsync(Guid categoryId, CancellationToken cancellationToken = default); -} -``` - -### **DTOs Planejados** -```csharp -public sealed record ModuleServiceDto -{ - public required Guid Id { get; init; } - public required Guid ProviderId { get; init; } - public required string Name { get; init; } - public required string Description { get; init; } - public required string CategoryName { get; init; } - public required decimal BasePrice { get; init; } - public required string Currency { get; init; } - public required TimeSpan EstimatedDuration { get; init; } - public required bool IsActive { get; init; } -} - -public sealed record ModuleServiceBasicDto -{ - public required Guid Id { get; init; } - public required string Name { get; init; } - public required decimal BasePrice { get; init; } - public required string Currency { get; init; } - public required bool IsActive { get; init; } -} -``` - -## 🗄️ Schema de Banco Planejado - -### **Tabelas Principais** -```sql --- Categorias hierárquicas -CREATE TABLE services.Categories ( - Id uuid PRIMARY KEY, - Name varchar(200) NOT NULL, - Description text, - IconUrl varchar(500), - ParentCategoryId uuid REFERENCES services.Categories(Id), - SortOrder int NOT NULL DEFAULT 0, - IsActive boolean NOT NULL DEFAULT true, - CreatedAt timestamp NOT NULL DEFAULT NOW(), - UpdatedAt timestamp NULL -); - --- Serviços -CREATE TABLE services.Services ( - Id uuid PRIMARY KEY, - ProviderId uuid NOT NULL, -- Referência ao Providers module - CategoryId uuid NOT NULL REFERENCES services.Categories(Id), - Name varchar(200) NOT NULL, - Description text NOT NULL, - - -- Pricing - PricingType int NOT NULL, -- EPricingType - BasePrice decimal(10,2) NOT NULL, - MinPrice decimal(10,2), - MaxPrice decimal(10,2), - Currency varchar(3) NOT NULL DEFAULT 'BRL', - - -- Duration - EstimatedDurationMinutes int NOT NULL, - MinDurationMinutes int, - MaxDurationMinutes int, - DurationType int NOT NULL, -- EDurationType - - -- Service Area - ServiceAreaType int NOT NULL, -- EServiceAreaType - MaxRadiusKm decimal(8,2), - - Status int NOT NULL DEFAULT 0, -- EServiceStatus - IsDeleted boolean NOT NULL DEFAULT false, - DeletedAt timestamp NULL, - CreatedAt timestamp NOT NULL DEFAULT NOW(), - UpdatedAt timestamp NULL -); - --- Requisitos de serviço -CREATE TABLE services.ServiceRequirements ( - Id uuid PRIMARY KEY, - ServiceId uuid NOT NULL REFERENCES services.Services(Id), - Name varchar(200) NOT NULL, - Description text, - IsRequired boolean NOT NULL DEFAULT true, - SortOrder int NOT NULL DEFAULT 0 -); - --- Modificadores de preço -CREATE TABLE services.PriceModifiers ( - Id uuid PRIMARY KEY, - ServiceId uuid NOT NULL REFERENCES services.Services(Id), - Name varchar(200) NOT NULL, - Type int NOT NULL, -- EPriceModifierType - Value decimal(10,2) NOT NULL, - Unit varchar(50) -- '%', 'fixed', 'per_hour', etc. -); -``` - -## 🔗 Integração com Outros Módulos - -### **Com Módulo Providers** -```csharp -// Services referencia Providers -public class Service : AggregateRoot -{ - public Guid ProviderId { get; private set; } // FK para Provider - // Validação: só prestadores verificados podem criar serviços -} -``` - -### **Com Módulo Bookings (Futuro)** -```csharp -// Bookings usa Services para agendamentos -public class Booking : AggregateRoot -{ - public Guid ServiceId { get; private set; } // FK para Service - public Guid ProviderId { get; private set; } // FK para Provider - public Guid CustomerId { get; private set; } // FK para User -} -``` - -## 📊 Métricas Planejadas - -### **Business Metrics** -- **services_by_category**: Distribuição de serviços por categoria -- **average_service_price**: Preço médio por categoria -- **service_creation_rate**: Taxa de criação de novos serviços -- **popular_categories**: Categorias mais procuradas - -### **Technical Metrics** -- **service_search_desempenho**: Desempenho de buscas -- **category_tree_load_time**: Tempo de carregamento da árvore -- **service_availability_uptime**: Disponibilidade dos serviços - -## 🧪 Estratégia de Testes Planejada - -### **Testes de Domínio** -- ✅ **Agregado Service**: Validações de negócio -- ✅ **Value Objects**: PricingModel, ServiceDuration, ServiceArea -- ✅ **Domain Events**: Criação, atualização, ativação - -### **Testes de Integração** -- ✅ **API Endpoints**: CRUD completo de serviços -- ✅ **Search Engine**: Busca e filtros avançados -- ✅ **Module API**: Comunicação com outros módulos - -### **Testes de Desempenho** -- ✅ **Busca de serviços**: Desempenho com grandes volumes -- ✅ **Árvore de categorias**: Carregamento eficiente -- ✅ **Filtros complexos**: Otimização de queries - -## 🚀 Roadmap de Implementação - -### **Fase 1: Core Services** -- 🔄 Criar estrutura básica do módulo -- 🔄 Implementar agregado Service -- 🔄 CRUD básico de serviços -- 🔄 Integração com Providers - -### **Fase 2: Categorização** -- 🔄 Implementar sistema de categorias -- 🔄 Árvore hierárquica -- 🔄 Interface de administração - -### **Fase 3: Search & Filters** -- 🔄 Sistema de busca avançada -- 🔄 Filtros por preço, localização, duração -- 🔄 Elasticsearch integration (opcional) - -### **Fase 4: Advanced Features** -- 🔄 Sistema de avaliações -- 🔄 Recomendações inteligentes -- 🔄 Analytics e métricas avançadas - -## � Padrões Distribuídos e Comunicação Inter-Módulos - -### **Event-Driven Communication** -O módulo Services participa ativamente de workflows distribuídos via Domain Events: - -```csharp -// Eventos disparados pelo módulo Services -public record ServiceCreatedEvent(Guid ServiceId, Guid ProviderId, string CategoryId) : IDomainEvent; -public record ServiceActivatedEvent(Guid ServiceId, Guid ProviderId) : IDomainEvent; -public record ServicePriceUpdatedEvent(Guid ServiceId, decimal OldPrice, decimal NewPrice) : IDomainEvent; -public record ServiceDeactivatedEvent(Guid ServiceId, Guid ProviderId, string Reason) : IDomainEvent; - -// Eventos consumidos de outros módulos -public record ProviderVerificationStatusChangedEvent(Guid ProviderId, VerificationStatus Status) : IDomainEvent; -public record ProviderSuspendedEvent(Guid ProviderId, string Reason) : IDomainEvent; -``` - -### **Saga Pattern para Criação de Serviços** -Coordenação distribuída quando um novo serviço é criado: - -```csharp -public class ServiceCreationSaga : ISagaOrchestrator -{ - public async Task HandleAsync(CreateServiceCommand command) - { - var sagaId = Guid.NewGuid(); - - try - { - // 1. Validar provedor está verificado - var provider = await _moduleApi.Providers.GetByIdAsync(command.ProviderId); - if (provider.Status != ProviderStatus.Verified) - throw new ProviderNotVerifiedException(); - - // 2. Criar serviço - var service = Service.Create(command.Name, command.Description, provider.Id); - await _serviceRepository.AddAsync(service); - - // 3. Indexar no sistema de busca - await _searchIndexer.IndexServiceAsync(service); - - // 4. Notificar outros módulos - await _eventBus.PublishAsync(new ServiceCreatedEvent(service.Id, provider.Id, service.CategoryId)); - - // 5. Confirmar saga - await _sagaRepository.CompleteAsync(sagaId); - } - catch (Exception ex) - { - // Compensação: reverter operações realizadas - await CompensateServiceCreation(sagaId, ex); - } - } -} -``` - -### **Eventual Consistency e Sincronização** -Estratégias para manter consistência entre módulos: - -```csharp -// Handler para mudanças no status do Provider -public class ProviderStatusChangedHandler : IEventHandler -{ - public async Task HandleAsync(ProviderVerificationStatusChangedEvent evt) - { - if (evt.Status == VerificationStatus.Suspended) - { - // Desativar todos os serviços do provedor - var services = await _serviceRepository.GetByProviderIdAsync(evt.ProviderId); - - foreach (var service in services) - { - service.Deactivate("Provider suspended"); - await _eventBus.PublishAsync(new ServiceDeactivatedEvent( - service.Id, evt.ProviderId, "Provider suspended")); - } - - await _serviceRepository.UpdateRangeAsync(services); - } - } -} -``` - -### **Module API para Comunicação Síncrona** -Interface para consultas diretas de outros módulos: - -```csharp -public interface IServicesModuleApi -{ - Task GetByIdAsync(Guid serviceId); - Task> GetByProviderIdAsync(Guid providerId); - Task> SearchServicesAsync(ServiceSearchCriteria criteria); - Task IsServiceAvailableAsync(Guid serviceId); - Task GetServicePricingAsync(Guid serviceId); -} - -// Implementação do Module API -public class ServicesModuleApi : IServicesModuleApi -{ - private readonly IServiceRepository _serviceRepository; - private readonly IMapper _mapper; - - public async Task GetByIdAsync(Guid serviceId) - { - var service = await _serviceRepository.GetByIdAsync(serviceId); - return _mapper.Map(service); - } - - public async Task IsServiceAvailableAsync(Guid serviceId) - { - var service = await _serviceRepository.GetByIdAsync(serviceId); - return service?.IsActive == true && service.IsAvailable; - } -} -``` - -### **Caching Distribuído para Desempenho** -Estratégia de cache para reduzir latência entre módulos: - -```csharp -public class CachedServicesModuleApi : IServicesModuleApi -{ - private readonly IServicesModuleApi _inner; - private readonly IDistributedCache _cache; - - public async Task GetByIdAsync(Guid serviceId) - { - var cacheKey = $"service:{serviceId}"; - var cached = await _cache.GetStringAsync(cacheKey); - - if (cached != null) - return JsonSerializer.Deserialize(cached); - - var service = await _inner.GetByIdAsync(serviceId); - - await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(service), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) - }); - - return service; - } -} -``` - -## �📋 Dependências e Pré-requisitos - -### **Módulos Necessários** -- ✅ **Users**: Já implementado -- ✅ **Providers**: Já implementado -- 🔄 **Shared**: Extensões para search e caching - -### **Infraestrutura** -- 🔄 **Search Engine**: Elasticsearch ou alternativa -- 🔄 **Cache**: Redis para consultas frequentes -- 🔄 **Image Storage**: Para ícones de categorias - -## 📚 Referências para Implementação - -- **[Módulo Providers](./providers.md)** - Integração com prestadores -- **[Módulo Users](./users.md)** - Base de usuários -- **[Arquitetura](../architecture.md)** - Padrões e estrutura -- **[Search Patterns](../patterns/search-patterns.md)** - Padrões de busca (a criar) - ---- - -## 📝 Notas de Implementação - -### **Decisões Técnicas Pendentes** -1. **Search Engine**: Elasticsearch vs. PostgreSQL Full-Text Search -2. **Categorization**: Hierarquia vs. Tags vs. Ambos -3. **Pricing**: Flexibilidade vs. Simplicidade -4. **Caching Strategy**: Níveis e invalidação - -### **Considerações de Desempenho** -- **Indexação eficiente** para buscas -- **Cache de categorias** (raramente mudam) -- **Pagination** para grandes volumes -- **Lazy loading** de relacionamentos - ---- - -*📅 Planejamento: Novembro 2025* -*🎯 Implementação prevista: Q1 2026* -*✨ Documentação mantida pela equipe de desenvolvimento MeAjudaAi* \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index c423ba1bb..7431833c7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -218,7 +218,7 @@ public interface ILocationModuleApi : IModuleApi --- -### 1.6. ✅ Módulo Service Catalog (Concluído) +### 1.6. ✅ Módulo Catalogs (Concluído) **Status**: Implementado e funcional com testes completos @@ -688,7 +688,7 @@ web/ **Funcionalidades Core**: - **User & Provider Management**: Visualizar, suspender, verificar manualmente -- **Service Catalog Management**: Aprovar/rejeitar serviços sugeridos +- **Catalogs Management**: Aprovar/rejeitar serviços sugeridos - **Review Moderation**: Lidar com reviews sinalizados - **Dashboard**: Métricas-chave do módulo Analytics @@ -780,7 +780,7 @@ web/ 3. ✅ Módulo Documents (Concluído) 4. ✅ Módulo Search & Discovery (Concluído) 5. 📋 Módulo Location - CEP lookup e geocoding -6. 📋 Módulo Service Catalog - Catálogo admin-managed +6. 📋 Módulo Catalogs - Catálogo admin-managed de categorias e serviços 7. 📋 Admin Portal - Gestão básica 8. 📋 Customer Profile - Gestão de perfil diff --git a/infrastructure/README.md b/infrastructure/README.md index a640df803..a8b37aa35 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -259,7 +259,7 @@ The test scripts verify: ✅ All module schemas created correctly: - `users`, `providers`, `documents` -- `search`, `location` +- `search`, `location`, `catalogs` - `hangfire`, `meajudaai_app` ✅ All database roles created: diff --git a/infrastructure/database/README.md b/infrastructure/database/README.md index 28107e289..ad3a2ad05 100644 --- a/infrastructure/database/README.md +++ b/infrastructure/database/README.md @@ -23,6 +23,9 @@ database/ │ ├── location/ # Location module (CEP lookup and geocoding) │ │ ├── 00-roles.sql # Database roles for location module │ │ └── 01-permissions.sql # Permissions setup for location module +│ └── catalogs/ # Service Catalog module (admin-managed) +│ ├── 00-roles.sql # Database roles for catalogs module +│ └── 01-permissions.sql # Permissions setup for catalogs module └── views/ # Cross-module database views └── cross-module-views.sql # Views that span multiple modules (includes document status views) ``` @@ -70,6 +73,7 @@ The database initialization creates the following schemas: | `documents` | Documents | Document upload, verification, and storage metadata | | `search` | Search & Discovery | Geospatial provider search with PostGIS | | `location` | Location | CEP lookup, address validation, and geocoding | +| `catalogs` | Service Catalog | Admin-managed service categories and services | | `hangfire` | Background Jobs | Hangfire job queue and execution tracking | | `meajudaai_app` | Shared | Cross-cutting application objects | diff --git a/infrastructure/database/modules/catalogs/00-roles.sql b/infrastructure/database/modules/catalogs/00-roles.sql new file mode 100644 index 000000000..7d83dff58 --- /dev/null +++ b/infrastructure/database/modules/catalogs/00-roles.sql @@ -0,0 +1,55 @@ +-- Catalogs Module - Database Roles +-- Create dedicated role for catalogs module (NOLOGIN role for permission grouping) + +-- Create catalogs module role if it doesn't exist (NOLOGIN, no password in DDL) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'catalogs_role') THEN + CREATE ROLE catalogs_role NOLOGIN INHERIT; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Create catalogs module owner role if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'catalogs_owner') THEN + CREATE ROLE catalogs_owner NOLOGIN INHERIT; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Grant catalogs role to app role for cross-module access (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_auth_members m + JOIN pg_roles r1 ON m.roleid = r1.oid + JOIN pg_roles r2 ON m.member = r2.oid + WHERE r1.rolname = 'catalogs_role' AND r2.rolname = 'meajudaai_app_role' + ) THEN + GRANT catalogs_role TO meajudaai_app_role; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Grant catalogs_owner to app_owner (for schema management) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_auth_members m + JOIN pg_roles r1 ON m.roleid = r1.oid + JOIN pg_roles r2 ON m.member = r2.oid + WHERE r1.rolname = 'catalogs_owner' AND r2.rolname = 'meajudaai_app_owner' + ) THEN + GRANT catalogs_owner TO meajudaai_app_owner; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- NOTE: Actual LOGIN users with passwords should be created in environment-specific +-- migrations that read passwords from secure session GUCs or configuration, not in versioned DDL. + +-- Document roles +COMMENT ON ROLE catalogs_role IS 'Permission grouping role for catalogs schema'; +COMMENT ON ROLE catalogs_owner IS 'Owner role for catalogs schema objects'; diff --git a/infrastructure/database/modules/catalogs/01-permissions.sql b/infrastructure/database/modules/catalogs/01-permissions.sql new file mode 100644 index 000000000..92917d87a --- /dev/null +++ b/infrastructure/database/modules/catalogs/01-permissions.sql @@ -0,0 +1,39 @@ +-- Catalogs Module - Permissions +-- Grant permissions for catalogs module (service catalog management) + +-- Create catalogs schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS catalogs; + +-- Set explicit schema ownership +ALTER SCHEMA catalogs OWNER TO catalogs_owner; + +GRANT USAGE ON SCHEMA catalogs TO catalogs_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA catalogs TO catalogs_role; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA catalogs TO catalogs_role; + +-- Set default privileges for future tables and sequences created by catalogs_owner +ALTER DEFAULT PRIVILEGES FOR ROLE catalogs_owner IN SCHEMA catalogs GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO catalogs_role; +ALTER DEFAULT PRIVILEGES FOR ROLE catalogs_owner IN SCHEMA catalogs GRANT USAGE, SELECT ON SEQUENCES TO catalogs_role; + +-- Set default search path for catalogs_role +ALTER ROLE catalogs_role SET search_path = catalogs, public; + +-- Grant cross-schema permissions to app role +GRANT USAGE ON SCHEMA catalogs TO meajudaai_app_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA catalogs TO meajudaai_app_role; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA catalogs TO meajudaai_app_role; + +-- Set default privileges for app role on objects created by catalogs_owner +ALTER DEFAULT PRIVILEGES FOR ROLE catalogs_owner IN SCHEMA catalogs GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO meajudaai_app_role; +ALTER DEFAULT PRIVILEGES FOR ROLE catalogs_owner IN SCHEMA catalogs GRANT USAGE, SELECT ON SEQUENCES TO meajudaai_app_role; + +-- Grant read-only access to providers schema (for future ProviderServices integration) +GRANT USAGE ON SCHEMA providers TO catalogs_role; +GRANT SELECT ON ALL TABLES IN SCHEMA providers TO catalogs_role; + +-- Grant read-only access to search schema (for denormalization of services) +GRANT USAGE ON SCHEMA search TO catalogs_role; +GRANT SELECT ON ALL TABLES IN SCHEMA search TO catalogs_role; + +-- Document schema purpose +COMMENT ON SCHEMA catalogs IS 'Service Catalog module - Admin-managed service categories and services'; diff --git a/infrastructure/test-database-init.ps1 b/infrastructure/test-database-init.ps1 index f5b1662c0..d5e5188dd 100644 --- a/infrastructure/test-database-init.ps1 +++ b/infrastructure/test-database-init.ps1 @@ -76,7 +76,7 @@ try { # Test schemas $hasErrors = $false - $schemas = @("users", "providers", "documents", "search", "location", "hangfire", "meajudaai_app") + $schemas = @("users", "providers", "documents", "search", "location", "catalogs", "hangfire", "meajudaai_app") foreach ($schema in $schemas) { $query = "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = '$schema');" @@ -101,6 +101,7 @@ try { "documents_role", "documents_owner", "search_role", "search_owner", "location_role", "location_owner", + "catalogs_role", "catalogs_owner", "hangfire_role", "meajudaai_app_role", "meajudaai_app_owner" ) diff --git a/infrastructure/test-database-init.sh b/infrastructure/test-database-init.sh index 4396122a6..22b2b3b52 100644 --- a/infrastructure/test-database-init.sh +++ b/infrastructure/test-database-init.sh @@ -74,7 +74,7 @@ echo "🔍 Verifying database schemas..." has_errors=false # Test schemas -SCHEMAS=("users" "providers" "documents" "search" "location" "hangfire" "meajudaai_app") +SCHEMAS=("users" "providers" "documents" "search" "location" "catalogs" "hangfire" "meajudaai_app") for schema in "${SCHEMAS[@]}"; do # Use double-dollar quoting to safely handle identifiers @@ -99,6 +99,7 @@ ROLES=( "documents_role" "documents_owner" "search_role" "search_owner" "location_role" "location_owner" + "catalogs_role" "catalogs_owner" "hangfire_role" "meajudaai_app_role" "meajudaai_app_owner" ) diff --git a/src/Aspire/MeAjudaAi.AppHost/Properties/launchSettings.json b/src/Aspire/MeAjudaAi.AppHost/Properties/launchSettings.json deleted file mode 100644 index dcc8ab8b9..000000000 --- a/src/Aspire/MeAjudaAi.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17063;http://localhost:15297", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21041", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22145" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15297", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19102", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20208" - } - } - } -} diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj index 6049cf73e..a077f15c6 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj +++ b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs index b833fc449..524a4e1ac 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using MeAjudaAi.ApiService.Extensions; +using MeAjudaAi.Modules.Catalogs.API; using MeAjudaAi.Modules.Documents.API; using MeAjudaAi.Modules.Location.Infrastructure; using MeAjudaAi.Modules.Providers.API; @@ -33,6 +34,7 @@ public static async Task Main(string[] args) builder.Services.AddDocumentsModule(builder.Configuration); builder.Services.AddSearchModule(builder.Configuration); builder.Services.AddLocationModule(builder.Configuration); + builder.Services.AddCatalogsModule(builder.Configuration); var app = builder.Build(); @@ -106,6 +108,7 @@ private static async Task ConfigureMiddlewareAsync(WebApplication app) app.UseDocumentsModule(); app.UseSearchModule(); app.UseLocationModule(); + app.UseCatalogsModule(); } private static void LogStartupComplete(WebApplication app) diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Properties/launchSettings.json b/src/Bootstrapper/MeAjudaAi.ApiService/Properties/launchSettings.json deleted file mode 100644 index fda3ff27b..000000000 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5545", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7524;http://localhost:5545", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Modules/Catalogs/API/Endpoints/CatalogsModuleEndpoints.cs b/src/Modules/Catalogs/API/Endpoints/CatalogsModuleEndpoints.cs new file mode 100644 index 000000000..ab387f946 --- /dev/null +++ b/src/Modules/Catalogs/API/Endpoints/CatalogsModuleEndpoints.cs @@ -0,0 +1,42 @@ +using MeAjudaAi.Shared.Endpoints; +using Microsoft.AspNetCore.Builder; + +namespace MeAjudaAi.Modules.Catalogs.API.Endpoints; + +/// +/// Classe responsável pelo mapeamento de todos os endpoints do módulo Catalogs. +/// +public static class CatalogsModuleEndpoints +{ + /// + /// Mapeia todos os endpoints do módulo Catalogs. + /// + /// Aplicação web para configuração das rotas + public static void MapCatalogsEndpoints(this WebApplication app) + { + // Service Categories endpoints + var categoriesEndpoints = BaseEndpoint.CreateVersionedGroup(app, "catalogs/categories", "ServiceCategories"); + + categoriesEndpoints.MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); + + // Services endpoints + var servicesEndpoints = BaseEndpoint.CreateVersionedGroup(app, "catalogs/services", "Services"); + + servicesEndpoints.MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); + } +} diff --git a/src/Modules/Catalogs/API/Endpoints/ServiceCategoryEndpoints.cs b/src/Modules/Catalogs/API/Endpoints/ServiceCategoryEndpoints.cs new file mode 100644 index 000000000..421b7f2a5 --- /dev/null +++ b/src/Modules/Catalogs/API/Endpoints/ServiceCategoryEndpoints.cs @@ -0,0 +1,196 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Catalogs.API.Endpoints; + +// ============================================================ +// Request DTOs +// ============================================================ + +public record CreateServiceCategoryRequest(string Name, string? Description, int DisplayOrder); +public record UpdateServiceCategoryRequest(string Name, string? Description, int DisplayOrder); + +// ============================================================ +// CREATE +// ============================================================ + +public class CreateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/", CreateAsync) + .WithName("CreateServiceCategory") + .WithSummary("Criar categoria de serviço") + .Produces>(StatusCodes.Status201Created) + .RequireAdmin(); + + private static async Task CreateAsync( + [FromBody] CreateServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new CreateServiceCategoryCommand(request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync>( + command, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + return Handle(result, "GetServiceCategoryById", new { id = result.Value!.Id }); + } +} + +// ============================================================ +// READ +// ============================================================ + +public class GetAllServiceCategoriesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/", GetAllAsync) + .WithName("GetAllServiceCategories") + .WithSummary("Listar todas as categorias") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetAllAsync( + [AsParameters] GetAllCategoriesQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var qry = new GetAllServiceCategoriesQuery(query.ActiveOnly); + var result = await queryDispatcher.QueryAsync>>( + qry, cancellationToken); + + return Handle(result); + } +} + +public record GetAllCategoriesQuery(bool ActiveOnly = false); + +public class GetServiceCategoryByIdEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/{id:guid}", GetByIdAsync) + .WithName("GetServiceCategoryById") + .WithSummary("Buscar categoria por ID") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + private static async Task GetByIdAsync( + Guid id, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var query = new GetServiceCategoryByIdQuery(id); + var result = await queryDispatcher.QueryAsync>( + query, cancellationToken); + + if (result.IsSuccess && result.Value == null) + return Results.NotFound(); + + return Handle(result); + } +} + +// ============================================================ +// UPDATE +// ============================================================ + +public class UpdateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPut("/{id:guid}", UpdateAsync) + .WithName("UpdateServiceCategory") + .WithSummary("Atualizar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task UpdateAsync( + Guid id, + [FromBody] UpdateServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new UpdateServiceCategoryCommand(id, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +// ============================================================ +// DELETE +// ============================================================ + +public class DeleteServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapDelete("/{id:guid}", DeleteAsync) + .WithName("DeleteServiceCategory") + .WithSummary("Deletar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeleteAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeleteServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +// ============================================================ +// ACTIVATE / DEACTIVATE +// ============================================================ + +public class ActivateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/activate", ActivateAsync) + .WithName("ActivateServiceCategory") + .WithSummary("Ativar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ActivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ActivateServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +public class DeactivateServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/deactivate", DeactivateAsync) + .WithName("DeactivateServiceCategory") + .WithSummary("Desativar categoria de serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeactivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeactivateServiceCategoryCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} diff --git a/src/Modules/Catalogs/API/Endpoints/ServiceEndpoints.cs b/src/Modules/Catalogs/API/Endpoints/ServiceEndpoints.cs new file mode 100644 index 000000000..e3e7ebcbf --- /dev/null +++ b/src/Modules/Catalogs/API/Endpoints/ServiceEndpoints.cs @@ -0,0 +1,261 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.Service; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Application.DTOs.Requests; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Shared.Authorization; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Contracts; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Catalogs.API.Endpoints; + +// ============================================================ +// CREATE +// ============================================================ + +public class CreateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/", CreateAsync) + .WithName("CreateService") + .WithSummary("Criar serviço") + .Produces>(StatusCodes.Status201Created) + .RequireAdmin(); + + private static async Task CreateAsync( + [FromBody] CreateServiceRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new CreateServiceCommand(request.CategoryId, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync>(command, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + return Handle(result, "GetServiceById", new { id = result.Value!.Id }); + } +} + +// ============================================================ +// READ +// ============================================================ + +public class GetAllServicesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/", GetAllAsync) + .WithName("GetAllServices") + .WithSummary("Listar todos os serviços") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetAllAsync( + [AsParameters] GetAllServicesQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var result = await queryDispatcher.QueryAsync>>( + query, cancellationToken); + return Handle(result); + } +} + +public class GetServiceByIdEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/{id:guid}", GetByIdAsync) + .WithName("GetServiceById") + .WithSummary("Buscar serviço por ID") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + private static async Task GetByIdAsync( + Guid id, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var query = new GetServiceByIdQuery(id); + var result = await queryDispatcher.QueryAsync>(query, cancellationToken); + + if (result.IsSuccess && result.Value is null) + { + return Results.NotFound(); + } + + return Handle(result); + } +} + +public class GetServicesByCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("/category/{categoryId:guid}", GetByCategoryAsync) + .WithName("GetServicesByCategory") + .WithSummary("Listar serviços por categoria") + .Produces>>(StatusCodes.Status200OK); + + private static async Task GetByCategoryAsync( + Guid categoryId, + [AsParameters] GetServicesByCategoryQuery query, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var queryWithCategory = query with { CategoryId = categoryId }; + var result = await queryDispatcher.QueryAsync>>(queryWithCategory, cancellationToken); + return Handle(result); + } +} + +// ============================================================ +// UPDATE +// ============================================================ + +public class UpdateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPut("/{id:guid}", UpdateAsync) + .WithName("UpdateService") + .WithSummary("Atualizar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task UpdateAsync( + Guid id, + [FromBody] UpdateServiceRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new UpdateServiceCommand(id, request.Name, request.Description, request.DisplayOrder); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +public class ChangeServiceCategoryEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/change-category", ChangeAsync) + .WithName("ChangeServiceCategory") + .WithSummary("Alterar categoria do serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ChangeAsync( + Guid id, + [FromBody] ChangeServiceCategoryRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ChangeServiceCategoryCommand(id, request.NewCategoryId); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +// ============================================================ +// DELETE +// ============================================================ + +public class DeleteServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapDelete("/{id:guid}", DeleteAsync) + .WithName("DeleteService") + .WithSummary("Deletar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeleteAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeleteServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +// ============================================================ +// ACTIVATE / DEACTIVATE +// ============================================================ + +public class ActivateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/activate", ActivateAsync) + .WithName("ActivateService") + .WithSummary("Ativar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task ActivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new ActivateServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +public class DeactivateServiceEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/deactivate", DeactivateAsync) + .WithName("DeactivateService") + .WithSummary("Desativar serviço") + .Produces(StatusCodes.Status204NoContent) + .RequireAdmin(); + + private static async Task DeactivateAsync( + Guid id, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var command = new DeactivateServiceCommand(id); + var result = await commandDispatcher.SendAsync(command, cancellationToken); + return HandleNoContent(result); + } +} + +// ============================================================ +// VALIDATE +// ============================================================ + +public class ValidateServicesEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/validate", ValidateAsync) + .WithName("ValidateServices") + .WithSummary("Validar múltiplos serviços") + .Produces>(StatusCodes.Status200OK) + .AllowAnonymous(); + + private static async Task ValidateAsync( + [FromBody] ValidateServicesRequest request, + [FromServices] ICatalogsModuleApi moduleApi, + CancellationToken cancellationToken) + { + var result = await moduleApi.ValidateServicesAsync(request.ServiceIds, cancellationToken); + + if (!result.IsSuccess) + return Handle(result); + + var response = new ValidateServicesResponse( + result.Value!.AllValid, + result.Value.InvalidServiceIds, + result.Value.InactiveServiceIds + ); + + return Handle(Result.Success(response)); + } +} diff --git a/src/Modules/Catalogs/API/Extensions.cs b/src/Modules/Catalogs/API/Extensions.cs new file mode 100644 index 000000000..3f5fc0411 --- /dev/null +++ b/src/Modules/Catalogs/API/Extensions.cs @@ -0,0 +1,87 @@ +using MeAjudaAi.Modules.Catalogs.API.Endpoints; +using MeAjudaAi.Modules.Catalogs.Application; +using MeAjudaAi.Modules.Catalogs.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Catalogs.API; + +public static class Extensions +{ + /// + /// Adiciona os serviços do módulo Catalogs. + /// + public static IServiceCollection AddCatalogsModule( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddApplication(); + services.AddCatalogsInfrastructure(configuration); + + return services; + } + + /// + /// Configura os endpoints do módulo Catalogs. + /// + public static WebApplication UseCatalogsModule(this WebApplication app) + { + // Garantir que as migrações estão aplicadas + EnsureDatabaseMigrations(app); + + app.MapCatalogsEndpoints(); + + return app; + } + + private static void EnsureDatabaseMigrations(WebApplication app) + { + if (app?.Services == null) return; + + try + { + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetService(); + if (context == null) return; + + // Em ambiente de teste, pular migrações automáticas + if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) + { + return; + } + + context.Database.Migrate(); + } + catch (Exception ex) + { + using var scope = app.Services.CreateScope(); + var logger = scope.ServiceProvider.GetService>(); + + // Only fallback to EnsureCreated in Development + if (app.Environment.IsDevelopment()) + { + logger?.LogWarning(ex, "Falha ao aplicar migrações do módulo Catalogs. Usando EnsureCreated como fallback em Development."); + try + { + var context = scope.ServiceProvider.GetService(); + context?.Database.EnsureCreated(); + } + catch (Exception fallbackEx) + { + logger?.LogError(fallbackEx, "Falha crítica ao inicializar o banco do módulo Catalogs."); + throw; // Fail fast even in Development if EnsureCreated fails + } + } + else + { + // Fail fast in non-development environments + logger?.LogError(ex, "Falha crítica ao aplicar migrações do módulo Catalogs em ambiente de produção."); + throw; + } + } + } +} diff --git a/src/Modules/Catalogs/API/MeAjudaAi.Modules.Catalogs.API.csproj b/src/Modules/Catalogs/API/MeAjudaAi.Modules.Catalogs.API.csproj new file mode 100644 index 000000000..1c7eb0bcd --- /dev/null +++ b/src/Modules/Catalogs/API/MeAjudaAi.Modules.Catalogs.API.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + true + + NU1701;NU1507 + true + true + latest + + + + + + + + + + + + + + + + diff --git a/src/Modules/Catalogs/Application/Commands/Service/ActivateServiceCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/ActivateServiceCommand.cs new file mode 100644 index 000000000..658df556c --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/ActivateServiceCommand.cs @@ -0,0 +1,9 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to activate a service, making it available for use. +/// +public sealed record ActivateServiceCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs new file mode 100644 index 000000000..de1e93b89 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/ChangeServiceCategoryCommand.cs @@ -0,0 +1,12 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to move a service to a different category. +/// +public sealed record ChangeServiceCategoryCommand( + Guid ServiceId, + Guid NewCategoryId +) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/Service/CreateServiceCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/CreateServiceCommand.cs new file mode 100644 index 000000000..14386b1e5 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/CreateServiceCommand.cs @@ -0,0 +1,15 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to create a new service in a specific category. +/// +public sealed record CreateServiceCommand( + Guid CategoryId, + string Name, + string? Description, + int DisplayOrder = 0 +) : Command>; diff --git a/src/Modules/Catalogs/Application/Commands/Service/DeactivateServiceCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/DeactivateServiceCommand.cs new file mode 100644 index 000000000..1e2466a7f --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/DeactivateServiceCommand.cs @@ -0,0 +1,9 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to deactivate a service, removing it from active use. +/// +public sealed record DeactivateServiceCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/Service/DeleteServiceCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/DeleteServiceCommand.cs new file mode 100644 index 000000000..5f372b364 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/DeleteServiceCommand.cs @@ -0,0 +1,10 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to delete a service from the catalog. +/// Note: Currently does not check for provider references (see handler TODO). +/// +public sealed record DeleteServiceCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/Service/UpdateServiceCommand.cs b/src/Modules/Catalogs/Application/Commands/Service/UpdateServiceCommand.cs new file mode 100644 index 000000000..b3bf2eea7 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/Service/UpdateServiceCommand.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.Service; + +/// +/// Command to update an existing service's details. +/// Validation limits must match ValidationConstants.CatalogLimits. +/// Note: Guid.Empty validation is handled by the command handler to provide domain-specific error messages. +/// +public sealed record UpdateServiceCommand( + Guid Id, + [Required] + [MaxLength(150)] + string Name, + [MaxLength(1000)] + string? Description, + [Range(0, int.MaxValue)] + int DisplayOrder +) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs new file mode 100644 index 000000000..ab00c99e1 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/ServiceCategory/ActivateServiceCategoryCommand.cs @@ -0,0 +1,6 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; + +public sealed record ActivateServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs new file mode 100644 index 000000000..b66b77f47 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/ServiceCategory/CreateServiceCategoryCommand.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; + +public sealed record CreateServiceCategoryCommand( + string Name, + string? Description, + int DisplayOrder = 0 +) : Command>; diff --git a/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs new file mode 100644 index 000000000..58fd6d3f9 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeactivateServiceCategoryCommand.cs @@ -0,0 +1,6 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; + +public sealed record DeactivateServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs new file mode 100644 index 000000000..916fc20fa --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/ServiceCategory/DeleteServiceCategoryCommand.cs @@ -0,0 +1,6 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; + +public sealed record DeleteServiceCategoryCommand(Guid Id) : Command; diff --git a/src/Modules/Catalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs b/src/Modules/Catalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs new file mode 100644 index 000000000..71f7873e6 --- /dev/null +++ b/src/Modules/Catalogs/Application/Commands/ServiceCategory/UpdateServiceCategoryCommand.cs @@ -0,0 +1,11 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; + +public sealed record UpdateServiceCategoryCommand( + Guid Id, + string Name, + string? Description, + int DisplayOrder = 0 +) : Command; diff --git a/src/Modules/Catalogs/Application/DTOs/Requests.cs b/src/Modules/Catalogs/Application/DTOs/Requests.cs new file mode 100644 index 000000000..9dcffdaaf --- /dev/null +++ b/src/Modules/Catalogs/Application/DTOs/Requests.cs @@ -0,0 +1,53 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.Catalogs.Application.DTOs.Requests; + +// ============================================================================ +// SERVICE CATEGORY REQUESTS +// ============================================================================ + +public sealed record UpdateServiceCategoryRequest : Request +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public int DisplayOrder { get; init; } +} + +// ============================================================================ +// SERVICE REQUESTS +// ============================================================================ + +public sealed record CreateServiceRequest : Request +{ + public Guid CategoryId { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public int DisplayOrder { get; init; } = 0; +} + +public sealed record UpdateServiceRequest : Request +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public int DisplayOrder { get; init; } +} + +public sealed record ChangeServiceCategoryRequest : Request +{ + public Guid NewCategoryId { get; init; } +} + +public sealed record ValidateServicesRequest : Request +{ + public IReadOnlyCollection ServiceIds { get; init; } = Array.Empty(); +} + +// ============================================================================ +// RESPONSES +// ============================================================================ + +public sealed record ValidateServicesResponse( + bool AllValid, + IReadOnlyCollection InvalidServiceIds, + IReadOnlyCollection InactiveServiceIds +); diff --git a/src/Modules/Catalogs/Application/DTOs/ServiceCategoryDto.cs b/src/Modules/Catalogs/Application/DTOs/ServiceCategoryDto.cs new file mode 100644 index 000000000..c97ff411e --- /dev/null +++ b/src/Modules/Catalogs/Application/DTOs/ServiceCategoryDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Modules.Catalogs.Application.DTOs; + +/// +/// DTO for service category information. +/// +public sealed record ServiceCategoryDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/src/Modules/Catalogs/Application/DTOs/ServiceCategoryWithCountDto.cs b/src/Modules/Catalogs/Application/DTOs/ServiceCategoryWithCountDto.cs new file mode 100644 index 000000000..d870ec5ca --- /dev/null +++ b/src/Modules/Catalogs/Application/DTOs/ServiceCategoryWithCountDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Modules.Catalogs.Application.DTOs; + +/// +/// DTO for category with its services count. +/// +public sealed record ServiceCategoryWithCountDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + int ActiveServicesCount, + int TotalServicesCount +); diff --git a/src/Modules/Catalogs/Application/DTOs/ServiceDto.cs b/src/Modules/Catalogs/Application/DTOs/ServiceDto.cs new file mode 100644 index 000000000..2be2450ba --- /dev/null +++ b/src/Modules/Catalogs/Application/DTOs/ServiceDto.cs @@ -0,0 +1,16 @@ +namespace MeAjudaAi.Modules.Catalogs.Application.DTOs; + +/// +/// DTO for service information. +/// +public sealed record ServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + bool IsActive, + int DisplayOrder, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/src/Modules/Catalogs/Application/DTOs/ServiceListDto.cs b/src/Modules/Catalogs/Application/DTOs/ServiceListDto.cs new file mode 100644 index 000000000..82598b4eb --- /dev/null +++ b/src/Modules/Catalogs/Application/DTOs/ServiceListDto.cs @@ -0,0 +1,12 @@ +namespace MeAjudaAi.Modules.Catalogs.Application.DTOs; + +/// +/// Simplified DTO for service without category details (for lists). +/// +public sealed record ServiceListDto( + Guid Id, + Guid CategoryId, + string Name, + string? Description, + bool IsActive +); diff --git a/src/Modules/Catalogs/Application/Extensions.cs b/src/Modules/Catalogs/Application/Extensions.cs new file mode 100644 index 000000000..5bded0b4d --- /dev/null +++ b/src/Modules/Catalogs/Application/Extensions.cs @@ -0,0 +1,20 @@ +using MeAjudaAi.Modules.Catalogs.Application.ModuleApi; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Modules.Catalogs.Application; + +public static class Extensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + // Note: Handlers are automatically registered through reflection in Infrastructure layer + // via AddApplicationHandlers() which scans the Application assembly + + // Module API - register both interface and concrete type for DI flexibility + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Modules/Catalogs/Application/Handlers/Commands/CommandHandlers.cs b/src/Modules/Catalogs/Application/Handlers/Commands/CommandHandlers.cs new file mode 100644 index 000000000..1092fc40f --- /dev/null +++ b/src/Modules/Catalogs/Application/Handlers/Commands/CommandHandlers.cs @@ -0,0 +1,372 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.Service; +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Exceptions; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; + +// ============================================================================ +// SERVICE CATEGORY COMMAND HANDLERS +// ============================================================================ + +public sealed class CreateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler> +{ + public async Task> HandleAsync(CreateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + // Check for duplicate name + if (await categoryRepository.ExistsWithNameAsync(request.Name, null, cancellationToken)) + return Result.Failure($"A category with name '{request.Name}' already exists."); + + var category = ServiceCategory.Create(request.Name, request.Description, request.DisplayOrder); + + await categoryRepository.AddAsync(category, cancellationToken); + + var dto = new ServiceCategoryDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + category.CreatedAt, + category.UpdatedAt + ); + + return Result.Success(dto); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} +public sealed class UpdateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(UpdateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + // Check for duplicate name (excluding current category) + if (await categoryRepository.ExistsWithNameAsync(request.Name, categoryId, cancellationToken)) + return Result.Failure($"A category with name '{request.Name}' already exists."); + + category.Update(request.Name, request.Description, request.DisplayOrder); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} + +public sealed class DeleteServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeleteServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + // Check if category has services + var serviceCount = await serviceRepository.CountByCategoryAsync(categoryId, activeOnly: false, cancellationToken); + if (serviceCount > 0) + return Result.Failure($"Cannot delete category with {serviceCount} service(s). Remove or reassign services first."); + + await categoryRepository.DeleteAsync(categoryId, cancellationToken); + + return Result.Success(); + } +} + +public sealed class ActivateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(ActivateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + category.Activate(); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } +} + +public sealed class DeactivateServiceCategoryCommandHandler( + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeactivateServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.Id); + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Failure($"Category with ID '{request.Id}' not found."); + + category.Deactivate(); + + await categoryRepository.UpdateAsync(category, cancellationToken); + + return Result.Success(); + } +} + +// ============================================================================ +// SERVICE COMMAND HANDLERS +// ============================================================================ + +public sealed class CreateServiceCommandHandler( + IServiceRepository serviceRepository, + IServiceCategoryRepository categoryRepository) + : ICommandHandler> +{ + public async Task> HandleAsync(CreateServiceCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.CategoryId == Guid.Empty) + return Result.Failure("Category ID cannot be empty."); + + var categoryId = ServiceCategoryId.From(request.CategoryId); + + // Verify category exists and is active + var category = await categoryRepository.GetByIdAsync(categoryId, cancellationToken); + if (category is null) + return Result.Failure($"Category with ID '{request.CategoryId}' not found."); + + if (!category.IsActive) + return Result.Failure("Cannot create service in inactive category."); + + // Check for duplicate name within the same category + if (await serviceRepository.ExistsWithNameAsync(request.Name, null, categoryId, cancellationToken)) + return Result.Failure($"A service with name '{request.Name}' already exists in this category."); + + var service = Service.Create(categoryId, request.Name, request.Description, request.DisplayOrder); + + await serviceRepository.AddAsync(service, cancellationToken); + + var dto = new ServiceDto( + service.Id.Value, + service.CategoryId.Value, + category.Name, + service.Name, + service.Description, + service.IsActive, + service.DisplayOrder, + service.CreatedAt, + service.UpdatedAt + ); + return Result.Success(dto); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} + +public sealed class UpdateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(UpdateServiceCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + // Check for duplicate name within the same category (excluding current service) + if (await serviceRepository.ExistsWithNameAsync(request.Name, serviceId, service.CategoryId, cancellationToken)) + return Result.Failure($"A service with name '{request.Name}' already exists in this category."); + + service.Update(request.Name, request.Description, request.DisplayOrder); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} + +public sealed class DeleteServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeleteServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + // TODO: Check if any provider offers this service before deleting + // This requires integration with Providers module via IProvidersModuleApi + // Consider implementing: + // 1. Call IProvidersModuleApi.HasProvidersOfferingServiceAsync(serviceId) + // 2. Return failure if providers exist: "Cannot delete service: X providers offer this service" + // 3. Or implement soft-delete pattern to preserve historical data + + await serviceRepository.DeleteAsync(serviceId, cancellationToken); + + return Result.Success(); + } +} + +public sealed class ActivateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(ActivateServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + service.Activate(); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } +} + +public sealed class DeactivateServiceCommandHandler( + IServiceRepository serviceRepository) + : ICommandHandler +{ + public async Task HandleAsync(DeactivateServiceCommand request, CancellationToken cancellationToken = default) + { + if (request.Id == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + var serviceId = ServiceId.From(request.Id); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.Id}' not found."); + + service.Deactivate(); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } +} + +public sealed class ChangeServiceCategoryCommandHandler( + IServiceRepository serviceRepository, + IServiceCategoryRepository categoryRepository) + : ICommandHandler +{ + public async Task HandleAsync(ChangeServiceCategoryCommand request, CancellationToken cancellationToken = default) + { + try + { + if (request.ServiceId == Guid.Empty) + return Result.Failure("Service ID cannot be empty."); + + if (request.NewCategoryId == Guid.Empty) + return Result.Failure("New category ID cannot be empty."); + + var serviceId = ServiceId.From(request.ServiceId); + var service = await serviceRepository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Failure($"Service with ID '{request.ServiceId}' not found."); + + var newCategoryId = ServiceCategoryId.From(request.NewCategoryId); + var newCategory = await categoryRepository.GetByIdAsync(newCategoryId, cancellationToken); + + if (newCategory is null) + return Result.Failure($"Category with ID '{request.NewCategoryId}' not found."); + + if (!newCategory.IsActive) + return Result.Failure("Cannot move service to inactive category."); + + // Ensure the name is still unique in the target category + if (await serviceRepository.ExistsWithNameAsync( + service.Name, + service.Id, + newCategoryId, + cancellationToken)) + { + return Result.Failure( + $"A service with name '{service.Name}' already exists in the target category."); + } + + service.ChangeCategory(newCategoryId); + + await serviceRepository.UpdateAsync(service, cancellationToken); + + return Result.Success(); + } + catch (CatalogDomainException ex) + { + return Result.Failure(ex.Message); + } + } +} diff --git a/src/Modules/Catalogs/Application/Handlers/Queries/QueryHandlers.cs b/src/Modules/Catalogs/Application/Handlers/Queries/QueryHandlers.cs new file mode 100644 index 000000000..50fa1b34e --- /dev/null +++ b/src/Modules/Catalogs/Application/Handlers/Queries/QueryHandlers.cs @@ -0,0 +1,185 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; + +// ============================================================================ +// SERVICE QUERY HANDLERS +// ============================================================================ + +public sealed class GetServiceByIdQueryHandler(IServiceRepository repository) + : IQueryHandler> +{ + public async Task> HandleAsync( + GetServiceByIdQuery request, + CancellationToken cancellationToken = default) + { + var serviceId = ServiceId.From(request.Id); + var service = await repository.GetByIdAsync(serviceId, cancellationToken); + + if (service is null) + return Result.Success(null); + + // Note: Category navigation property should be loaded by repository + var categoryName = service.Category?.Name ?? "Unknown"; + + var dto = new ServiceDto( + service.Id.Value, + service.CategoryId.Value, + categoryName, + service.Name, + service.Description, + service.IsActive, + service.DisplayOrder, + service.CreatedAt, + service.UpdatedAt + ); + + return Result.Success(dto); + } +} + +public sealed class GetAllServicesQueryHandler(IServiceRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetAllServicesQuery request, + CancellationToken cancellationToken = default) + { + var services = await repository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = services.Select(s => new ServiceListDto( + s.Id.Value, + s.CategoryId.Value, + s.Name, + s.Description, + s.IsActive + )).ToList(); + + return Result>.Success(dtos); + } +} + +public sealed class GetServicesByCategoryQueryHandler(IServiceRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetServicesByCategoryQuery request, + CancellationToken cancellationToken = default) + { + var categoryId = ServiceCategoryId.From(request.CategoryId); + var services = await repository.GetByCategoryAsync(categoryId, request.ActiveOnly, cancellationToken); + + var dtos = services.Select(s => new ServiceListDto( + s.Id.Value, + s.CategoryId.Value, + s.Name, + s.Description, + s.IsActive + )).ToList(); + + return Result>.Success(dtos); + } +} + +// ============================================================================ +// SERVICE CATEGORY QUERY HANDLERS +// ============================================================================ + +public sealed class GetServiceCategoryByIdQueryHandler(IServiceCategoryRepository repository) + : IQueryHandler> +{ + public async Task> HandleAsync( + GetServiceCategoryByIdQuery request, + CancellationToken cancellationToken = default) + { + var categoryId = ServiceCategoryId.From(request.Id); + var category = await repository.GetByIdAsync(categoryId, cancellationToken); + + if (category is null) + return Result.Success(null); + + var dto = new ServiceCategoryDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + category.CreatedAt, + category.UpdatedAt + ); + + return Result.Success(dto); + } +} + +public sealed class GetAllServiceCategoriesQueryHandler(IServiceCategoryRepository repository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetAllServiceCategoriesQuery request, + CancellationToken cancellationToken = default) + { + var categories = await repository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = categories.Select(c => new ServiceCategoryDto( + c.Id.Value, + c.Name, + c.Description, + c.IsActive, + c.DisplayOrder, + c.CreatedAt, + c.UpdatedAt + )).ToList(); + + return Result>.Success(dtos); + } +} + +public sealed class GetServiceCategoriesWithCountQueryHandler( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository) + : IQueryHandler>> +{ + public async Task>> HandleAsync( + GetServiceCategoriesWithCountQuery request, + CancellationToken cancellationToken = default) + { + var categories = await categoryRepository.GetAllAsync(request.ActiveOnly, cancellationToken); + + var dtos = new List(); + + // NOTE: This performs 2 * N count queries (one for total, one for active per category). + // For small-to-medium catalogs this is acceptable. If this becomes a performance bottleneck + // with many categories, consider optimizing with a batched query or grouping in the repository. + foreach (var category in categories) + { + var totalCount = await serviceRepository.CountByCategoryAsync( + category.Id, + activeOnly: false, + cancellationToken); + + var activeCount = await serviceRepository.CountByCategoryAsync( + category.Id, + activeOnly: true, + cancellationToken); + + dtos.Add(new ServiceCategoryWithCountDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder, + activeCount, + totalCount + )); + } + + return Result>.Success(dtos); + } +} diff --git a/src/Modules/Catalogs/Application/MeAjudaAi.Modules.Catalogs.Application.csproj b/src/Modules/Catalogs/Application/MeAjudaAi.Modules.Catalogs.Application.csproj new file mode 100644 index 000000000..334c61810 --- /dev/null +++ b/src/Modules/Catalogs/Application/MeAjudaAi.Modules.Catalogs.Application.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Catalogs.Tests + + + + + + + + + diff --git a/src/Modules/Catalogs/Application/ModuleApi/CatalogsModuleApi.cs b/src/Modules/Catalogs/Application/ModuleApi/CatalogsModuleApi.cs new file mode 100644 index 000000000..f6d51dc17 --- /dev/null +++ b/src/Modules/Catalogs/Application/ModuleApi/CatalogsModuleApi.cs @@ -0,0 +1,305 @@ +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Contracts.Modules; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Catalogs.Application.ModuleApi; + +/// +/// Implementation of the public API for the Catalogs module. +/// +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] +public sealed class CatalogsModuleApi( + IServiceCategoryRepository categoryRepository, + IServiceRepository serviceRepository, + ILogger logger) : ICatalogsModuleApi +{ + private static class ModuleMetadata + { + public const string Name = "Catalogs"; + public const string Version = "1.0"; + } + + private const string UnknownCategoryName = "Unknown"; + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; + + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + try + { + logger.LogDebug("Checking Catalogs module availability"); + + // Simple database connectivity test + var categories = await categoryRepository.GetAllAsync(activeOnly: true, cancellationToken); + + logger.LogDebug("Catalogs module is available and healthy"); + return true; + } + catch (OperationCanceledException) + { + logger.LogDebug("Catalogs module availability check was cancelled"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking Catalogs module availability"); + return false; + } + } + + public async Task> GetServiceCategoryByIdAsync( + Guid categoryId, + CancellationToken cancellationToken = default) + { + try + { + if (categoryId == Guid.Empty) + return Result.Failure("Category id must be provided"); + + var id = ServiceCategoryId.From(categoryId); + var category = await categoryRepository.GetByIdAsync(id, cancellationToken); + + if (category is null) + return Result.Success(null); + + var dto = new ModuleServiceCategoryDto( + category.Id.Value, + category.Name, + category.Description, + category.IsActive, + category.DisplayOrder + ); + + return Result.Success(dto); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service category {CategoryId}", categoryId); + return Result.Failure($"Error retrieving service category: {ex.Message}"); + } + } + + public async Task>> GetAllServiceCategoriesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + var categories = await categoryRepository.GetAllAsync(activeOnly, cancellationToken); + + var dtos = categories.Select(c => new ModuleServiceCategoryDto( + c.Id.Value, + c.Name, + c.Description, + c.IsActive, + c.DisplayOrder + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service categories"); + return Result>.Failure($"Error retrieving service categories: {ex.Message}"); + } + } + + public async Task> GetServiceByIdAsync( + Guid serviceId, + CancellationToken cancellationToken = default) + { + try + { + if (serviceId == Guid.Empty) + return Result.Failure("Service id must be provided"); + + var id = ServiceId.From(serviceId); + var service = await serviceRepository.GetByIdAsync(id, cancellationToken); + + if (service is null) + return Result.Success(null); + + var categoryName = service.Category?.Name ?? UnknownCategoryName; + + var dto = new ModuleServiceDto( + service.Id.Value, + service.CategoryId.Value, + categoryName, + service.Name, + service.Description, + service.IsActive + ); + + return Result.Success(dto); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving service {ServiceId}", serviceId); + return Result.Failure($"Error retrieving service: {ex.Message}"); + } + } + + public async Task>> GetAllServicesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + var services = await serviceRepository.GetAllAsync(activeOnly, cancellationToken); + + var dtos = services.Select(s => new ModuleServiceListDto( + s.Id.Value, + s.CategoryId.Value, + s.Name, + s.IsActive + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving services"); + return Result>.Failure($"Error retrieving services: {ex.Message}"); + } + } + + public async Task>> GetServicesByCategoryAsync( + Guid categoryId, + bool activeOnly = true, + CancellationToken cancellationToken = default) + { + try + { + if (categoryId == Guid.Empty) + return Result>.Failure("Category id must be provided"); + + var id = ServiceCategoryId.From(categoryId); + var services = await serviceRepository.GetByCategoryAsync(id, activeOnly, cancellationToken); + + var dtos = services.Select(s => new ModuleServiceDto( + s.Id.Value, + s.CategoryId.Value, + s.Category?.Name ?? UnknownCategoryName, + s.Name, + s.Description, + s.IsActive + )).ToList(); + + return Result>.Success(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving services for category {CategoryId}", categoryId); + return Result>.Failure($"Error retrieving services: {ex.Message}"); + } + } + + public async Task> IsServiceActiveAsync( + Guid serviceId, + CancellationToken cancellationToken = default) + { + try + { + if (serviceId == Guid.Empty) + return Result.Failure("Service id must be provided"); + + var serviceIdValue = ServiceId.From(serviceId); + var service = await serviceRepository.GetByIdAsync(serviceIdValue, cancellationToken); + + // Return false for not-found to align with query semantics (vs Failure) + if (service is null) + return Result.Success(false); + + return Result.Success(service.IsActive); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking if service {ServiceId} is active", serviceId); + return Result.Failure($"Error checking service status: {ex.Message}"); + } + } + + public async Task> ValidateServicesAsync( + IReadOnlyCollection serviceIds, + CancellationToken cancellationToken = default) + { + try + { + if (serviceIds is null) + return Result.Failure("Service IDs collection cannot be null"); + + // Short-circuit for empty collection + if (serviceIds.Count == 0) + { + return Result.Success( + new ModuleServiceValidationResultDto(true, [], [])); + } + + var invalidIds = new List(); + var inactiveIds = new List(); + + // Deduplicate input IDs and separate empty GUIDs + var distinctIds = serviceIds.Distinct().ToList(); + var validGuids = new List(); + + foreach (var id in distinctIds) + { + if (id == Guid.Empty) + { + invalidIds.Add(id); + } + else + { + validGuids.Add(id); + } + } + + // Only convert non-empty GUIDs to ServiceId value objects + if (validGuids.Count > 0) + { + var serviceIdValues = validGuids.Select(ServiceId.From).ToList(); + + // Batch query to avoid N+1 problem + var services = await serviceRepository.GetByIdsAsync(serviceIdValues, cancellationToken); + var serviceLookup = services.ToDictionary(s => s.Id.Value); + + foreach (var serviceId in validGuids) + { + if (!serviceLookup.TryGetValue(serviceId, out var service)) + { + invalidIds.Add(serviceId); + } + else if (!service.IsActive) + { + inactiveIds.Add(serviceId); + } + } + } + + var allValid = invalidIds.Count == 0 && inactiveIds.Count == 0; + + var result = new ModuleServiceValidationResultDto( + allValid, + [.. invalidIds], + [.. inactiveIds] + ); + + return Result.Success(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error validating services"); + return Result.Failure($"Error validating services: {ex.Message}"); + } + } +} diff --git a/src/Modules/Catalogs/Application/Queries/Service/GetAllServicesQuery.cs b/src/Modules/Catalogs/Application/Queries/Service/GetAllServicesQuery.cs new file mode 100644 index 000000000..36e3d4f66 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/Service/GetAllServicesQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.Service; + +public sealed record GetAllServicesQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/Catalogs/Application/Queries/Service/GetServiceByIdQuery.cs b/src/Modules/Catalogs/Application/Queries/Service/GetServiceByIdQuery.cs new file mode 100644 index 000000000..03ee75926 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/Service/GetServiceByIdQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.Service; + +public sealed record GetServiceByIdQuery(Guid Id) + : Query>; diff --git a/src/Modules/Catalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs b/src/Modules/Catalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs new file mode 100644 index 000000000..a6fa553c6 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/Service/GetServicesByCategoryQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.Service; + +public sealed record GetServicesByCategoryQuery(Guid CategoryId, bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs new file mode 100644 index 000000000..458d28418 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetAllServiceCategoriesQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; + +public sealed record GetAllServiceCategoriesQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs new file mode 100644 index 000000000..8cdbb5b69 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoriesWithCountQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; + +public sealed record GetServiceCategoriesWithCountQuery(bool ActiveOnly = false) + : Query>>; diff --git a/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs new file mode 100644 index 000000000..fd6a22170 --- /dev/null +++ b/src/Modules/Catalogs/Application/Queries/ServiceCategory/GetServiceCategoryByIdQuery.cs @@ -0,0 +1,8 @@ +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; + +namespace MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; + +public sealed record GetServiceCategoryByIdQuery(Guid Id) + : Query>; diff --git a/src/Modules/Catalogs/Domain/Entities/Service.cs b/src/Modules/Catalogs/Domain/Entities/Service.cs new file mode 100644 index 000000000..0e3e272c1 --- /dev/null +++ b/src/Modules/Catalogs/Domain/Entities/Service.cs @@ -0,0 +1,158 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Events; +using MeAjudaAi.Modules.Catalogs.Domain.Exceptions; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Constants; +using MeAjudaAi.Shared.Domain; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Entities; + +/// +/// Represents a specific service that providers can offer (e.g., "Limpeza de Apartamento", "Conserto de Torneira"). +/// Services belong to a category and can be activated/deactivated by administrators. +/// +public sealed class Service : AggregateRoot +{ + /// + /// ID of the category this service belongs to. + /// + public ServiceCategoryId CategoryId { get; private set; } = null!; + + /// + /// Name of the service. + /// + public string Name { get; private set; } = string.Empty; + + /// + /// Optional description explaining what this service includes. + /// + public string? Description { get; private set; } + + /// + /// Indicates if this service is currently active and available for providers to offer. + /// Deactivated services are hidden from the catalog. + /// + public bool IsActive { get; private set; } + + /// + /// Optional display order within the category for UI sorting. + /// + public int DisplayOrder { get; private set; } + + // Navigation property (loaded explicitly when needed) + public ServiceCategory? Category { get; private set; } + + // EF Core constructor + private Service() { } + + /// + /// Creates a new service within a category. + /// + /// ID of the parent category + /// Service name (required, 1-150 characters) + /// Optional service description (max 1000 characters) + /// Display order for sorting (default: 0) + /// Thrown when validation fails + public static Service Create(ServiceCategoryId categoryId, string name, string? description = null, int displayOrder = 0) + { + if (categoryId is null) + throw new CatalogDomainException("Category ID is required."); + + ValidateName(name); + ValidateDescription(description); + ValidateDisplayOrder(displayOrder); + + var service = new Service + { + Id = ServiceId.New(), + CategoryId = categoryId, + Name = name.Trim(), + Description = description?.Trim(), + IsActive = true, + DisplayOrder = displayOrder + }; + + service.AddDomainEvent(new ServiceCreatedDomainEvent(service.Id, categoryId)); + return service; + } + + /// + /// Updates the service information. + /// + public void Update(string name, string? description = null, int displayOrder = 0) + { + ValidateName(name); + ValidateDescription(description); + ValidateDisplayOrder(displayOrder); + + Name = name.Trim(); + Description = description?.Trim(); + DisplayOrder = displayOrder; + MarkAsUpdated(); + + AddDomainEvent(new ServiceUpdatedDomainEvent(Id)); + } + + /// + /// Changes the category of this service. + /// + public void ChangeCategory(ServiceCategoryId newCategoryId) + { + if (newCategoryId is null) + throw new CatalogDomainException("Category ID is required."); + + if (CategoryId.Value == newCategoryId.Value) + return; + + var oldCategoryId = CategoryId; + CategoryId = newCategoryId; + MarkAsUpdated(); + + AddDomainEvent(new ServiceCategoryChangedDomainEvent(Id, oldCategoryId, newCategoryId)); + } + + /// + /// Activates the service, making it available in the catalog. + /// + public void Activate() + { + if (IsActive) return; + + IsActive = true; + MarkAsUpdated(); + AddDomainEvent(new ServiceActivatedDomainEvent(Id)); + } + + /// + /// Deactivates the service, removing it from the catalog. + /// Providers who currently offer this service retain it, but new assignments are prevented. + /// + public void Deactivate() + { + if (!IsActive) return; + + IsActive = false; + MarkAsUpdated(); + AddDomainEvent(new ServiceDeactivatedDomainEvent(Id)); + } + + private static void ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new CatalogDomainException("Service name is required."); + + if (name.Trim().Length > ValidationConstants.CatalogLimits.ServiceNameMaxLength) + throw new CatalogDomainException($"Service name cannot exceed {ValidationConstants.CatalogLimits.ServiceNameMaxLength} characters."); + } + + private static void ValidateDescription(string? description) + { + if (description is not null && description.Trim().Length > ValidationConstants.CatalogLimits.ServiceDescriptionMaxLength) + throw new CatalogDomainException($"Service description cannot exceed {ValidationConstants.CatalogLimits.ServiceDescriptionMaxLength} characters."); + } + + private static void ValidateDisplayOrder(int displayOrder) + { + if (displayOrder < 0) + throw new CatalogDomainException("Display order cannot be negative."); + } +} diff --git a/src/Modules/Catalogs/Domain/Entities/ServiceCategory.cs b/src/Modules/Catalogs/Domain/Entities/ServiceCategory.cs new file mode 100644 index 000000000..98f660047 --- /dev/null +++ b/src/Modules/Catalogs/Domain/Entities/ServiceCategory.cs @@ -0,0 +1,127 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Events; +using MeAjudaAi.Modules.Catalogs.Domain.Exceptions; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Constants; +using MeAjudaAi.Shared.Domain; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Entities; + +/// +/// Represents a service category in the catalog (e.g., "Limpeza", "Reparos"). +/// Categories organize services into logical groups for easier discovery. +/// +public sealed class ServiceCategory : AggregateRoot +{ + /// + /// Name of the category. + /// + public string Name { get; private set; } = string.Empty; + + /// + /// Optional description explaining what services belong to this category. + /// + public string? Description { get; private set; } + + /// + /// Indicates if this category is currently active and available for use. + /// Deactivated categories cannot be assigned to new services. + /// + public bool IsActive { get; private set; } + + /// + /// Optional display order for UI sorting. + /// + public int DisplayOrder { get; private set; } + + // EF Core constructor + private ServiceCategory() { } + + /// + /// Creates a new service category. + /// + /// Category name (required, 1-100 characters) + /// Optional category description (max 500 characters) + /// Display order for sorting (default: 0) + /// Thrown when validation fails + public static ServiceCategory Create(string name, string? description = null, int displayOrder = 0) + { + ValidateName(name); + ValidateDescription(description); + ValidateDisplayOrder(displayOrder); + + var category = new ServiceCategory + { + Id = ServiceCategoryId.New(), + Name = name.Trim(), + Description = description?.Trim(), + IsActive = true, + DisplayOrder = displayOrder + }; + + category.AddDomainEvent(new ServiceCategoryCreatedDomainEvent(category.Id)); + return category; + } + + /// + /// Updates the category information. + /// + public void Update(string name, string? description = null, int displayOrder = 0) + { + ValidateName(name); + ValidateDescription(description); + ValidateDisplayOrder(displayOrder); + + Name = name.Trim(); + Description = description?.Trim(); + DisplayOrder = displayOrder; + MarkAsUpdated(); + + AddDomainEvent(new ServiceCategoryUpdatedDomainEvent(Id)); + } + + /// + /// Activates the category, making it available for use. + /// + public void Activate() + { + if (IsActive) return; + + IsActive = true; + MarkAsUpdated(); + AddDomainEvent(new ServiceCategoryActivatedDomainEvent(Id)); + } + + /// + /// Deactivates the category, preventing it from being assigned to new services. + /// Existing services retain their category assignment. + /// + public void Deactivate() + { + if (!IsActive) return; + + IsActive = false; + MarkAsUpdated(); + AddDomainEvent(new ServiceCategoryDeactivatedDomainEvent(Id)); + } + + private static void ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new CatalogDomainException("Category name is required."); + + if (name.Trim().Length > ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength) + throw new CatalogDomainException($"Category name cannot exceed {ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength} characters."); + } + + private static void ValidateDescription(string? description) + { + if (description is not null && description.Trim().Length > ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength) + throw new CatalogDomainException($"Category description cannot exceed {ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength} characters."); + } + + private static void ValidateDisplayOrder(int displayOrder) + { + if (displayOrder < 0) + throw new CatalogDomainException("Display order cannot be negative."); + } +} diff --git a/src/Modules/Catalogs/Domain/Events/ServiceCategoryDomainEvents.cs b/src/Modules/Catalogs/Domain/Events/ServiceCategoryDomainEvents.cs new file mode 100644 index 000000000..b3b0b01af --- /dev/null +++ b/src/Modules/Catalogs/Domain/Events/ServiceCategoryDomainEvents.cs @@ -0,0 +1,16 @@ +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Events; + +public sealed record ServiceCategoryCreatedDomainEvent(ServiceCategoryId CategoryId) + : DomainEvent(CategoryId.Value, Version: 1); + +public sealed record ServiceCategoryUpdatedDomainEvent(ServiceCategoryId CategoryId) + : DomainEvent(CategoryId.Value, Version: 1); + +public sealed record ServiceCategoryActivatedDomainEvent(ServiceCategoryId CategoryId) + : DomainEvent(CategoryId.Value, Version: 1); + +public sealed record ServiceCategoryDeactivatedDomainEvent(ServiceCategoryId CategoryId) + : DomainEvent(CategoryId.Value, Version: 1); diff --git a/src/Modules/Catalogs/Domain/Events/ServiceDomainEvents.cs b/src/Modules/Catalogs/Domain/Events/ServiceDomainEvents.cs new file mode 100644 index 000000000..cd6fe5a7f --- /dev/null +++ b/src/Modules/Catalogs/Domain/Events/ServiceDomainEvents.cs @@ -0,0 +1,22 @@ +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Events; + +public sealed record ServiceCreatedDomainEvent(ServiceId ServiceId, ServiceCategoryId CategoryId) + : DomainEvent(ServiceId.Value, Version: 1); + +public sealed record ServiceUpdatedDomainEvent(ServiceId ServiceId) + : DomainEvent(ServiceId.Value, Version: 1); + +public sealed record ServiceActivatedDomainEvent(ServiceId ServiceId) + : DomainEvent(ServiceId.Value, Version: 1); + +public sealed record ServiceDeactivatedDomainEvent(ServiceId ServiceId) + : DomainEvent(ServiceId.Value, Version: 1); + +public sealed record ServiceCategoryChangedDomainEvent( + ServiceId ServiceId, + ServiceCategoryId OldCategoryId, + ServiceCategoryId NewCategoryId) + : DomainEvent(ServiceId.Value, Version: 1); diff --git a/src/Modules/Catalogs/Domain/Exceptions/CatalogDomainException.cs b/src/Modules/Catalogs/Domain/Exceptions/CatalogDomainException.cs new file mode 100644 index 000000000..98891096a --- /dev/null +++ b/src/Modules/Catalogs/Domain/Exceptions/CatalogDomainException.cs @@ -0,0 +1,12 @@ +namespace MeAjudaAi.Modules.Catalogs.Domain.Exceptions; + +/// +/// Exception thrown when a domain rule is violated in the Catalogs module. +/// +public sealed class CatalogDomainException : Exception +{ + public CatalogDomainException(string message) : base(message) { } + + public CatalogDomainException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Modules/Catalogs/Domain/MeAjudaAi.Modules.Catalogs.Domain.csproj b/src/Modules/Catalogs/Domain/MeAjudaAi.Modules.Catalogs.Domain.csproj new file mode 100644 index 000000000..246d513be --- /dev/null +++ b/src/Modules/Catalogs/Domain/MeAjudaAi.Modules.Catalogs.Domain.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Catalogs.Tests + + + + + + + + diff --git a/src/Modules/Catalogs/Domain/Repositories/IServiceCategoryRepository.cs b/src/Modules/Catalogs/Domain/Repositories/IServiceCategoryRepository.cs new file mode 100644 index 000000000..84c1a7122 --- /dev/null +++ b/src/Modules/Catalogs/Domain/Repositories/IServiceCategoryRepository.cs @@ -0,0 +1,47 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Repositories; + +/// +/// Repository contract for ServiceCategory aggregate. +/// +public interface IServiceCategoryRepository +{ + /// + /// Retrieves a service category by its ID. + /// + Task GetByIdAsync(ServiceCategoryId id, CancellationToken cancellationToken = default); + + /// + /// Retrieves a service category by its name. + /// + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Retrieves all service categories. + /// + /// If true, returns only active categories + /// + Task> GetAllAsync(bool activeOnly = false, CancellationToken cancellationToken = default); + + /// + /// Checks if a category with the given name already exists. + /// + Task ExistsWithNameAsync(string name, ServiceCategoryId? excludeId = null, CancellationToken cancellationToken = default); + + /// + /// Adds a new service category. + /// + Task AddAsync(ServiceCategory category, CancellationToken cancellationToken = default); + + /// + /// Updates an existing service category. + /// + Task UpdateAsync(ServiceCategory category, CancellationToken cancellationToken = default); + + /// + /// Deletes a service category by its ID (hard delete - use with caution). + /// + Task DeleteAsync(ServiceCategoryId id, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Catalogs/Domain/Repositories/IServiceRepository.cs b/src/Modules/Catalogs/Domain/Repositories/IServiceRepository.cs new file mode 100644 index 000000000..3ee8e34e0 --- /dev/null +++ b/src/Modules/Catalogs/Domain/Repositories/IServiceRepository.cs @@ -0,0 +1,69 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +namespace MeAjudaAi.Modules.Catalogs.Domain.Repositories; + +/// +/// Repository contract for Service aggregate. +/// +public interface IServiceRepository +{ + /// + /// Retrieves a service by its ID. + /// + Task GetByIdAsync(ServiceId id, CancellationToken cancellationToken = default); + + /// + /// Retrieves multiple services by their IDs (batch query). + /// + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); + + /// + /// Retrieves a service by its name. + /// + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Retrieves all services. + /// + /// If true, returns only active services + /// + Task> GetAllAsync(bool activeOnly = false, CancellationToken cancellationToken = default); + + /// + /// Retrieves all services in a specific category. + /// + /// ID of the category + /// If true, returns only active services + /// + Task> GetByCategoryAsync(ServiceCategoryId categoryId, bool activeOnly = false, CancellationToken cancellationToken = default); + + /// + /// Checks if a service with the given name already exists. + /// + /// The service name to check + /// Optional service ID to exclude from the check + /// Optional category ID to scope the check to a specific category + /// + Task ExistsWithNameAsync(string name, ServiceId? excludeId = null, ServiceCategoryId? categoryId = null, CancellationToken cancellationToken = default); + + /// + /// Counts how many services exist in a category. + /// + Task CountByCategoryAsync(ServiceCategoryId categoryId, bool activeOnly = false, CancellationToken cancellationToken = default); + + /// + /// Adds a new service. + /// + Task AddAsync(Service service, CancellationToken cancellationToken = default); + + /// + /// Updates an existing service. + /// + Task UpdateAsync(Service service, CancellationToken cancellationToken = default); + + /// + /// Deletes a service by its ID (hard delete - use with caution). + /// + Task DeleteAsync(ServiceId id, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Catalogs/Domain/ValueObjects/ServiceCategoryId.cs b/src/Modules/Catalogs/Domain/ValueObjects/ServiceCategoryId.cs new file mode 100644 index 000000000..4204e8cd2 --- /dev/null +++ b/src/Modules/Catalogs/Domain/ValueObjects/ServiceCategoryId.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Shared.Domain; +using MeAjudaAi.Shared.Time; + +namespace MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +/// +/// Strongly-typed identifier for ServiceCategory aggregate. +/// +public class ServiceCategoryId : ValueObject +{ + public Guid Value { get; } + + public ServiceCategoryId(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("ServiceCategoryId cannot be empty"); + Value = value; + } + + public static ServiceCategoryId New() => new(UuidGenerator.NewId()); + public static ServiceCategoryId From(Guid value) => new(value); + + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + public override string ToString() => Value.ToString(); + + public static implicit operator Guid(ServiceCategoryId id) => id.Value; + public static implicit operator ServiceCategoryId(Guid value) => new(value); +} diff --git a/src/Modules/Catalogs/Domain/ValueObjects/ServiceId.cs b/src/Modules/Catalogs/Domain/ValueObjects/ServiceId.cs new file mode 100644 index 000000000..fc1ed028d --- /dev/null +++ b/src/Modules/Catalogs/Domain/ValueObjects/ServiceId.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Shared.Domain; +using MeAjudaAi.Shared.Time; + +namespace MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +/// +/// Strongly-typed identifier for Service aggregate. +/// +public class ServiceId : ValueObject +{ + public Guid Value { get; } + + public ServiceId(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("ServiceId cannot be empty"); + Value = value; + } + + public static ServiceId New() => new(UuidGenerator.NewId()); + public static ServiceId From(Guid value) => new(value); + + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + + public override string ToString() => Value.ToString(); + + public static implicit operator Guid(ServiceId id) => id.Value; + public static implicit operator ServiceId(Guid value) => new(value); +} diff --git a/src/Modules/Catalogs/Infrastructure/Extensions.cs b/src/Modules/Catalogs/Infrastructure/Extensions.cs new file mode 100644 index 000000000..343405c74 --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Extensions.cs @@ -0,0 +1,94 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.Service; +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Repositories; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Database; +using MeAjudaAi.Shared.Functional; +using MeAjudaAi.Shared.Queries; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure; + +public static class Extensions +{ + /// + /// Adds Catalogs module infrastructure services. + /// + public static IServiceCollection AddCatalogsInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // Configure DbContext + services.AddDbContext((serviceProvider, options) => + { + var environment = serviceProvider.GetService(); + var isTestEnvironment = environment?.EnvironmentName == "Testing"; + + // Use shared connection string resolution logic (same precedence as DapperConnection) + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration.GetConnectionString("Catalogs") + ?? configuration.GetConnectionString("meajudaai-db"); + + if (string.IsNullOrEmpty(connectionString)) + { + if (isTestEnvironment) + { + // Same test fallback as DapperConnection + connectionString = "Host=localhost;Port=5432;Database=meajudaai_test;Username=postgres;Password=test;"; + } + else + { + throw new InvalidOperationException( + "PostgreSQL connection string not found. Configure connection string via Aspire, 'Postgres:ConnectionString' in appsettings.json, or as ConnectionStrings:meajudaai-db"); + } + } + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(CatalogsDbContext).Assembly.FullName); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "catalogs"); + npgsqlOptions.CommandTimeout(60); + }) + .UseSnakeCaseNamingConvention() + .EnableServiceProviderCaching() + .EnableSensitiveDataLogging(false); + }); + + // Register repositories + services.AddScoped(); + services.AddScoped(); + + // Register command handlers + services.AddScoped>, CreateServiceCategoryCommandHandler>(); + services.AddScoped>, CreateServiceCommandHandler>(); + services.AddScoped, UpdateServiceCategoryCommandHandler>(); + services.AddScoped, UpdateServiceCommandHandler>(); + services.AddScoped, DeleteServiceCategoryCommandHandler>(); + services.AddScoped, DeleteServiceCommandHandler>(); + services.AddScoped, ActivateServiceCategoryCommandHandler>(); + services.AddScoped, ActivateServiceCommandHandler>(); + services.AddScoped, DeactivateServiceCategoryCommandHandler>(); + services.AddScoped, DeactivateServiceCommandHandler>(); + services.AddScoped, ChangeServiceCategoryCommandHandler>(); + + // Register query handlers + services.AddScoped>>, GetAllServiceCategoriesQueryHandler>(); + services.AddScoped>, GetServiceCategoryByIdQueryHandler>(); + services.AddScoped>>, GetServiceCategoriesWithCountQueryHandler>(); + services.AddScoped>>, GetAllServicesQueryHandler>(); + services.AddScoped>, GetServiceByIdQueryHandler>(); + services.AddScoped>>, GetServicesByCategoryQueryHandler>(); + + return services; + } +} diff --git a/src/Modules/Catalogs/Infrastructure/MeAjudaAi.Modules.Catalogs.Infrastructure.csproj b/src/Modules/Catalogs/Infrastructure/MeAjudaAi.Modules.Catalogs.Infrastructure.csproj new file mode 100644 index 000000000..fec22b8f5 --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/MeAjudaAi.Modules.Catalogs.Infrastructure.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Catalogs.Tests + + + + + + + build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContext.cs b/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContext.cs new file mode 100644 index 000000000..d3395049d --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContext.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; + +/// +/// Entity Framework context for the Catalogs module. +/// +public class CatalogsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet ServiceCategories { get; set; } = null!; + public DbSet Services { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("catalogs"); + + // Apply configurations from assembly + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContextFactory.cs b/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContextFactory.cs new file mode 100644 index 000000000..23ae908d6 --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/CatalogsDbContextFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; + +/// +/// Design-time factory for creating CatalogsDbContext during EF Core migrations. +/// This allows migrations to be created without running the full application. +/// +public sealed class CatalogsDbContextFactory : IDesignTimeDbContextFactory +{ + public CatalogsDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use default development connection string for design-time operations + // This is only used for migrations generation, not runtime + optionsBuilder.UseNpgsql( + "Host=localhost;Port=5432;Database=meajudaai;Username=postgres;Password=development123", + npgsqlOptions => + { + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "catalogs"); + npgsqlOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + }); + + return new CatalogsDbContext(optionsBuilder.Options); + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceCategoryConfiguration.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceCategoryConfiguration.cs new file mode 100644 index 000000000..59a5d8a02 --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceCategoryConfiguration.cs @@ -0,0 +1,60 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Configurations; + +internal sealed class ServiceCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("service_categories"); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.Id) + .HasConversion( + id => id.Value, + value => ServiceCategoryId.From(value)) + .HasColumnName("id"); + + builder.Property(c => c.Name) + .IsRequired() + .HasMaxLength(100) + .HasColumnName("name"); + + builder.Property(c => c.Description) + .HasMaxLength(500) + .HasColumnName("description"); + + builder.Property(c => c.IsActive) + .IsRequired() + .HasColumnName("is_active"); + + builder.Property(c => c.DisplayOrder) + .IsRequired() + .HasColumnName("display_order"); + + builder.Property(c => c.CreatedAt) + .IsRequired() + .HasColumnName("created_at"); + + builder.Property(c => c.UpdatedAt) + .HasColumnName("updated_at"); + + // Indexes + builder.HasIndex(c => c.Name) + .IsUnique() + .HasDatabaseName("ix_service_categories_name"); + + builder.HasIndex(c => c.IsActive) + .HasDatabaseName("ix_service_categories_is_active"); + + builder.HasIndex(c => c.DisplayOrder) + .HasDatabaseName("ix_service_categories_display_order"); + + // Ignore navigation properties + builder.Ignore(c => c.DomainEvents); + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceConfiguration.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceConfiguration.cs new file mode 100644 index 000000000..76c0560ef --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Configurations/ServiceConfiguration.cs @@ -0,0 +1,77 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Configurations; + +internal sealed class ServiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("services"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasConversion( + id => id.Value, + value => ServiceId.From(value)) + .HasColumnName("id"); + + builder.Property(s => s.CategoryId) + .HasConversion( + id => id.Value, + value => ServiceCategoryId.From(value)) + .IsRequired() + .HasColumnName("category_id"); + + builder.Property(s => s.Name) + .IsRequired() + .HasMaxLength(150) + .HasColumnName("name"); + + builder.Property(s => s.Description) + .HasMaxLength(1000) + .HasColumnName("description"); + + builder.Property(s => s.IsActive) + .IsRequired() + .HasColumnName("is_active"); + + builder.Property(s => s.DisplayOrder) + .IsRequired() + .HasColumnName("display_order"); + + builder.Property(s => s.CreatedAt) + .IsRequired() + .HasColumnName("created_at"); + + builder.Property(s => s.UpdatedAt) + .HasColumnName("updated_at"); + + // Relationships + builder.HasOne(s => s.Category) + .WithMany() + .HasForeignKey(s => s.CategoryId) + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_services_category"); + + // Indexes + builder.HasIndex(s => s.Name) + .IsUnique() + .HasDatabaseName("ix_services_name"); + + builder.HasIndex(s => s.CategoryId) + .HasDatabaseName("ix_services_category_id"); + + builder.HasIndex(s => s.IsActive) + .HasDatabaseName("ix_services_is_active"); + + builder.HasIndex(s => new { s.CategoryId, s.DisplayOrder }) + .HasDatabaseName("ix_services_category_display_order"); + + // Ignore navigation properties + builder.Ignore(s => s.DomainEvents); + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.Designer.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.Designer.cs new file mode 100644 index 000000000..a907124ac --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.Designer.cs @@ -0,0 +1,146 @@ +// +using System; +using MeAjudaAi.Modules.Catalogs.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.Catalogs.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogsDbContext))] + [Migration("20251117205349_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("catalogs") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.Service", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_services_category_id"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_services_is_active"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_services_name"); + + b.HasIndex("CategoryId", "DisplayOrder") + .HasDatabaseName("ix_services_category_display_order"); + + b.ToTable("services", "catalogs"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.ServiceCategory", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("DisplayOrder") + .HasDatabaseName("ix_service_categories_display_order"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_service_categories_is_active"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_service_categories_name"); + + b.ToTable("service_categories", "catalogs"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.Service", b => + { + b.HasOne("MeAjudaAi.Modules.Catalogs.Domain.Entities.ServiceCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_services_category"); + + b.Navigation("Category"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.cs new file mode 100644 index 000000000..fd4a20c4a --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/20251117205349_InitialCreate.cs @@ -0,0 +1,118 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "catalogs"); + + migrationBuilder.CreateTable( + name: "service_categories", + schema: "catalogs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + is_active = table.Column(type: "boolean", nullable: false), + display_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_service_categories", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "services", + schema: "catalogs", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + category_id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + is_active = table.Column(type: "boolean", nullable: false), + display_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_services", x => x.id); + table.ForeignKey( + name: "fk_services_category", + column: x => x.category_id, + principalSchema: "catalogs", + principalTable: "service_categories", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_service_categories_display_order", + schema: "catalogs", + table: "service_categories", + column: "display_order"); + + migrationBuilder.CreateIndex( + name: "ix_service_categories_is_active", + schema: "catalogs", + table: "service_categories", + column: "is_active"); + + migrationBuilder.CreateIndex( + name: "ix_service_categories_name", + schema: "catalogs", + table: "service_categories", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_services_category_display_order", + schema: "catalogs", + table: "services", + columns: new[] { "category_id", "display_order" }); + + migrationBuilder.CreateIndex( + name: "ix_services_category_id", + schema: "catalogs", + table: "services", + column: "category_id"); + + migrationBuilder.CreateIndex( + name: "ix_services_is_active", + schema: "catalogs", + table: "services", + column: "is_active"); + + migrationBuilder.CreateIndex( + name: "ix_services_name", + schema: "catalogs", + table: "services", + column: "name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "services", + schema: "catalogs"); + + migrationBuilder.DropTable( + name: "service_categories", + schema: "catalogs"); + } + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/CatalogsDbContextModelSnapshot.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/CatalogsDbContextModelSnapshot.cs new file mode 100644 index 000000000..716c4192e --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Migrations/CatalogsDbContextModelSnapshot.cs @@ -0,0 +1,143 @@ +// +using System; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogsDbContext))] + partial class CatalogsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("catalogs") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.Service", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_services_category_id"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_services_is_active"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_services_name"); + + b.HasIndex("CategoryId", "DisplayOrder") + .HasDatabaseName("ix_services_category_display_order"); + + b.ToTable("services", "catalogs"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.ServiceCategory", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("DisplayOrder") + .HasColumnType("integer") + .HasColumnName("display_order"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("DisplayOrder") + .HasDatabaseName("ix_service_categories_display_order"); + + b.HasIndex("IsActive") + .HasDatabaseName("ix_service_categories_is_active"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_service_categories_name"); + + b.ToTable("service_categories", "catalogs"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Catalogs.Domain.Entities.Service", b => + { + b.HasOne("MeAjudaAi.Modules.Catalogs.Domain.Entities.ServiceCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_services_category"); + + b.Navigation("Category"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceCategoryRepository.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceCategoryRepository.cs new file mode 100644 index 000000000..4ff9cb74e --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceCategoryRepository.cs @@ -0,0 +1,73 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Repositories; + +public sealed class ServiceCategoryRepository(CatalogsDbContext context) : IServiceCategoryRepository +{ + public async Task GetByIdAsync(ServiceCategoryId id, CancellationToken cancellationToken = default) + { + return await context.ServiceCategories + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + var normalized = name?.Trim() ?? string.Empty; + return await context.ServiceCategories + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Name == normalized, cancellationToken); + } + + public async Task> GetAllAsync(bool activeOnly = false, CancellationToken cancellationToken = default) + { + var query = context.ServiceCategories.AsQueryable(); + + if (activeOnly) + query = query.Where(c => c.IsActive); + + return await query + .OrderBy(c => c.DisplayOrder) + .ThenBy(c => c.Name) + .ToListAsync(cancellationToken); + } + + public async Task ExistsWithNameAsync(string name, ServiceCategoryId? excludeId = null, CancellationToken cancellationToken = default) + { + var normalized = name?.Trim() ?? string.Empty; + var query = context.ServiceCategories.Where(c => c.Name == normalized); + + if (excludeId is not null) + query = query.Where(c => c.Id != excludeId); + + return await query.AnyAsync(cancellationToken); + } + + public async Task AddAsync(ServiceCategory category, CancellationToken cancellationToken = default) + { + await context.ServiceCategories.AddAsync(category, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(ServiceCategory category, CancellationToken cancellationToken = default) + { + context.ServiceCategories.Update(category); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(ServiceCategoryId id, CancellationToken cancellationToken = default) + { + // For delete, we need to track the entity, so don't use AsNoTracking + var category = await context.ServiceCategories + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (category is not null) + { + context.ServiceCategories.Remove(category); + await context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceRepository.cs b/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceRepository.cs new file mode 100644 index 000000000..9586964ab --- /dev/null +++ b/src/Modules/Catalogs/Infrastructure/Persistence/Repositories/ServiceRepository.cs @@ -0,0 +1,121 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Repositories; + +public sealed class ServiceRepository(CatalogsDbContext context) : IServiceRepository +{ + public async Task GetByIdAsync(ServiceId id, CancellationToken cancellationToken = default) + { + return await context.Services + .AsNoTracking() + .Include(s => s.Category) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + var idList = ids.ToList(); + return await context.Services + .AsNoTracking() + .Include(s => s.Category) + .Where(s => idList.Contains(s.Id)) + .ToListAsync(cancellationToken); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + var normalized = name?.Trim() ?? string.Empty; + + return await context.Services + .AsNoTracking() + .Include(s => s.Category) + .FirstOrDefaultAsync(s => s.Name == normalized, cancellationToken); + } + + public async Task> GetAllAsync(bool activeOnly = false, CancellationToken cancellationToken = default) + { + IQueryable query = context.Services + .AsNoTracking() + .Include(s => s.Category); + + if (activeOnly) + query = query.Where(s => s.IsActive); + + return await query + .OrderBy(s => s.Name) + .ToListAsync(cancellationToken); + } + + public async Task> GetByCategoryAsync(ServiceCategoryId categoryId, bool activeOnly = false, CancellationToken cancellationToken = default) + { + var query = context.Services + .AsNoTracking() + .Include(s => s.Category) + .Where(s => s.CategoryId == categoryId); + + if (activeOnly) + query = query.Where(s => s.IsActive); + + return await query + .OrderBy(s => s.DisplayOrder) + .ThenBy(s => s.Name) + .ToListAsync(cancellationToken); + } + + public async Task ExistsWithNameAsync(string name, ServiceId? excludeId = null, ServiceCategoryId? categoryId = null, CancellationToken cancellationToken = default) + { + var normalized = name?.Trim() ?? string.Empty; + var query = context.Services.Where(s => s.Name == normalized); + + if (excludeId is not null) + query = query.Where(s => s.Id != excludeId); + + if (categoryId is not null) + query = query.Where(s => s.CategoryId == categoryId); + + return await query.AnyAsync(cancellationToken); + } + + public async Task CountByCategoryAsync(ServiceCategoryId categoryId, bool activeOnly = false, CancellationToken cancellationToken = default) + { + var query = context.Services.Where(s => s.CategoryId == categoryId); + + if (activeOnly) + query = query.Where(s => s.IsActive); + + return await query.CountAsync(cancellationToken); + } + + // NOTE: Write methods call SaveChangesAsync directly, treating each operation as a unit of work. + // This is appropriate for single-aggregate commands. If multi-aggregate transactions are needed + // in the future, consider introducing a shared unit-of-work abstraction. + + public async Task AddAsync(Service service, CancellationToken cancellationToken = default) + { + await context.Services.AddAsync(service, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAsync(Service service, CancellationToken cancellationToken = default) + { + context.Services.Update(service); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(ServiceId id, CancellationToken cancellationToken = default) + { + // Use lightweight lookup without includes for delete + var service = await context.Services + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + + if (service is not null) + { + context.Services.Remove(service); + await context.SaveChangesAsync(cancellationToken); + } + // Delete is idempotent - no-op if service doesn't exist + } +} diff --git a/src/Modules/Catalogs/Tests/Builders/ServiceBuilder.cs b/src/Modules/Catalogs/Tests/Builders/ServiceBuilder.cs new file mode 100644 index 000000000..1f573c019 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Builders/ServiceBuilder.cs @@ -0,0 +1,81 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Builders; + +public class ServiceBuilder : BuilderBase +{ + private ServiceCategoryId? _categoryId; + private string? _name; + private string? _description; + private bool _isActive = true; + private int? _displayOrder = null; + + public ServiceBuilder() + { + Faker = new Faker() + .CustomInstantiator(f => + { + var generatedName = f.Commerce.ProductName(); + var generatedDescription = f.Commerce.ProductDescription(); + + var service = Service.Create( + _categoryId ?? ServiceCategoryId.New(), + _name ?? (generatedName.Length <= 150 ? generatedName : generatedName[..150]), + _description ?? (generatedDescription.Length <= 1000 ? generatedDescription : generatedDescription[..1000]), + _displayOrder ?? f.Random.Int(0, 100) + ); + + // Define o estado de ativo/inativo + if (!_isActive) + { + service.Deactivate(); + } + + return service; + }); + } + + public ServiceBuilder WithCategoryId(ServiceCategoryId categoryId) + { + _categoryId = categoryId; + return this; + } + + public ServiceBuilder WithCategoryId(Guid categoryId) + { + _categoryId = new ServiceCategoryId(categoryId); + return this; + } + + public ServiceBuilder WithName(string name) + { + _name = name; + return this; + } + + public ServiceBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + public ServiceBuilder WithDisplayOrder(int displayOrder) + { + _displayOrder = displayOrder; + return this; + } + + public ServiceBuilder AsActive() + { + _isActive = true; + return this; + } + + public ServiceBuilder AsInactive() + { + _isActive = false; + return this; + } +} diff --git a/src/Modules/Catalogs/Tests/Builders/ServiceCategoryBuilder.cs b/src/Modules/Catalogs/Tests/Builders/ServiceCategoryBuilder.cs new file mode 100644 index 000000000..04766be67 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Builders/ServiceCategoryBuilder.cs @@ -0,0 +1,66 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Shared.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Builders; + +public class ServiceCategoryBuilder : BuilderBase +{ + private string? _name; + private string? _description; + private bool _isActive = true; + private int _displayOrder; + + public ServiceCategoryBuilder() + { + Faker = new Faker() + .CustomInstantiator(f => + { + var category = ServiceCategory.Create( + _name ?? f.Commerce.Department(), + _description ?? f.Lorem.Sentence(), + _displayOrder >= 0 ? _displayOrder : f.Random.Int(1, 100) + ); + + // Define o estado de ativo/inativo + if (!_isActive) + { + category.Deactivate(); + } + + return category; + }); + } + + public ServiceCategoryBuilder WithName(string name) + { + _name = name; + return this; + } + + public ServiceCategoryBuilder WithDescription(string description) + { + _description = description; + return this; + } + + public ServiceCategoryBuilder WithDisplayOrder(int displayOrder) + { + _displayOrder = displayOrder; + return this; + } + + public ServiceCategoryBuilder AsActive() + { + _isActive = true; + // CustomInstantiator will ensure category is created active + return this; + } + + public ServiceCategoryBuilder AsInactive() + { + _isActive = false; + // CustomInstantiator will call Deactivate() after creation + return this; + } +} diff --git a/src/Modules/Catalogs/Tests/GlobalTestConfiguration.cs b/src/Modules/Catalogs/Tests/GlobalTestConfiguration.cs new file mode 100644 index 000000000..7b3813559 --- /dev/null +++ b/src/Modules/Catalogs/Tests/GlobalTestConfiguration.cs @@ -0,0 +1,12 @@ +using MeAjudaAi.Shared.Tests; + +namespace MeAjudaAi.Modules.Catalogs.Tests; + +/// +/// Collection definition específica para testes de integração do módulo Catalogs +/// +[CollectionDefinition("CatalogsIntegrationTests")] +public class CatalogsIntegrationTestCollection : ICollectionFixture +{ + // Esta classe não tem implementação - apenas define a collection específica do módulo Catalogs +} diff --git a/src/Modules/Catalogs/Tests/Infrastructure/CatalogsIntegrationTestBase.cs b/src/Modules/Catalogs/Tests/Infrastructure/CatalogsIntegrationTestBase.cs new file mode 100644 index 000000000..40080c47b --- /dev/null +++ b/src/Modules/Catalogs/Tests/Infrastructure/CatalogsIntegrationTestBase.cs @@ -0,0 +1,109 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using MeAjudaAi.Shared.Tests.Infrastructure; +using MeAjudaAi.Shared.Time; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Infrastructure; + +/// +/// Classe base para testes de integração específicos do módulo Catalogs. +/// +public abstract class CatalogsIntegrationTestBase : IntegrationTestBase +{ + /// + /// Configurações padrão para testes do módulo Catalogs + /// + protected override TestInfrastructureOptions GetTestOptions() + { + return new TestInfrastructureOptions + { + Database = new TestDatabaseOptions + { + DatabaseName = $"test_db_{GetType().Name.ToUpperInvariant()}", + Username = "test_user", + Password = "test_password", + Schema = "catalogs" + }, + Cache = new TestCacheOptions + { + Enabled = true // Usa o Redis compartilhado + }, + ExternalServices = new TestExternalServicesOptions + { + UseKeycloakMock = true, + UseMessageBusMock = true + } + }; + } + + /// + /// Configura serviços específicos do módulo Catalogs + /// + protected override void ConfigureModuleServices(IServiceCollection services, TestInfrastructureOptions options) + { + services.AddCatalogsTestInfrastructure(options); + } + + /// + /// Setup específico do módulo Catalogs (configurações adicionais se necessário) + /// + protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider) + { + // Qualquer setup específico adicional do módulo Catalogs pode ser feito aqui + // As migrações são aplicadas automaticamente pelo sistema de auto-descoberta + await Task.CompletedTask; + } + + /// + /// Cria uma categoria de serviço para teste e persiste no banco de dados + /// + protected async Task CreateServiceCategoryAsync( + string name, + string? description = null, + int displayOrder = 0, + CancellationToken cancellationToken = default) + { + var category = ServiceCategory.Create(name, description, displayOrder); + + var dbContext = GetService(); + await dbContext.ServiceCategories.AddAsync(category, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return category; + } + + /// + /// Cria um serviço para teste e persiste no banco de dados + /// + protected async Task CreateServiceAsync( + ServiceCategoryId categoryId, + string name, + string? description = null, + int displayOrder = 0, + CancellationToken cancellationToken = default) + { + var service = Service.Create(categoryId, name, description, displayOrder); + + var dbContext = GetService(); + await dbContext.Services.AddAsync(service, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return service; + } + + /// + /// Cria uma categoria de serviço e um serviço associado + /// + protected async Task<(ServiceCategory Category, Service Service)> CreateCategoryWithServiceAsync( + string categoryName, + string serviceName, + CancellationToken cancellationToken = default) + { + var category = await CreateServiceCategoryAsync(categoryName, cancellationToken: cancellationToken); + var service = await CreateServiceAsync(category.Id, serviceName, cancellationToken: cancellationToken); + + return (category, service); + } +} diff --git a/src/Modules/Catalogs/Tests/Infrastructure/TestInfrastructureExtensions.cs b/src/Modules/Catalogs/Tests/Infrastructure/TestInfrastructureExtensions.cs new file mode 100644 index 000000000..9e13691b1 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Infrastructure/TestInfrastructureExtensions.cs @@ -0,0 +1,79 @@ +using MeAjudaAi.Modules.Catalogs.Application; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence.Repositories; +using MeAjudaAi.Shared.Tests.Extensions; +using MeAjudaAi.Shared.Tests.Infrastructure; +using MeAjudaAi.Shared.Time; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Infrastructure; + +public static class TestInfrastructureExtensions +{ + /// + /// Adiciona infraestrutura de teste específica do módulo Catalogs + /// + public static IServiceCollection AddCatalogsTestInfrastructure( + this IServiceCollection services, + TestInfrastructureOptions? options = null) + { + options ??= new TestInfrastructureOptions(); + + services.AddSingleton(options); + + // Adicionar serviços compartilhados essenciais + services.AddSingleton(); + + // Usar extensões compartilhadas + services.AddTestLogging(); + services.AddTestCache(options.Cache); + + // Adicionar serviços de cache do Shared + services.AddSingleton(); + + // Configurar banco de dados específico do módulo Catalogs + services.AddDbContext((serviceProvider, dbOptions) => + { + var container = serviceProvider.GetRequiredService(); + var connectionString = container.GetConnectionString(); + + dbOptions.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Catalogs.Infrastructure"); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", options.Database.Schema); + npgsqlOptions.CommandTimeout(60); + }) + .UseSnakeCaseNamingConvention() + .ConfigureWarnings(warnings => + { + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning); + }); + }); + + // Configurar mocks específicos do módulo Catalogs + if (options.ExternalServices.UseMessageBusMock) + { + services.AddTestMessageBus(); + } + + // Adicionar repositórios específicos do Catalogs + services.AddScoped(); + services.AddScoped(); + + // Adicionar serviços de aplicação (incluindo ICatalogsModuleApi) + services.AddApplication(); + + return services; + } +} + +/// +/// Implementação de IDateTimeProvider para testes +/// +internal class TestDateTimeProvider : IDateTimeProvider +{ + public DateTime CurrentDate() => DateTime.UtcNow; +} diff --git a/src/Modules/Catalogs/Tests/Integration/CatalogsModuleApiIntegrationTests.cs b/src/Modules/Catalogs/Tests/Integration/CatalogsModuleApiIntegrationTests.cs new file mode 100644 index 000000000..c91ec9fa2 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Integration/CatalogsModuleApiIntegrationTests.cs @@ -0,0 +1,224 @@ +using MeAjudaAi.Modules.Catalogs.Tests.Infrastructure; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs; +using MeAjudaAi.Shared.Time; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Integration; + +[Collection("CatalogsIntegrationTests")] +public class CatalogsModuleApiIntegrationTests : CatalogsIntegrationTestBase +{ + private ICatalogsModuleApi _moduleApi = null!; + + protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider) + { + await base.OnModuleInitializeAsync(serviceProvider); + _moduleApi = GetService(); + } + + [Fact] + public async Task GetServiceCategoryByIdAsync_WithExistingCategory_ShouldReturnCategory() + { + // Arrange + var category = await CreateServiceCategoryAsync("Test Category", "Test Description", 1); + + // Act + var result = await _moduleApi.GetServiceCategoryByIdAsync(category.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(category.Id.Value); + result.Value.Name.Should().Be("Test Category"); + result.Value.Description.Should().Be("Test Description"); + result.Value.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task GetServiceCategoryByIdAsync_WithNonExistentCategory_ShouldReturnNull() + { + // Arrange + var nonExistentId = UuidGenerator.NewId(); + + // Act + var result = await _moduleApi.GetServiceCategoryByIdAsync(nonExistentId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task GetAllServiceCategoriesAsync_ShouldReturnAllCategories() + { + // Arrange + await CreateServiceCategoryAsync("Category 1"); + await CreateServiceCategoryAsync("Category 2"); + await CreateServiceCategoryAsync("Category 3"); + + // Act + var result = await _moduleApi.GetAllServiceCategoriesAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCountGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task GetAllServiceCategoriesAsync_WithActiveOnlyFilter_ShouldReturnOnlyActiveCategories() + { + // Arrange + var activeCategory = await CreateServiceCategoryAsync("Active Category"); + var inactiveCategory = await CreateServiceCategoryAsync("Inactive Category"); + + inactiveCategory.Deactivate(); + var repository = GetService(); + await repository.UpdateAsync(inactiveCategory); + + // Act + var result = await _moduleApi.GetAllServiceCategoriesAsync(activeOnly: true); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(c => c.Id == activeCategory.Id.Value); + result.Value.Should().NotContain(c => c.Id == inactiveCategory.Id.Value); + } + + [Fact] + public async Task GetServiceByIdAsync_WithExistingService_ShouldReturnService() + { + // Arrange + var (category, service) = await CreateCategoryWithServiceAsync("Category", "Test Service"); + + // Act + var result = await _moduleApi.GetServiceByIdAsync(service.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(service.Id.Value); + result.Value.Name.Should().Be("Test Service"); + result.Value.CategoryId.Should().Be(category.Id.Value); + } + + [Fact] + public async Task GetServiceByIdAsync_WithNonExistentService_ShouldReturnNull() + { + // Arrange + var nonExistentId = UuidGenerator.NewId(); + + // Act + var result = await _moduleApi.GetServiceByIdAsync(nonExistentId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public async Task GetAllServicesAsync_ShouldReturnAllServices() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + await CreateServiceAsync(category.Id, "Service 1"); + await CreateServiceAsync(category.Id, "Service 2"); + await CreateServiceAsync(category.Id, "Service 3"); + + // Act + var result = await _moduleApi.GetAllServicesAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCountGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task GetServicesByCategoryAsync_ShouldReturnCategoryServices() + { + // Arrange + var category1 = await CreateServiceCategoryAsync("Category 1"); + var category2 = await CreateServiceCategoryAsync("Category 2"); + + var service1 = await CreateServiceAsync(category1.Id, "Service 1-1"); + var service2 = await CreateServiceAsync(category1.Id, "Service 1-2"); + await CreateServiceAsync(category2.Id, "Service 2-1"); + + // Act + var result = await _moduleApi.GetServicesByCategoryAsync(category1.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().Contain(s => s.Id == service1.Id.Value); + result.Value.Should().Contain(s => s.Id == service2.Id.Value); + } + + [Fact] + public async Task IsServiceActiveAsync_WithActiveService_ShouldReturnTrue() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service = await CreateServiceAsync(category.Id, "Active Service"); + + // Act + var result = await _moduleApi.IsServiceActiveAsync(service.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Fact] + public async Task IsServiceActiveAsync_WithInactiveService_ShouldReturnFalse() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service = await CreateServiceAsync(category.Id, "Inactive Service"); + + service.Deactivate(); + var repository = GetService(); + await repository.UpdateAsync(service); + + // Act + var result = await _moduleApi.IsServiceActiveAsync(service.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeFalse(); + } + + [Fact] + public async Task ValidateServicesAsync_WithAllValidServices_ShouldReturnAllValid() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service1 = await CreateServiceAsync(category.Id, "Service 1"); + var service2 = await CreateServiceAsync(category.Id, "Service 2"); + + // Act + var result = await _moduleApi.ValidateServicesAsync(new[] { service1.Id.Value, service2.Id.Value }); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.AllValid.Should().BeTrue(); + result.Value.InvalidServiceIds.Should().BeEmpty(); + result.Value.InactiveServiceIds.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateServicesAsync_WithSomeInvalidServices_ShouldReturnMixedResult() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var validService = await CreateServiceAsync(category.Id, "Valid Service"); + var invalidServiceId = UuidGenerator.NewId(); + + // Act + var result = await _moduleApi.ValidateServicesAsync(new[] { validService.Id.Value, invalidServiceId }); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.AllValid.Should().BeFalse(); + result.Value.InvalidServiceIds.Should().HaveCount(1); + result.Value.InvalidServiceIds.Should().Contain(invalidServiceId); + } +} diff --git a/src/Modules/Catalogs/Tests/Integration/ServiceCategoryRepositoryIntegrationTests.cs b/src/Modules/Catalogs/Tests/Integration/ServiceCategoryRepositoryIntegrationTests.cs new file mode 100644 index 000000000..ed63744f6 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Integration/ServiceCategoryRepositoryIntegrationTests.cs @@ -0,0 +1,156 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Infrastructure; +using MeAjudaAi.Shared.Time; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Integration; + +[Collection("CatalogsIntegrationTests")] +public class ServiceCategoryRepositoryIntegrationTests : CatalogsIntegrationTestBase +{ + private IServiceCategoryRepository _repository = null!; + + protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider) + { + await base.OnModuleInitializeAsync(serviceProvider); + _repository = GetService(); + } + + [Fact] + public async Task GetByIdAsync_WithExistingCategory_ShouldReturnCategory() + { + // Arrange + var category = await CreateServiceCategoryAsync("Test Category", "Test Description", 1); + + // Act + var result = await _repository.GetByIdAsync(category.Id); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(category.Id); + result.Name.Should().Be("Test Category"); + result.Description.Should().Be("Test Description"); + result.DisplayOrder.Should().Be(1); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task GetByIdAsync_WithNonExistentCategory_ShouldReturnNull() + { + // Arrange + var nonExistentId = UuidGenerator.NewId(); + + // Act + var result = await _repository.GetByIdAsync(ServiceCategoryId.From(nonExistentId)); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetAllAsync_WithMultipleCategories_ShouldReturnAllCategories() + { + // Arrange + await CreateServiceCategoryAsync("Category 1", displayOrder: 1); + await CreateServiceCategoryAsync("Category 2", displayOrder: 2); + await CreateServiceCategoryAsync("Category 3", displayOrder: 3); + + // Act + var result = await _repository.GetAllAsync(); + + // Assert + result.Should().HaveCountGreaterThanOrEqualTo(3); + result.Should().Contain(c => c.Name == "Category 1"); + result.Should().Contain(c => c.Name == "Category 2"); + result.Should().Contain(c => c.Name == "Category 3"); + } + + [Fact] + public async Task GetAllAsync_WithActiveOnlyFilter_ShouldReturnOnlyActiveCategories() + { + // Arrange + var activeCategory = await CreateServiceCategoryAsync("Active Category"); + var inactiveCategory = await CreateServiceCategoryAsync("Inactive Category"); + + inactiveCategory.Deactivate(); + await _repository.UpdateAsync(inactiveCategory); + + // Act + var result = await _repository.GetAllAsync(activeOnly: true); + + // Assert + result.Should().Contain(c => c.Id == activeCategory.Id); + result.Should().NotContain(c => c.Id == inactiveCategory.Id); + } + + [Fact] + public async Task ExistsWithNameAsync_WithExistingName_ShouldReturnTrue() + { + // Arrange + await CreateServiceCategoryAsync("Unique Category"); + + // Act + var result = await _repository.ExistsWithNameAsync("Unique Category"); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsWithNameAsync_WithNonExistentName_ShouldReturnFalse() + { + // Act + var result = await _repository.ExistsWithNameAsync("Non Existent Category"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task AddAsync_WithValidCategory_ShouldPersistCategory() + { + // Arrange + var category = ServiceCategory.Create("New Category", "New Description", 10); + + // Act + await _repository.AddAsync(category); + + // Assert + var retrievedCategory = await _repository.GetByIdAsync(category.Id); + retrievedCategory.Should().NotBeNull(); + retrievedCategory!.Name.Should().Be("New Category"); + } + + [Fact] + public async Task UpdateAsync_WithModifiedCategory_ShouldPersistChanges() + { + // Arrange + var category = await CreateServiceCategoryAsync("Original Name"); + + // Act + category.Update("Updated Name", "Updated Description", 5); + await _repository.UpdateAsync(category); + + // Assert + var retrievedCategory = await _repository.GetByIdAsync(category.Id); + retrievedCategory.Should().NotBeNull(); + retrievedCategory!.Name.Should().Be("Updated Name"); + retrievedCategory.Description.Should().Be("Updated Description"); + retrievedCategory.DisplayOrder.Should().Be(5); + } + + [Fact] + public async Task DeleteAsync_WithExistingCategory_ShouldRemoveCategory() + { + // Arrange + var category = await CreateServiceCategoryAsync("To Be Deleted"); + + // Act + await _repository.DeleteAsync(category.Id); + + // Assert + var retrievedCategory = await _repository.GetByIdAsync(category.Id); + retrievedCategory.Should().BeNull(); + } +} diff --git a/src/Modules/Catalogs/Tests/Integration/ServiceRepositoryIntegrationTests.cs b/src/Modules/Catalogs/Tests/Integration/ServiceRepositoryIntegrationTests.cs new file mode 100644 index 000000000..5fec22928 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Integration/ServiceRepositoryIntegrationTests.cs @@ -0,0 +1,196 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Tests.Infrastructure; +using MeAjudaAi.Shared.Time; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Integration; + +[Collection("CatalogsIntegrationTests")] +public class ServiceRepositoryIntegrationTests : CatalogsIntegrationTestBase +{ + private IServiceRepository _repository = null!; + + protected override Task OnModuleInitializeAsync(IServiceProvider serviceProvider) + { + _repository = GetService(); + return Task.CompletedTask; + } + + [Fact] + public async Task GetByIdAsync_WithExistingService_ShouldReturnService() + { + // Arrange + var (category, service) = await CreateCategoryWithServiceAsync("Test Category", "Test Service"); + + // Act + var result = await _repository.GetByIdAsync(service.Id); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(service.Id); + result.Name.Should().Be("Test Service"); + result.CategoryId.Should().Be(category.Id); + } + + [Fact] + public async Task GetByIdAsync_WithNonExistentService_ShouldReturnNull() + { + // Arrange + var nonExistentId = UuidGenerator.NewId(); + + // Act + var result = await _repository.GetByIdAsync(new Domain.ValueObjects.ServiceId(nonExistentId)); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetAllAsync_WithMultipleServices_ShouldReturnAllServices() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + await CreateServiceAsync(category.Id, "Service 1", displayOrder: 1); + await CreateServiceAsync(category.Id, "Service 2", displayOrder: 2); + await CreateServiceAsync(category.Id, "Service 3", displayOrder: 3); + + // Act + var result = await _repository.GetAllAsync(); + + // Assert + result.Should().HaveCountGreaterThanOrEqualTo(3); + result.Should().Contain(s => s.Name == "Service 1"); + result.Should().Contain(s => s.Name == "Service 2"); + result.Should().Contain(s => s.Name == "Service 3"); + } + + [Fact] + public async Task GetAllAsync_WithActiveOnlyFilter_ShouldReturnOnlyActiveServices() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var activeService = await CreateServiceAsync(category.Id, "Active Service"); + var inactiveService = await CreateServiceAsync(category.Id, "Inactive Service"); + + inactiveService.Deactivate(); + await _repository.UpdateAsync(inactiveService); + + // Act + var result = await _repository.GetAllAsync(activeOnly: true); + + // Assert + result.Should().Contain(s => s.Id == activeService.Id); + result.Should().NotContain(s => s.Id == inactiveService.Id); + } + + [Fact] + public async Task GetByCategoryAsync_WithExistingCategory_ShouldReturnCategoryServices() + { + // Arrange + var category1 = await CreateServiceCategoryAsync("Category 1"); + var category2 = await CreateServiceCategoryAsync("Category 2"); + + var service1 = await CreateServiceAsync(category1.Id, "Service 1-1"); + var service2 = await CreateServiceAsync(category1.Id, "Service 1-2"); + await CreateServiceAsync(category2.Id, "Service 2-1"); + + // Act + var result = await _repository.GetByCategoryAsync(category1.Id); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(s => s.Id == service1.Id); + result.Should().Contain(s => s.Id == service2.Id); + } + + [Fact] + public async Task ExistsWithNameAsync_WithExistingName_ShouldReturnTrue() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + await CreateServiceAsync(category.Id, "Unique Service"); + + // Act + var result = await _repository.ExistsWithNameAsync("Unique Service"); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsWithNameAsync_WithNonExistentName_ShouldReturnFalse() + { + // Act + var result = await _repository.ExistsWithNameAsync("Non Existent Service"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task AddAsync_WithValidService_ShouldPersistService() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service = Domain.Entities.Service.Create(category.Id, "New Service", "New Description", 10); + + // Act + await _repository.AddAsync(service); + + // Assert + var retrievedService = await _repository.GetByIdAsync(service.Id); + retrievedService.Should().NotBeNull(); + retrievedService!.Name.Should().Be("New Service"); + } + + [Fact] + public async Task UpdateAsync_WithModifiedService_ShouldPersistChanges() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service = await CreateServiceAsync(category.Id, "Original Name"); + + // Act + service.Update("Updated Name", "Updated Description", 5); + await _repository.UpdateAsync(service); + + // Assert + var retrievedService = await _repository.GetByIdAsync(service.Id); + retrievedService.Should().NotBeNull(); + retrievedService!.Name.Should().Be("Updated Name"); + retrievedService.Description.Should().Be("Updated Description"); + retrievedService.DisplayOrder.Should().Be(5); + } + + [Fact] + public async Task DeleteAsync_WithExistingService_ShouldRemoveService() + { + // Arrange + var category = await CreateServiceCategoryAsync("Category"); + var service = await CreateServiceAsync(category.Id, "To Be Deleted"); + + // Act + await _repository.DeleteAsync(service.Id); + + // Assert + var retrievedService = await _repository.GetByIdAsync(service.Id); + retrievedService.Should().BeNull(); + } + + [Fact] + public async Task ChangeCategory_WithDifferentCategory_ShouldUpdateCategoryReference() + { + // Arrange + var category1 = await CreateServiceCategoryAsync("Category 1"); + var category2 = await CreateServiceCategoryAsync("Category 2"); + var service = await CreateServiceAsync(category1.Id, "Test Service"); + + // Act + service.ChangeCategory(category2.Id); + await _repository.UpdateAsync(service); + + // Assert + var retrievedService = await _repository.GetByIdAsync(service.Id); + retrievedService.Should().NotBeNull(); + retrievedService!.CategoryId.Should().Be(category2.Id); + } +} diff --git a/src/Modules/Catalogs/Tests/MeAjudaAi.Modules.Catalogs.Tests.csproj b/src/Modules/Catalogs/Tests/MeAjudaAi.Modules.Catalogs.Tests.csproj new file mode 100644 index 000000000..704e7b985 --- /dev/null +++ b/src/Modules/Catalogs/Tests/MeAjudaAi.Modules.Catalogs.Tests.csproj @@ -0,0 +1,56 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCategoryCommandHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCategoryCommandHandlerTests.cs new file mode 100644 index 000000000..d776c9513 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCategoryCommandHandlerTests.cs @@ -0,0 +1,90 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Commands; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class CreateServiceCategoryCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly CreateServiceCategoryCommandHandler _handler; + + public CreateServiceCategoryCommandHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new CreateServiceCategoryCommandHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldReturnSuccess() + { + // Arrange + var command = new CreateServiceCategoryCommand("Limpeza", "Serviços de limpeza", 1); + + _repositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny())) + .ReturnsAsync(false); + + _repositoryMock + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Id.Should().NotBe(Guid.Empty); + + _repositoryMock.Verify(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny()), Times.Once); + _repositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithDuplicateName_ShouldReturnFailure() + { + // Arrange + var command = new CreateServiceCategoryCommand("Limpeza", "Serviços de limpeza", 1); + + _repositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Message.Should().Contain("already exists"); + + _repositoryMock.Verify(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny()), Times.Once); + _repositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task Handle_WithInvalidName_ShouldReturnFailure(string? invalidName) + { + // Arrange + var command = new CreateServiceCategoryCommand(invalidName!, "Description", 1); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + result.Error!.Message.Should().Contain("name", "validation error should mention the problematic field"); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCommandHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCommandHandlerTests.cs new file mode 100644 index 000000000..ca2c617d7 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/CreateServiceCommandHandlerTests.cs @@ -0,0 +1,120 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.Service; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Commands; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class CreateServiceCommandHandlerTests +{ + private readonly Mock _categoryRepositoryMock; + private readonly Mock _serviceRepositoryMock; + private readonly CreateServiceCommandHandler _handler; + + public CreateServiceCommandHandlerTests() + { + _categoryRepositoryMock = new Mock(); + _serviceRepositoryMock = new Mock(); + _handler = new CreateServiceCommandHandler(_serviceRepositoryMock.Object, _categoryRepositoryMock.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldReturnSuccess() + { + // Arrange + var category = new ServiceCategoryBuilder().AsActive().Build(); + var command = new CreateServiceCommand(category.Id.Value, "Limpeza de Piscina", "Limpeza profunda", 1); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _serviceRepositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + _serviceRepositoryMock + .Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Id.Should().NotBe(Guid.Empty); + result.Value.Name.Should().Be(command.Name); + result.Value.CategoryId.Should().Be(command.CategoryId); + _serviceRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentCategory_ShouldReturnFailure() + { + // Arrange + var categoryId = Guid.NewGuid(); + var command = new CreateServiceCommand(categoryId, "Service Name", "Description", 1); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ServiceCategory?)null); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("not found"); + _serviceRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithInactiveCategory_ShouldReturnFailure() + { + // Arrange + var category = new ServiceCategoryBuilder().AsInactive().Build(); + var command = new CreateServiceCommand(category.Id.Value, "Limpeza de Piscina", "Limpeza profunda", 1); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("inactive"); + _serviceRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithDuplicateName_ShouldReturnFailure() + { + // Arrange + var category = new ServiceCategoryBuilder().AsActive().Build(); + var command = new CreateServiceCommand(category.Id.Value, "Duplicate Name", "Description", 1); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _serviceRepositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, null, It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("already exists"); + _serviceRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/DeleteServiceCategoryCommandHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/DeleteServiceCategoryCommandHandlerTests.cs new file mode 100644 index 000000000..9c5a9408b --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/DeleteServiceCategoryCommandHandlerTests.cs @@ -0,0 +1,96 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Commands; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class DeleteServiceCategoryCommandHandlerTests +{ + private readonly Mock _categoryRepositoryMock; + private readonly Mock _serviceRepositoryMock; + private readonly DeleteServiceCategoryCommandHandler _handler; + + public DeleteServiceCategoryCommandHandlerTests() + { + _categoryRepositoryMock = new Mock(); + _serviceRepositoryMock = new Mock(); + _handler = new DeleteServiceCategoryCommandHandler(_categoryRepositoryMock.Object, _serviceRepositoryMock.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldReturnSuccess() + { + // Arrange + var category = new ServiceCategoryBuilder().WithName("Limpeza").Build(); + var command = new DeleteServiceCategoryCommand(category.Id.Value); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _serviceRepositoryMock + .Setup(x => x.CountByCategoryAsync(It.IsAny(), false, It.IsAny())) + .ReturnsAsync(0); + + _categoryRepositoryMock + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + _categoryRepositoryMock.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentCategory_ShouldReturnFailure() + { + // Arrange + var categoryId = Guid.NewGuid(); + var command = new DeleteServiceCategoryCommand(categoryId); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ServiceCategory?)null); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("not found"); + _categoryRepositoryMock.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithAssociatedServices_ShouldReturnFailure() + { + // Arrange + var category = new ServiceCategoryBuilder().WithName("Limpeza").Build(); + var command = new DeleteServiceCategoryCommand(category.Id.Value); + + _categoryRepositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _serviceRepositoryMock + .Setup(x => x.CountByCategoryAsync(It.IsAny(), false, It.IsAny())) + .ReturnsAsync(3); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("Cannot delete"); + _categoryRepositoryMock.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/UpdateServiceCategoryCommandHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/UpdateServiceCategoryCommandHandlerTests.cs new file mode 100644 index 000000000..d741e24bc --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Commands/UpdateServiceCategoryCommandHandlerTests.cs @@ -0,0 +1,99 @@ +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Commands; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Commands; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class UpdateServiceCategoryCommandHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly UpdateServiceCategoryCommandHandler _handler; + + public UpdateServiceCategoryCommandHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new UpdateServiceCategoryCommandHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldReturnSuccess() + { + // Arrange + var category = new ServiceCategoryBuilder() + .WithName("Original Name") + .Build(); + var command = new UpdateServiceCategoryCommand(category.Id.Value, "Updated Name", "Updated Description", 2); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _repositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + _repositoryMock + .Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + _repositoryMock.Verify(x => x.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Once); + _repositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentCategory_ShouldReturnFailure() + { + // Arrange + var categoryId = Guid.NewGuid(); + var command = new UpdateServiceCategoryCommand(categoryId, "Updated Name", "Updated Description", 2); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ServiceCategory?)null); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("not found"); + _repositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WithDuplicateName_ShouldReturnFailure() + { + // Arrange + var category = new ServiceCategoryBuilder() + .WithName("Original Name") + .Build(); + var command = new UpdateServiceCategoryCommand(category.Id.Value, "Duplicate Name", "Description", 2); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + _repositoryMock + .Setup(x => x.ExistsWithNameAsync(command.Name, It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error!.Message.Should().Contain("already exists"); + _repositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServiceCategoriesQueryHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServiceCategoriesQueryHandlerTests.cs new file mode 100644 index 000000000..ea27aa590 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServiceCategoriesQueryHandlerTests.cs @@ -0,0 +1,96 @@ +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class GetAllServiceCategoriesQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly GetAllServiceCategoriesQueryHandler _handler; + + public GetAllServiceCategoriesQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new GetAllServiceCategoriesQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnAllCategories() + { + // Arrange + var query = new GetAllServiceCategoriesQuery(ActiveOnly: false); + var categories = new List + { + new ServiceCategoryBuilder().WithName("Limpeza").Build(), + new ServiceCategoryBuilder().WithName("Reparos").Build(), + new ServiceCategoryBuilder().WithName("Pintura").Build() + }; + + _repositoryMock + .Setup(x => x.GetAllAsync(false, It.IsAny())) + .ReturnsAsync(categories); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(3); + result.Value.Should().Contain(c => c.Name == "Limpeza"); + result.Value.Should().Contain(c => c.Name == "Reparos"); + result.Value.Should().Contain(c => c.Name == "Pintura"); + + _repositoryMock.Verify(x => x.GetAllAsync(false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithActiveOnlyTrue_ShouldReturnOnlyActiveCategories() + { + // Arrange + var query = new GetAllServiceCategoriesQuery(ActiveOnly: true); + var categories = new List + { + new ServiceCategoryBuilder().WithName("Limpeza").AsActive().Build(), + new ServiceCategoryBuilder().WithName("Reparos").AsActive().Build() + }; + + _repositoryMock + .Setup(x => x.GetAllAsync(true, It.IsAny())) + .ReturnsAsync(categories); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(c => c.IsActive); + + _repositoryMock.Verify(x => x.GetAllAsync(true, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNoCategories_ShouldReturnEmptyList() + { + // Arrange + var query = new GetAllServiceCategoriesQuery(ActiveOnly: false); + + _repositoryMock + .Setup(x => x.GetAllAsync(false, It.IsAny())) + .ReturnsAsync(new List()); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + + _repositoryMock.Verify(x => x.GetAllAsync(false, It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServicesQueryHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServicesQueryHandlerTests.cs new file mode 100644 index 000000000..4b38745a6 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetAllServicesQueryHandlerTests.cs @@ -0,0 +1,95 @@ +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class GetAllServicesQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly GetAllServicesQueryHandler _handler; + + public GetAllServicesQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new GetAllServicesQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnAllServices() + { + // Arrange + var query = new GetAllServicesQuery(ActiveOnly: false); + var categoryId = Guid.NewGuid(); + var services = new List + { + new ServiceBuilder().WithCategoryId(categoryId).WithName("Service 1").Build(), + new ServiceBuilder().WithCategoryId(categoryId).WithName("Service 2").Build(), + new ServiceBuilder().WithCategoryId(categoryId).WithName("Service 3").Build() + }; + + _repositoryMock + .Setup(x => x.GetAllAsync(false, It.IsAny())) + .ReturnsAsync(services); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(3); + + _repositoryMock.Verify(x => x.GetAllAsync(false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithActiveOnlyTrue_ShouldReturnOnlyActiveServices() + { + // Arrange + var query = new GetAllServicesQuery(ActiveOnly: true); + var categoryId = Guid.NewGuid(); + var services = new List + { + new ServiceBuilder().WithCategoryId(categoryId).WithName("Active 1").AsActive().Build(), + new ServiceBuilder().WithCategoryId(categoryId).WithName("Active 2").AsActive().Build() + }; + + _repositoryMock + .Setup(x => x.GetAllAsync(true, It.IsAny())) + .ReturnsAsync(services); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().OnlyContain(s => s.IsActive); + + _repositoryMock.Verify(x => x.GetAllAsync(true, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNoServices_ShouldReturnEmptyList() + { + // Arrange + var query = new GetAllServicesQuery(ActiveOnly: false); + + _repositoryMock + .Setup(x => x.GetAllAsync(false, It.IsAny())) + .ReturnsAsync(new List()); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + + _repositoryMock.Verify(x => x.GetAllAsync(false, It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceByIdQueryHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceByIdQueryHandlerTests.cs new file mode 100644 index 000000000..bb5671e63 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceByIdQueryHandlerTests.cs @@ -0,0 +1,74 @@ +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class GetServiceByIdQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly GetServiceByIdQueryHandler _handler; + + public GetServiceByIdQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new GetServiceByIdQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithExistingService_ShouldReturnSuccess() + { + // Arrange + var categoryId = Guid.NewGuid(); + var service = new ServiceBuilder() + .WithCategoryId(categoryId) + .WithName("Limpeza de Piscina") + .WithDescription("Limpeza profunda de piscina") + .Build(); + var query = new GetServiceByIdQuery(service.Id.Value); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(service); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(service.Id.Value); + result.Value.CategoryId.Should().Be(categoryId); + result.Value.Name.Should().Be("Limpeza de Piscina"); + result.Value.Description.Should().Be("Limpeza profunda de piscina"); + + _repositoryMock.Verify(x => x.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentService_ShouldReturnNull() + { + // Arrange + var serviceId = Guid.NewGuid(); + var query = new GetServiceByIdQuery(serviceId); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Service?)null); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + + _repositoryMock.Verify(x => x.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceCategoryByIdQueryHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceCategoryByIdQueryHandlerTests.cs new file mode 100644 index 000000000..f5f1dd0a1 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServiceCategoryByIdQueryHandlerTests.cs @@ -0,0 +1,70 @@ +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class GetServiceCategoryByIdQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly GetServiceCategoryByIdQueryHandler _handler; + + public GetServiceCategoryByIdQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new GetServiceCategoryByIdQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithExistingCategory_ShouldReturnSuccess() + { + // Arrange + var category = new ServiceCategoryBuilder() + .WithName("Limpeza") + .WithDescription("Serviços de limpeza") + .Build(); + var query = new GetServiceCategoryByIdQuery(category.Id.Value); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(category); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.Id.Should().Be(category.Id.Value); + result.Value.Name.Should().Be("Limpeza"); + result.Value.Description.Should().Be("Serviços de limpeza"); + + _repositoryMock.Verify(x => x.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentCategory_ShouldReturnNull() + { + // Arrange + var categoryId = Guid.NewGuid(); + var query = new GetServiceCategoryByIdQuery(categoryId); + + _repositoryMock + .Setup(x => x.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((MeAjudaAi.Modules.Catalogs.Domain.Entities.ServiceCategory?)null); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + + _repositoryMock.Verify(x => x.GetByIdAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServicesByCategoryQueryHandlerTests.cs b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServicesByCategoryQueryHandlerTests.cs new file mode 100644 index 000000000..ce2793d44 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Application/Handlers/Queries/GetServicesByCategoryQueryHandlerTests.cs @@ -0,0 +1,96 @@ +using MeAjudaAi.Modules.Catalogs.Application.Handlers.Queries; +using MeAjudaAi.Modules.Catalogs.Application.Queries.Service; +using MeAjudaAi.Modules.Catalogs.Domain.Repositories; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Tests.Builders; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Application.Handlers.Queries; + +[Trait("Category", "Unit")] +[Trait("Module", "Catalogs")] +[Trait("Layer", "Application")] +public class GetServicesByCategoryQueryHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly GetServicesByCategoryQueryHandler _handler; + + public GetServicesByCategoryQueryHandlerTests() + { + _repositoryMock = new Mock(); + _handler = new GetServicesByCategoryQueryHandler(_repositoryMock.Object); + } + + [Fact] + public async Task Handle_WithExistingCategory_ShouldReturnServices() + { + // Arrange + var categoryId = Guid.NewGuid(); + var query = new GetServicesByCategoryQuery(categoryId, ActiveOnly: false); + var services = new List + { + new ServiceBuilder().WithCategoryId(categoryId).WithName("Service 1").Build(), + new ServiceBuilder().WithCategoryId(categoryId).WithName("Service 2").Build() + }; + + _repositoryMock + .Setup(x => x.GetByCategoryAsync(It.IsAny(), false, It.IsAny())) + .ReturnsAsync(services); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().AllSatisfy(s => s.CategoryId.Should().Be(categoryId)); + + _repositoryMock.Verify(x => x.GetByCategoryAsync(It.IsAny(), false, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithActiveOnlyTrue_ShouldReturnOnlyActiveServices() + { + // Arrange + var categoryId = Guid.NewGuid(); + var query = new GetServicesByCategoryQuery(categoryId, ActiveOnly: true); + var services = new List + { + new ServiceBuilder().WithCategoryId(categoryId).WithName("Active").AsActive().Build() + }; + + _repositoryMock + .Setup(x => x.GetByCategoryAsync(It.IsAny(), true, It.IsAny())) + .ReturnsAsync(services); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.Should().OnlyContain(s => s.IsActive); + + _repositoryMock.Verify(x => x.GetByCategoryAsync(It.IsAny(), true, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNoServices_ShouldReturnEmptyList() + { + // Arrange + var categoryId = Guid.NewGuid(); + var query = new GetServicesByCategoryQuery(categoryId, ActiveOnly: false); + + _repositoryMock + .Setup(x => x.GetByCategoryAsync(It.IsAny(), false, It.IsAny())) + .ReturnsAsync(new List()); + + // Act + var result = await _handler.HandleAsync(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + + _repositoryMock.Verify(x => x.GetByCategoryAsync(It.IsAny(), false, It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceCategoryTests.cs b/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceCategoryTests.cs new file mode 100644 index 000000000..d6c5281c6 --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceCategoryTests.cs @@ -0,0 +1,252 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Events; +using MeAjudaAi.Modules.Catalogs.Domain.Exceptions; +using MeAjudaAi.Shared.Constants; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Domain.Entities; + +public class ServiceCategoryTests +{ + [Fact] + public void Create_WithValidParameters_ShouldCreateServiceCategory() + { + // Arrange + var name = "Home Repairs"; + var description = "General home repair services"; + var displayOrder = 1; + var before = DateTime.UtcNow; + + // Act + var category = ServiceCategory.Create(name, description, displayOrder); + + // Assert + category.Should().NotBeNull(); + category.Id.Should().NotBeNull(); + category.Id.Value.Should().NotBe(Guid.Empty); + category.Name.Should().Be(name); + category.Description.Should().Be(description); + category.DisplayOrder.Should().Be(displayOrder); + category.IsActive.Should().BeTrue(); + category.CreatedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + + // Service categories are created (domain events are raised internally but not exposed publicly) + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Create_WithInvalidName_ShouldThrowCatalogDomainException(string? invalidName) + { + // Act & Assert + var act = () => ServiceCategory.Create(invalidName!, null, 0); + act.Should().Throw() + .WithMessage("*name*"); + } + + [Fact] + public void Create_WithNameAtMaxLength_ShouldSucceed() + { + // Arrange + var maxLengthName = new string('a', ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength); + + // Act + var category = ServiceCategory.Create(maxLengthName, null, 0); + + // Assert + category.Should().NotBeNull(); + category.Name.Should().HaveLength(ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength); + } + + [Fact] + public void Create_WithNameExceedingMaxLength_ShouldThrowCatalogDomainException() + { + // Arrange + var tooLongName = new string('a', ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength + 1); + + // Act & Assert + var act = () => ServiceCategory.Create(tooLongName, null, 0); + act.Should().Throw() + .WithMessage($"*cannot exceed {ValidationConstants.CatalogLimits.ServiceCategoryNameMaxLength} characters*"); + } + + [Fact] + public void Create_WithNegativeDisplayOrder_ShouldThrowCatalogDomainException() + { + // Arrange + var name = "Test Category"; + var negativeDisplayOrder = -1; + + // Act & Assert + var act = () => ServiceCategory.Create(name, null, negativeDisplayOrder); + act.Should().Throw() + .WithMessage("*Display order cannot be negative*"); + } + + [Fact] + public void Create_WithLeadingAndTrailingSpacesInName_ShouldTrimSpaces() + { + // Arrange + var nameWithSpaces = " Test Category "; + var expectedName = "Test Category"; + + // Act + var category = ServiceCategory.Create(nameWithSpaces, null, 0); + + // Assert + category.Name.Should().Be(expectedName); + } + + [Fact] + public void Create_WithLeadingAndTrailingSpacesInDescription_ShouldTrimSpaces() + { + // Arrange + var descriptionWithSpaces = " Test Description "; + var expectedDescription = "Test Description"; + + // Act + var category = ServiceCategory.Create("Test", descriptionWithSpaces, 0); + + // Assert + category.Description.Should().Be(expectedDescription); + } + + [Fact] + public void Create_WithDescriptionAtMaxLength_ShouldSucceed() + { + // Arrange + var maxLengthDescription = new string('a', ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength); + + // Act + var category = ServiceCategory.Create("Test", maxLengthDescription, 0); + + // Assert + category.Should().NotBeNull(); + category.Description.Should().HaveLength(ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength); + } + + [Fact] + public void Create_WithDescriptionExceedingMaxLength_ShouldThrowCatalogDomainException() + { + // Arrange + var tooLongDescription = new string('a', ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength + 1); + + // Act & Assert + var act = () => ServiceCategory.Create("Test", tooLongDescription, 0); + act.Should().Throw() + .WithMessage($"*cannot exceed {ValidationConstants.CatalogLimits.ServiceCategoryDescriptionMaxLength} characters*"); + } + + [Fact] + public void Update_WithValidParameters_ShouldUpdateServiceCategory() + { + // Arrange + var category = ServiceCategory.Create("Original Name", "Original Description", 1); + var before = DateTime.UtcNow; + + var newName = "Updated Name"; + var newDescription = "Updated Description"; + var newDisplayOrder = 2; + + // Act + category.Update(newName, newDescription, newDisplayOrder); + + // Assert + category.Name.Should().Be(newName); + category.Description.Should().Be(newDescription); + category.DisplayOrder.Should().Be(newDisplayOrder); + category.UpdatedAt.Should().NotBeNull() + .And.Subject.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public void Update_WithLeadingAndTrailingSpaces_ShouldTrimSpaces() + { + // Arrange + var category = ServiceCategory.Create("Original", "Original Description", 1); + + // Act + category.Update(" Updated Name ", " Updated Description ", 2); + + // Assert + category.Name.Should().Be("Updated Name"); + category.Description.Should().Be("Updated Description"); + } + + [Fact] + public void Update_WithNegativeDisplayOrder_ShouldThrowCatalogDomainException() + { + // Arrange + var category = ServiceCategory.Create("Test Category", null, 0); + var negativeDisplayOrder = -1; + + // Act & Assert + var act = () => category.Update("Updated Name", null, negativeDisplayOrder); + act.Should().Throw() + .WithMessage("*Display order cannot be negative*"); + } + + [Fact] + public void Activate_WhenInactive_ShouldActivateCategoryAndUpdateTimestamp() + { + // Arrange + var category = ServiceCategory.Create("Test Category", null, 0); + category.Deactivate(); + var before = DateTime.UtcNow; + + // Act + category.Activate(); + + // Assert + category.IsActive.Should().BeTrue(); + category.UpdatedAt.Should().NotBeNull() + .And.Subject.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public void Activate_WhenAlreadyActive_ShouldRemainActiveWithoutUpdatingTimestamp() + { + // Arrange + var category = ServiceCategory.Create("Test Category", null, 0); + var originalUpdatedAt = category.UpdatedAt; + + // Act + category.Activate(); + + // Assert + category.IsActive.Should().BeTrue(); + category.UpdatedAt.Should().Be(originalUpdatedAt); + } + + [Fact] + public void Deactivate_WhenActive_ShouldDeactivateCategoryAndUpdateTimestamp() + { + // Arrange + var category = ServiceCategory.Create("Test Category", null, 0); + var before = DateTime.UtcNow; + + // Act + category.Deactivate(); + + // Assert + category.IsActive.Should().BeFalse(); + category.UpdatedAt.Should().NotBeNull() + .And.Subject.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public void Deactivate_WhenAlreadyInactive_ShouldRemainInactiveWithoutUpdatingTimestamp() + { + // Arrange + var category = ServiceCategory.Create("Test Category", null, 0); + category.Deactivate(); + var updatedAtAfterFirstDeactivate = category.UpdatedAt; + + // Act + category.Deactivate(); + + // Assert + category.IsActive.Should().BeFalse(); + category.UpdatedAt.Should().Be(updatedAtAfterFirstDeactivate); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceTests.cs b/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceTests.cs new file mode 100644 index 000000000..0e4d6910d --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Domain/Entities/ServiceTests.cs @@ -0,0 +1,195 @@ +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.Events; +using MeAjudaAi.Modules.Catalogs.Domain.Exceptions; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Domain.Entities; + +public class ServiceTests +{ + [Fact] + public void Create_WithValidParameters_ShouldCreateService() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var name = "Plumbing Repair"; + var description = "Fix leaks and pipes"; + var displayOrder = 1; + + // Act + var service = Service.Create(categoryId, name, description, displayOrder); + + // Assert + service.Should().NotBeNull(); + service.Id.Should().NotBeNull(); + service.Id.Value.Should().NotBe(Guid.Empty); + service.CategoryId.Should().Be(categoryId); + service.Name.Should().Be(name); + service.Description.Should().Be(description); + service.DisplayOrder.Should().Be(displayOrder); + service.IsActive.Should().BeTrue(); + service.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + + // Services are created (domain events are raised internally but not exposed publicly) + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Create_WithInvalidName_ShouldThrowCatalogDomainException(string? invalidName) + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + + // Act & Assert + var act = () => Service.Create(categoryId, invalidName!, null, 0); + act.Should().Throw() + .WithMessage("*name*"); + } + + [Fact] + public void Create_WithTooLongName_ShouldThrowCatalogDomainException() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var longName = new string('a', 151); + + // Act & Assert + var act = () => Service.Create(categoryId, longName, null, 0); + act.Should().Throw(); + } + + [Fact] + public void Create_WithNegativeDisplayOrder_ShouldThrowCatalogDomainException() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + + // Act & Assert + var act = () => Service.Create(categoryId, "Valid Name", null, -1); + act.Should().Throw() + .WithMessage("*Display order cannot be negative*"); + } + + [Fact] + public void Update_WithValidParameters_ShouldUpdateService() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Original Name", "Original Description", 1); + + var newName = "Updated Name"; + var newDescription = "Updated Description"; + var newDisplayOrder = 2; + + // Act + service.Update(newName, newDescription, newDisplayOrder); + + // Assert + service.Name.Should().Be(newName); + service.Description.Should().Be(newDescription); + service.DisplayOrder.Should().Be(newDisplayOrder); + service.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void Update_WithNegativeDisplayOrder_ShouldThrowCatalogDomainException() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + + // Act & Assert + var act = () => service.Update("Valid Name", null, -5); + act.Should().Throw() + .WithMessage("*Display order cannot be negative*"); + } + + [Fact] + public void ChangeCategory_WithDifferentCategory_ShouldChangeCategory() + { + // Arrange + var originalCategoryId = new ServiceCategoryId(Guid.NewGuid()); + var newCategoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(originalCategoryId, "Test Service", null, 0); + + // Act + service.ChangeCategory(newCategoryId); + + // Assert + service.CategoryId.Should().Be(newCategoryId); + } + + [Fact] + public void ChangeCategory_WithSameCategory_ShouldNotChange() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + + // Act + service.ChangeCategory(categoryId); + + // Assert + service.CategoryId.Should().Be(categoryId); + } + + [Fact] + public void Activate_WhenInactive_ShouldActivateService() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + service.Deactivate(); + + // Act + service.Activate(); + + // Assert + service.IsActive.Should().BeTrue(); + } + + [Fact] + public void Activate_WhenAlreadyActive_ShouldRemainActive() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + + // Act + service.Activate(); + + // Assert + service.IsActive.Should().BeTrue(); + } + + [Fact] + public void Deactivate_WhenActive_ShouldDeactivateService() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + + // Act + service.Deactivate(); + + // Assert + service.IsActive.Should().BeFalse(); + } + + [Fact] + public void Deactivate_WhenAlreadyInactive_ShouldRemainInactive() + { + // Arrange + var categoryId = new ServiceCategoryId(Guid.NewGuid()); + var service = Service.Create(categoryId, "Test Service", null, 0); + service.Deactivate(); + + // Act + service.Deactivate(); + + // Assert + service.IsActive.Should().BeFalse(); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceCategoryIdTests.cs b/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceCategoryIdTests.cs new file mode 100644 index 000000000..184bbbcfb --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceCategoryIdTests.cs @@ -0,0 +1,86 @@ +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Domain.ValueObjects; + +public class ServiceCategoryIdTests +{ + [Fact] + public void Constructor_WithValidGuid_ShouldCreateServiceCategoryId() + { + // Arrange + var guid = Guid.NewGuid(); + + // Act + var categoryId = new ServiceCategoryId(guid); + + // Assert + categoryId.Value.Should().Be(guid); + } + + [Fact] + public void Constructor_WithEmptyGuid_ShouldThrowArgumentException() + { + // Arrange + var emptyGuid = Guid.Empty; + + // Act & Assert + var act = () => new ServiceCategoryId(emptyGuid); + act.Should().Throw() + .WithMessage("ServiceCategoryId cannot be empty*"); + } + + [Fact] + public void Equals_WithSameValue_ShouldReturnTrue() + { + // Arrange + var guid = Guid.NewGuid(); + var categoryId1 = new ServiceCategoryId(guid); + var categoryId2 = new ServiceCategoryId(guid); + + // Act & Assert + categoryId1.Should().Be(categoryId2); + categoryId1.GetHashCode().Should().Be(categoryId2.GetHashCode()); + } + + [Fact] + public void Equals_WithDifferentValues_ShouldReturnFalse() + { + // Arrange + var categoryId1 = new ServiceCategoryId(Guid.NewGuid()); + var categoryId2 = new ServiceCategoryId(Guid.NewGuid()); + + // Act & Assert + categoryId1.Should().NotBe(categoryId2); + } + + [Fact] + public void ToString_ShouldReturnGuidString() + { + // Arrange + var guid = Guid.NewGuid(); + var categoryId = new ServiceCategoryId(guid); + + // Act + var result = categoryId.ToString(); + + // Assert + result.Should().Be(guid.ToString()); + } + + [Fact] + public void ValueObject_Equality_ShouldWorkCorrectly() + { + // Arrange + var guid = Guid.NewGuid(); + var categoryId1 = new ServiceCategoryId(guid); + var categoryId2 = new ServiceCategoryId(guid); + var categoryId3 = new ServiceCategoryId(Guid.NewGuid()); + + // Act & Assert + (categoryId1 == categoryId2).Should().BeTrue(); + (categoryId1 != categoryId3).Should().BeTrue(); + categoryId1.Equals(categoryId2).Should().BeTrue(); + categoryId1.Equals(categoryId3).Should().BeFalse(); + categoryId1.Equals(null).Should().BeFalse(); + } +} diff --git a/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceIdTests.cs b/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceIdTests.cs new file mode 100644 index 000000000..83053504d --- /dev/null +++ b/src/Modules/Catalogs/Tests/Unit/Domain/ValueObjects/ServiceIdTests.cs @@ -0,0 +1,86 @@ +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; + +namespace MeAjudaAi.Modules.Catalogs.Tests.Unit.Domain.ValueObjects; + +public class ServiceIdTests +{ + [Fact] + public void Constructor_WithValidGuid_ShouldCreateServiceId() + { + // Arrange + var guid = Guid.NewGuid(); + + // Act + var serviceId = new ServiceId(guid); + + // Assert + serviceId.Value.Should().Be(guid); + } + + [Fact] + public void Constructor_WithEmptyGuid_ShouldThrowArgumentException() + { + // Arrange + var emptyGuid = Guid.Empty; + + // Act & Assert + var act = () => new ServiceId(emptyGuid); + act.Should().Throw() + .WithMessage("ServiceId cannot be empty*"); + } + + [Fact] + public void Equals_WithSameValue_ShouldReturnTrue() + { + // Arrange + var guid = Guid.NewGuid(); + var serviceId1 = new ServiceId(guid); + var serviceId2 = new ServiceId(guid); + + // Act & Assert + serviceId1.Should().Be(serviceId2); + serviceId1.GetHashCode().Should().Be(serviceId2.GetHashCode()); + } + + [Fact] + public void Equals_WithDifferentValues_ShouldReturnFalse() + { + // Arrange + var serviceId1 = new ServiceId(Guid.NewGuid()); + var serviceId2 = new ServiceId(Guid.NewGuid()); + + // Act & Assert + serviceId1.Should().NotBe(serviceId2); + } + + [Fact] + public void ToString_ShouldReturnGuidString() + { + // Arrange + var guid = Guid.NewGuid(); + var serviceId = new ServiceId(guid); + + // Act + var result = serviceId.ToString(); + + // Assert + result.Should().Be(guid.ToString()); + } + + [Fact] + public void ValueObject_Equality_ShouldWorkCorrectly() + { + // Arrange + var guid = Guid.NewGuid(); + var serviceId1 = new ServiceId(guid); + var serviceId2 = new ServiceId(guid); + var serviceId3 = new ServiceId(Guid.NewGuid()); + + // Act & Assert + (serviceId1 == serviceId2).Should().BeTrue(); + (serviceId1 != serviceId3).Should().BeTrue(); + serviceId1.Equals(serviceId2).Should().BeTrue(); + serviceId1.Equals(serviceId3).Should().BeFalse(); + serviceId1.Equals(null).Should().BeFalse(); + } +} diff --git a/src/Modules/Documents/API/Properties/launchSettings.json b/src/Modules/Documents/API/Properties/launchSettings.json deleted file mode 100644 index 09d133bf7..000000000 --- a/src/Modules/Documents/API/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "MeAjudaAi.Modules.Documents.API": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:53345;http://localhost:53346" - } - } -} \ No newline at end of file diff --git a/src/Modules/Documents/Application/ModuleApi/DocumentsModuleApi.cs b/src/Modules/Documents/Application/ModuleApi/DocumentsModuleApi.cs index 08e4c9941..9b4bca9e2 100644 --- a/src/Modules/Documents/Application/ModuleApi/DocumentsModuleApi.cs +++ b/src/Modules/Documents/Application/ModuleApi/DocumentsModuleApi.cs @@ -23,21 +23,24 @@ namespace MeAjudaAi.Modules.Documents.Application.ModuleApi; /// GetDocumentByIdAsync retorna Success(null) para documentos inexistentes em vez de tratar /// "not found" como falha. A verificação de disponibilidade depende desta convenção. /// Metadados do Módulo: -/// Os valores do atributo ModuleApi devem corresponder às constantes ModuleNameConst e ApiVersionConst. -/// Um teste unitário valida esta consistência para prevenir deriva de configuração. +/// Os valores do atributo ModuleApi devem corresponder às propriedades ModuleMetadata.Name e ModuleMetadata.Version. +/// Esta consistência é garantida pela classe aninhada ModuleMetadata que centraliza as constantes. /// -[ModuleApi(ModuleNameConst, ApiVersionConst)] +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] public sealed class DocumentsModuleApi( IQueryHandler getDocumentStatusHandler, IQueryHandler> getProviderDocumentsHandler, IServiceProvider serviceProvider, ILogger logger) : IDocumentsModuleApi { - private const string ModuleNameConst = "Documents"; - private const string ApiVersionConst = "1.0"; + private static class ModuleMetadata + { + public const string Name = "Documents"; + public const string Version = "1.0"; + } - public string ModuleName => ModuleNameConst; - public string ApiVersion => ApiVersionConst; + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; public async Task IsAvailableAsync(CancellationToken cancellationToken = default) { diff --git a/src/Modules/Location/Application/ModuleApi/LocationsModuleApi.cs b/src/Modules/Location/Application/ModuleApi/LocationsModuleApi.cs index b26ecab1d..99de78e00 100644 --- a/src/Modules/Location/Application/ModuleApi/LocationsModuleApi.cs +++ b/src/Modules/Location/Application/ModuleApi/LocationsModuleApi.cs @@ -11,14 +11,20 @@ namespace MeAjudaAi.Modules.Location.Application.ModuleApi; /// /// Implementação da API pública do módulo Location para outros módulos. /// -[ModuleApi("Location", "1.0")] +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] public sealed class LocationsModuleApi( ICepLookupService cepLookupService, IGeocodingService geocodingService, ILogger logger) : ILocationModuleApi { - public string ModuleName => "Location"; - public string ApiVersion => "1.0"; + private static class ModuleMetadata + { + public const string Name = "Location"; + public const string Version = "1.0"; + } + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; // CEP real usado para health check - validação end-to-end de conectividade com APIs externas private const string HealthCheckCep = "01310100"; diff --git a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs index e2865b538..6ae634a84 100644 --- a/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs +++ b/src/Modules/Providers/Application/ModuleApi/ProvidersModuleApi.cs @@ -16,7 +16,7 @@ namespace MeAjudaAi.Modules.Providers.Application.ModuleApi; /// /// Implementação da API pública do módulo Providers para outros módulos /// -[ModuleApi("Providers", "1.0")] +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] public sealed class ProvidersModuleApi( IQueryHandler> getProviderByIdHandler, IQueryHandler> getProviderByUserIdHandler, @@ -29,8 +29,14 @@ public sealed class ProvidersModuleApi( IServiceProvider serviceProvider, ILogger logger) : IProvidersModuleApi { - public string ModuleName => "Providers"; - public string ApiVersion => "1.0"; + private static class ModuleMetadata + { + public const string Name = "Providers"; + public const string Version = "1.0"; + } + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; public async Task IsAvailableAsync(CancellationToken cancellationToken = default) { diff --git a/src/Modules/Search/API/Properties/launchSettings.json b/src/Modules/Search/API/Properties/launchSettings.json deleted file mode 100644 index 527bb245b..000000000 --- a/src/Modules/Search/API/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "MeAjudaAi.Modules.Search.API": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:56415;http://localhost:56416" - } - } -} \ No newline at end of file diff --git a/src/Modules/Search/Application/ModuleApi/SearchModuleApi.cs b/src/Modules/Search/Application/ModuleApi/SearchModuleApi.cs index 137a52b4c..e03b30574 100644 --- a/src/Modules/Search/Application/ModuleApi/SearchModuleApi.cs +++ b/src/Modules/Search/Application/ModuleApi/SearchModuleApi.cs @@ -14,13 +14,19 @@ namespace MeAjudaAi.Modules.Search.Application.ModuleApi; /// /// Implementação da API pública do módulo Search para outros módulos. /// -[ModuleApi("Search", "1.0")] +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] public sealed class SearchModuleApi( IQueryDispatcher queryDispatcher, ILogger logger) : ISearchModuleApi { - public string ModuleName => "Search"; - public string ApiVersion => "1.0"; + private static class ModuleMetadata + { + public const string Name = "Search"; + public const string Version = "1.0"; + } + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; public async Task IsAvailableAsync(CancellationToken cancellationToken = default) { @@ -38,7 +44,14 @@ public async Task IsAvailableAsync(CancellationToken cancellationToken = d cancellationToken: cancellationToken); // Módulo está disponível se conseguiu executar a busca (mesmo que retorne 0 resultados) - logger.LogDebug("Search module is available and healthy"); + if (testResult.IsSuccess) + { + logger.LogDebug("Search module is available and healthy"); + } + else + { + logger.LogWarning("Search module test query failed"); + } return testResult.IsSuccess; } catch (OperationCanceledException) diff --git a/src/Modules/Users/Application/ModuleApi/UsersModuleApi.cs b/src/Modules/Users/Application/ModuleApi/UsersModuleApi.cs index 23863f385..875ff5153 100644 --- a/src/Modules/Users/Application/ModuleApi/UsersModuleApi.cs +++ b/src/Modules/Users/Application/ModuleApi/UsersModuleApi.cs @@ -14,7 +14,7 @@ namespace MeAjudaAi.Modules.Users.Application.ModuleApi; /// /// Implementação da API pública do módulo Users para outros módulos /// -[ModuleApi("Users", "1.0")] +[ModuleApi(ModuleMetadata.Name, ModuleMetadata.Version)] public sealed class UsersModuleApi( IQueryHandler> getUserByIdHandler, IQueryHandler> getUserByEmailHandler, @@ -23,8 +23,14 @@ public sealed class UsersModuleApi( IServiceProvider serviceProvider, ILogger logger) : IUsersModuleApi, IModuleApi { - public string ModuleName => "Users"; - public string ApiVersion => "1.0"; + private static class ModuleMetadata + { + public const string Name = "Users"; + public const string Version = "1.0"; + } + + public string ModuleName => ModuleMetadata.Name; + public string ApiVersion => ModuleMetadata.Version; private static readonly Guid HealthCheckUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); @@ -186,7 +192,7 @@ public async Task> UsernameExistsAsync(string username, Cancellatio return result.Match( onSuccess: _ => Result.Success(true), - onFailure: _ => Result.Success(false) + onFailure: _ => Result.Success(false) // Any error means user doesn't exist ); } } diff --git a/src/Modules/Users/Tests/Infrastructure/TestInfrastructureExtensions.cs b/src/Modules/Users/Tests/Infrastructure/TestInfrastructureExtensions.cs index d919c9415..08b1448db 100644 --- a/src/Modules/Users/Tests/Infrastructure/TestInfrastructureExtensions.cs +++ b/src/Modules/Users/Tests/Infrastructure/TestInfrastructureExtensions.cs @@ -40,7 +40,7 @@ public static IServiceCollection AddUsersTestInfrastructure( // Adicionar serviços de cache do Shared (incluindo ICacheService) // Para testes, usar implementação simples sem dependências complexas - services.AddSingleton(); + services.AddSingleton(); // Configurar banco de dados específico do módulo Users services.AddTestDatabase( diff --git a/src/Shared/Constants/ValidationConstants.cs b/src/Shared/Constants/ValidationConstants.cs index 78c6099c1..f66ea1472 100644 --- a/src/Shared/Constants/ValidationConstants.cs +++ b/src/Shared/Constants/ValidationConstants.cs @@ -62,4 +62,18 @@ public static class Pagination public const int MaxPageSize = 100; public const int MinPageSize = 1; } + + /// + /// Limites para entidades do módulo Catalogs + /// + public static class CatalogLimits + { + // Service Category + public const int ServiceCategoryNameMaxLength = 100; + public const int ServiceCategoryDescriptionMaxLength = 500; + + // Service + public const int ServiceNameMaxLength = 150; + public const int ServiceDescriptionMaxLength = 1000; + } } diff --git a/src/Shared/Contracts/Modules/Catalogs/DTOs/CatalogsModuleDtos.cs b/src/Shared/Contracts/Modules/Catalogs/DTOs/CatalogsModuleDtos.cs new file mode 100644 index 000000000..29e86b21c --- /dev/null +++ b/src/Shared/Contracts/Modules/Catalogs/DTOs/CatalogsModuleDtos.cs @@ -0,0 +1,43 @@ +namespace MeAjudaAi.Shared.Contracts.Modules.Catalogs.DTOs; + +/// +/// DTO for service category information exposed to other modules. +/// +public sealed record ModuleServiceCategoryDto( + Guid Id, + string Name, + string? Description, + bool IsActive, + int DisplayOrder +); + +/// +/// DTO for service information exposed to other modules. +/// +public sealed record ModuleServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + bool IsActive +); + +/// +/// Simplified service DTO for list operations. +/// +public sealed record ModuleServiceListDto( + Guid Id, + Guid CategoryId, + string Name, + bool IsActive +); + +/// +/// Result of service validation operation. +/// +public sealed record ModuleServiceValidationResultDto( + bool AllValid, + Guid[] InvalidServiceIds, + Guid[] InactiveServiceIds +); diff --git a/src/Shared/Contracts/Modules/Catalogs/ICatalogsModuleApi.cs b/src/Shared/Contracts/Modules/Catalogs/ICatalogsModuleApi.cs new file mode 100644 index 000000000..dcf8b6f3f --- /dev/null +++ b/src/Shared/Contracts/Modules/Catalogs/ICatalogsModuleApi.cs @@ -0,0 +1,70 @@ +using MeAjudaAi.Shared.Contracts.Modules.Catalogs.DTOs; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Shared.Contracts.Modules.Catalogs; + +/// +/// Public API contract for the Catalogs module. +/// Provides access to service categories and services catalog for other modules. +/// +public interface ICatalogsModuleApi : IModuleApi +{ + // ============ Service Categories ============ + + /// + /// Retrieves a service category by ID. + /// + Task> GetServiceCategoryByIdAsync( + Guid categoryId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all service categories. + /// + /// If true, returns only active categories + /// + Task>> GetAllServiceCategoriesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default); + + // ============ Services ============ + + /// + /// Retrieves a service by ID. + /// + Task> GetServiceByIdAsync( + Guid serviceId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all services. + /// + /// If true, returns only active services + /// + Task>> GetAllServicesAsync( + bool activeOnly = true, + CancellationToken cancellationToken = default); + + /// + /// Retrieves all services in a specific category. + /// + Task>> GetServicesByCategoryAsync( + Guid categoryId, + bool activeOnly = true, + CancellationToken cancellationToken = default); + + /// + /// Checks if a service exists and is active. + /// + Task> IsServiceActiveAsync( + Guid serviceId, + CancellationToken cancellationToken = default); + + /// + /// Validates if all provided service IDs exist and are active. + /// + /// Result containing validation outcome and list of invalid service IDs + Task> ValidateServicesAsync( + IReadOnlyCollection serviceIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Shared/Modules/ModuleApiInfo.cs b/src/Shared/Modules/ModuleApiInfo.cs new file mode 100644 index 000000000..42fbaf7ff --- /dev/null +++ b/src/Shared/Modules/ModuleApiInfo.cs @@ -0,0 +1,15 @@ +namespace MeAjudaAi.Shared.Modules; + +/// +/// Informações sobre uma Module API registrada. +/// +/// Nome do módulo +/// Versão da API +/// Tipo completo da implementação (formato: Namespace.TypeName, AssemblyName) +/// Indica se o módulo está disponível e saudável +public sealed record ModuleApiInfo( + string ModuleName, + string ApiVersion, + string ImplementationType, + bool IsAvailable +); diff --git a/src/Shared/Modules/ModuleApiRegistry.cs b/src/Shared/Modules/ModuleApiRegistry.cs index f311d54ba..72696070b 100644 --- a/src/Shared/Modules/ModuleApiRegistry.cs +++ b/src/Shared/Modules/ModuleApiRegistry.cs @@ -71,13 +71,3 @@ public static async Task> GetRegisteredModulesAsync return moduleInfos; } } - -/// -/// Informações sobre uma Module API -/// -public sealed record ModuleApiInfo( - string ModuleName, - string ApiVersion, - string ImplementationType, - bool IsAvailable -); diff --git a/tests/MeAjudaAi.Architecture.Tests/ModuleApiArchitectureTests.cs b/tests/MeAjudaAi.Architecture.Tests/ModuleApiArchitectureTests.cs index d48069893..ff612f97a 100644 --- a/tests/MeAjudaAi.Architecture.Tests/ModuleApiArchitectureTests.cs +++ b/tests/MeAjudaAi.Architecture.Tests/ModuleApiArchitectureTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using MeAjudaAi.Shared.Contracts.Modules; +using MeAjudaAi.Shared.Contracts.Modules.Catalogs; using MeAjudaAi.Shared.Contracts.Modules.Location; using MeAjudaAi.Shared.Contracts.Modules.Providers; using MeAjudaAi.Shared.Contracts.Modules.Users; @@ -286,6 +287,28 @@ public void ILocationModuleApi_ShouldHaveAllEssentialMethods() methods.Should().Contain("GetCoordinatesFromAddressAsync", because: "Should allow geocoding addresses"); } + [Fact] + public void ICatalogsModuleApi_ShouldHaveAllEssentialMethods() + { + // Arrange + var type = typeof(ICatalogsModuleApi); + + // Act + var methods = type.GetMethods() + .Where(m => !m.IsSpecialName && m.DeclaringType == type) + .Select(m => m.Name) + .ToList(); + + // Assert + methods.Should().Contain("GetServiceCategoryByIdAsync", because: "Should allow getting service category by ID"); + methods.Should().Contain("GetAllServiceCategoriesAsync", because: "Should allow getting all service categories"); + methods.Should().Contain("GetServiceByIdAsync", because: "Should allow getting service by ID"); + methods.Should().Contain("GetAllServicesAsync", because: "Should allow getting all services"); + methods.Should().Contain("GetServicesByCategoryAsync", because: "Should allow getting services by category"); + methods.Should().Contain("ValidateServicesAsync", because: "Should allow validating services"); + methods.Should().Contain("IsServiceActiveAsync", because: "Should allow checking if service is active"); + } + private static Assembly[] GetModuleAssemblies() { // Obtém todos os assemblies que possuem implementações de Module API diff --git a/tests/MeAjudaAi.E2E.Tests/Integration/CatalogsModuleIntegrationTests.cs b/tests/MeAjudaAi.E2E.Tests/Integration/CatalogsModuleIntegrationTests.cs new file mode 100644 index 000000000..ab8b90a86 --- /dev/null +++ b/tests/MeAjudaAi.E2E.Tests/Integration/CatalogsModuleIntegrationTests.cs @@ -0,0 +1,268 @@ +using System.Net; +using System.Text.Json; +using MeAjudaAi.E2E.Tests.Base; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; + +namespace MeAjudaAi.E2E.Tests.Integration; + +/// +/// Testes de integração entre o módulo Catalogs e outros módulos +/// Demonstra como o módulo de catálogos pode ser consumido por outros módulos +/// +public class CatalogsModuleIntegrationTests : TestContainerTestBase +{ + [Fact] + public async Task ServicesModule_Can_Validate_Services_From_Catalogs() + { + // Arrange - Create test service categories and services + AuthenticateAsAdmin(); + var category = await CreateServiceCategoryAsync("Limpeza", "Serviços de limpeza"); + var service1 = await CreateServiceAsync(category.Id, "Limpeza de Piscina", "Limpeza completa"); + var service2 = await CreateServiceAsync(category.Id, "Limpeza de Jardim", "Manutenção de jardim"); + + // Act - Services module would validate service IDs by querying individual services + var response1 = await ApiClient.GetAsync($"/api/v1/catalogs/services/{service1.Id}"); + var response2 = await ApiClient.GetAsync($"/api/v1/catalogs/services/{service2.Id}"); + + // Assert - Both services should exist + response1.StatusCode.Should().Be(HttpStatusCode.OK, "service1 should exist"); + response2.StatusCode.Should().Be(HttpStatusCode.OK, "service2 should exist"); + + var content1 = await response1.Content.ReadAsStringAsync(); + var result1 = JsonSerializer.Deserialize(content1, JsonOptions); + result1.TryGetProperty("data", out var data1).Should().BeTrue(); + data1.TryGetProperty("id", out var id1).Should().BeTrue(); + id1.GetGuid().Should().Be(service1.Id); + + var content2 = await response2.Content.ReadAsStringAsync(); + var result2 = JsonSerializer.Deserialize(content2, JsonOptions); + result2.TryGetProperty("data", out var data2).Should().BeTrue(); + data2.TryGetProperty("id", out var id2).Should().BeTrue(); + id2.GetGuid().Should().Be(service2.Id); + } + + [Fact] + public async Task ProvidersModule_Can_Query_Active_Services_Only() + { + // Arrange - Create services with different states + AuthenticateAsAdmin(); + var category = await CreateServiceCategoryAsync("Manutenção", "Serviços de manutenção"); + var activeService = await CreateServiceAsync(category.Id, "Manutenção Elétrica", "Serviços elétricos"); + var inactiveService = await CreateServiceAsync(category.Id, "Manutenção Antiga", "Serviço descontinuado"); + + // Deactivate one service + await PostJsonAsync($"/api/v1/catalogs/services/{inactiveService.Id}/deactivate", new { }); + + // Act - Query only active services + var response = await ApiClient.GetAsync("/api/v1/catalogs/services?activeOnly=true"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + + result.TryGetProperty("data", out var data).Should().BeTrue(); + var services = data.Deserialize(JsonOptions); + services.Should().NotBeNull(); + services!.Should().Contain(s => s.Id == activeService.Id); + services!.Should().NotContain(s => s.Id == inactiveService.Id); + } + + [Fact] + public async Task RequestsModule_Can_Filter_Services_By_Category() + { + // Arrange - Create multiple categories and services + AuthenticateAsAdmin(); + var category1 = await CreateServiceCategoryAsync("Limpeza", "Limpeza geral"); + var category2 = await CreateServiceCategoryAsync("Reparos", "Reparos diversos"); + + var service1 = await CreateServiceAsync(category1.Id, "Limpeza de Casa", "Limpeza residencial"); + var service2 = await CreateServiceAsync(category1.Id, "Limpeza de Escritório", "Limpeza comercial"); + var service3 = await CreateServiceAsync(category2.Id, "Reparo de Torneira", "Hidráulica"); + + // Act - Filter services by category (Requests module would do this) + var response = await ApiClient.GetAsync($"/api/v1/catalogs/services/category/{category1.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + + result.TryGetProperty("data", out var data).Should().BeTrue(); + var services = data.Deserialize(JsonOptions); + services.Should().NotBeNull(); + services!.Length.Should().Be(2); + services!.Should().AllSatisfy(s => s.CategoryId.Should().Be(category1.Id)); + } + + [Fact] + public async Task MultipleModules_Can_Read_Same_ServiceCategory_Concurrently() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateServiceCategoryAsync("Popular Service", "Very popular category"); + + // Act - Simulate multiple modules reading the same category concurrently + var tasks = Enumerable.Range(0, 10).Select(async _ => + { + var response = await ApiClient.GetAsync($"/api/v1/catalogs/categories/{category.Id}"); + return response; + }); + + var responses = await Task.WhenAll(tasks); + + // Assert - All requests should succeed + responses.Should().AllSatisfy(r => r.StatusCode.Should().Be(HttpStatusCode.OK)); + } + + [Fact] + public async Task Dashboard_Module_Can_Get_All_Categories_For_Statistics() + { + // Arrange - Create diverse categories + AuthenticateAsAdmin(); + await CreateServiceCategoryAsync("Limpeza", "Serviços de limpeza"); + await CreateServiceCategoryAsync("Reparos", "Serviços de reparo"); + await CreateServiceCategoryAsync("Jardinagem", "Serviços de jardim"); + + // Act - Dashboard module gets all categories for statistics + var response = await ApiClient.GetAsync("/api/v1/catalogs/categories"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + + result.TryGetProperty("data", out var data).Should().BeTrue(); + var categories = data.Deserialize(JsonOptions); + categories.Should().NotBeNull(); + categories!.Length.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task Admin_Module_Can_Manage_Service_Lifecycle() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateServiceCategoryAsync("Temporário", "Categoria temporária"); + var service = await CreateServiceAsync(category.Id, "Serviço Teste", "Para testes"); + + // Act & Assert - Full lifecycle management + + // 1. Update service + var updateRequest = new + { + Name = "Serviço Atualizado", + Description = "Descrição atualizada", + DisplayOrder = 10 + }; + var updateResponse = await PutJsonAsync($"/api/v1/catalogs/services/{service.Id}", updateRequest); + updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // 2. Deactivate service + var deactivateResponse = await PostJsonAsync($"/api/v1/catalogs/services/{service.Id}/deactivate", new { }); + deactivateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // 3. Verify service is inactive + var checkResponse = await ApiClient.GetAsync($"/api/v1/catalogs/services/{service.Id}"); + checkResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var checkContent = await checkResponse.Content.ReadAsStringAsync(); + var checkResult = JsonSerializer.Deserialize(checkContent, JsonOptions); + checkResult.TryGetProperty("data", out var serviceData).Should().BeTrue(); + serviceData.TryGetProperty("isActive", out var isActiveProperty).Should().BeTrue(); + isActiveProperty.GetBoolean().Should().BeFalse(); + + // 4. Delete service (should work now that it's inactive) + var deleteResponse = await ApiClient.DeleteAsync($"/api/v1/catalogs/services/{service.Id}"); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + #region Helper Methods + + private async Task CreateServiceCategoryAsync(string name, string description) + { + var request = new + { + Name = name, + Description = description, + DisplayOrder = 1 + }; + + var response = await PostJsonAsync("/api/v1/catalogs/categories", request); + + if (response.StatusCode != HttpStatusCode.Created) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Failed to create service category. Status: {response.StatusCode}, Content: {errorContent}"); + } + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + result.TryGetProperty("data", out var data).Should().BeTrue(); + + return data.Deserialize(JsonOptions)!; + } + + private async Task CreateServiceAsync(Guid categoryId, string name, string description) + { + var request = new + { + CategoryId = categoryId, + Name = name, + Description = description, + DisplayOrder = 1 + }; + + var response = await PostJsonAsync("/api/v1/catalogs/services", request); + + if (response.StatusCode != HttpStatusCode.Created) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Failed to create service. Status: {response.StatusCode}, Content: {errorContent}"); + } + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + result.TryGetProperty("data", out var data).Should().BeTrue(); + + return data.Deserialize(JsonOptions)!; + } + + #endregion + + #region DTOs + + private record ServiceCategoryDto( + Guid Id, + string Name, + string? Description, + int DisplayOrder, + bool IsActive, + DateTime CreatedAt, + DateTime? UpdatedAt + ); + + private record ServiceDto( + Guid Id, + Guid CategoryId, + string CategoryName, + string Name, + string? Description, + int DisplayOrder, + bool IsActive, + DateTime CreatedAt, + DateTime? UpdatedAt + ); + + private record ServiceListDto( + Guid Id, + Guid CategoryId, + string Name, + string? Description, + bool IsActive + ); + + #endregion +} diff --git a/tests/MeAjudaAi.E2E.Tests/Modules/Catalogs/CatalogsEndToEndTests.cs b/tests/MeAjudaAi.E2E.Tests/Modules/Catalogs/CatalogsEndToEndTests.cs new file mode 100644 index 000000000..1dcec868d --- /dev/null +++ b/tests/MeAjudaAi.E2E.Tests/Modules/Catalogs/CatalogsEndToEndTests.cs @@ -0,0 +1,396 @@ +using System.Text.Json; +using MeAjudaAi.E2E.Tests.Base; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Modules.Catalogs.Domain.Entities; +using MeAjudaAi.Modules.Catalogs.Domain.ValueObjects; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.E2E.Tests.Modules.Catalogs; + +/// +/// Testes E2E para o módulo de Catálogos usando TestContainers +/// +public class CatalogsEndToEndTests : TestContainerTestBase +{ + [Fact] + public async Task CreateServiceCategory_Should_Return_Success() + { + // Arrange + AuthenticateAsAdmin(); + + var createCategoryRequest = new + { + Name = Faker.Commerce.Department(), + Description = Faker.Lorem.Sentence(), + DisplayOrder = Faker.Random.Int(1, 100) + }; + + // Act + var response = await PostJsonAsync("/api/v1/catalogs/categories", createCategoryRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var locationHeader = response.Headers.Location?.ToString(); + locationHeader.Should().NotBeNull(); + locationHeader.Should().Contain("/api/v1/catalogs/categories"); + } + + [Fact] + public async Task GetServiceCategories_Should_Return_All_Categories() + { + // Arrange + AuthenticateAsAdmin(); + await CreateTestServiceCategoriesAsync(3); + + // Act + var response = await ApiClient.GetAsync("/api/v1/catalogs/categories"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + result.TryGetProperty("data", out var data).Should().BeTrue(); + + var categories = data.Deserialize(JsonOptions); + categories.Should().NotBeNull(); + categories!.Length.Should().BeGreaterThanOrEqualTo(3, "should have at least the 3 created categories"); + } + + [Fact] + public async Task CreateService_Should_Succeed_With_Valid_Category() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + + var createServiceRequest = new + { + CategoryId = category.Id.Value, + Name = Faker.Commerce.ProductName(), + Description = Faker.Commerce.ProductDescription(), + DisplayOrder = Faker.Random.Int(1, 100) + }; + + // Act + var response = await PostJsonAsync("/api/v1/catalogs/services", createServiceRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var locationHeader = response.Headers.Location?.ToString(); + locationHeader.Should().NotBeNull(); + locationHeader.Should().Contain("/api/v1/catalogs/services"); + } + + [Fact] + public async Task CreateService_Should_Reject_Invalid_CategoryId() + { + // Arrange + AuthenticateAsAdmin(); + var nonExistentCategoryId = Guid.NewGuid(); + + var createServiceRequest = new + { + CategoryId = nonExistentCategoryId, + Name = Faker.Commerce.ProductName(), + Description = Faker.Commerce.ProductDescription(), + DisplayOrder = Faker.Random.Int(1, 100) + }; + + // Act + var response = await PostJsonAsync("/api/v1/catalogs/services", createServiceRequest); + + // Assert - Should reject with BadRequest or NotFound + response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetServicesByCategory_Should_Return_Filtered_Results() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + await CreateTestServicesAsync(category.Id.Value, 3); + + // Act + var response = await ApiClient.GetAsync($"/api/v1/catalogs/services/category/{category.Id.Value}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(content, JsonOptions); + result.TryGetProperty("data", out var data).Should().BeTrue(); + + var services = data.Deserialize(JsonOptions); + services.Should().NotBeNull(); + services!.Length.Should().Be(3, "should return exactly 3 services for this category"); + services.Should().OnlyContain(s => s.CategoryId == category.Id.Value, "all services should belong to the specified category"); + } + + [Fact] + public async Task UpdateServiceCategory_Should_Modify_Existing_Category() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + + var updateRequest = new + { + Name = "Updated " + Faker.Commerce.Department(), + Description = "Updated " + Faker.Lorem.Sentence(), + DisplayOrder = Faker.Random.Int(1, 100) + }; + + // Act + var response = await PutJsonAsync($"/api/v1/catalogs/categories/{category.Id.Value}", updateRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify the category was actually updated + var getResponse = await ApiClient.GetAsync($"/api/v1/catalogs/categories/{category.Id.Value}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var updatedCategory = await ReadJsonAsync(getResponse); + updatedCategory.Should().NotBeNull(); + updatedCategory!.Name.Should().Be(updateRequest.Name); + updatedCategory.Description.Should().Be(updateRequest.Description); + updatedCategory.DisplayOrder.Should().Be(updateRequest.DisplayOrder); + } + + [Fact] + public async Task DeleteServiceCategory_Should_Fail_If_Has_Services() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + await CreateTestServicesAsync(category.Id.Value, 1); + + // Act + var response = await ApiClient.DeleteAsync($"/api/v1/catalogs/categories/{category.Id.Value}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + // Category should still exist after failed delete + var getResponse = await ApiClient.GetAsync($"/api/v1/catalogs/categories/{category.Id.Value}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task DeleteServiceCategory_Should_Succeed_When_No_Services() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + + // Act + var response = await ApiClient.DeleteAsync($"/api/v1/catalogs/categories/{category.Id.Value}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Verify category was deleted + var getResponse = await ApiClient.GetAsync($"/api/v1/catalogs/categories/{category.Id.Value}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ActivateDeactivate_Service_Should_Work_Correctly() + { + // Arrange + AuthenticateAsAdmin(); + var category = await CreateTestServiceCategoryAsync(); + var service = await CreateTestServiceAsync(category.Id.Value); + + // Act - Deactivate + var deactivateResponse = await PostJsonAsync($"/api/v1/catalogs/services/{service.Id.Value}/deactivate", new { }); + deactivateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Assert - Verify service is inactive + var getAfterDeactivate = await ApiClient.GetAsync($"/api/v1/catalogs/services/{service.Id.Value}"); + getAfterDeactivate.StatusCode.Should().Be(HttpStatusCode.OK); + var deactivatedService = await ReadJsonAsync(getAfterDeactivate); + deactivatedService.Should().NotBeNull(); + deactivatedService!.IsActive.Should().BeFalse("service should be inactive after deactivate"); + + // Act - Activate + var activateResponse = await PostJsonAsync($"/api/v1/catalogs/services/{service.Id.Value}/activate", new { }); + activateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Assert - Verify service is active again + var getAfterActivate = await ApiClient.GetAsync($"/api/v1/catalogs/services/{service.Id.Value}"); + getAfterActivate.StatusCode.Should().Be(HttpStatusCode.OK); + var activatedService = await ReadJsonAsync(getAfterActivate); + activatedService.Should().NotBeNull(); + activatedService!.IsActive.Should().BeTrue("service should be active after activate"); + } + + [Fact] + public async Task Database_Should_Persist_ServiceCategories_Correctly() + { + // Arrange + var name = Faker.Commerce.Department(); + var description = Faker.Lorem.Sentence(); + ServiceCategoryId? categoryId = null; + + // Act - Create category directly in database + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + var category = ServiceCategory.Create(name, description, 1); + categoryId = category.Id; + + context.ServiceCategories.Add(category); + await context.SaveChangesAsync(); + }); + + // Assert - Verify category was persisted by ID for determinism + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + var foundCategory = await context.ServiceCategories + .FirstOrDefaultAsync(c => c.Id == categoryId); + + foundCategory.Should().NotBeNull(); + foundCategory!.Name.Should().Be(name); + foundCategory.Description.Should().Be(description); + }); + } + + [Fact] + public async Task Database_Should_Persist_Services_With_Category_Relationship() + { + // Arrange + ServiceCategory? category = null; + var serviceName = Faker.Commerce.ProductName(); + ServiceId? serviceId = null; + + // Act - Create category and service + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + category = ServiceCategory.Create(Faker.Commerce.Department(), Faker.Lorem.Sentence(), 1); + context.ServiceCategories.Add(category); + await context.SaveChangesAsync(); + + var service = Service.Create(category.Id, serviceName, Faker.Commerce.ProductDescription(), 1); + serviceId = service.Id; + context.Services.Add(service); + await context.SaveChangesAsync(); + }); + + // Assert - Verify service and category relationship by ID for determinism + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + var foundService = await context.Services + .FirstOrDefaultAsync(s => s.Id == serviceId); + + foundService.Should().NotBeNull(); + foundService!.Name.Should().Be(serviceName); + foundService.CategoryId.Should().Be(category!.Id); + }); + } + + #region Helper Methods + + private async Task CreateTestServiceCategoryAsync() + { + ServiceCategory? category = null; + + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + category = ServiceCategory.Create( + Faker.Commerce.Department(), + Faker.Lorem.Sentence(), + Faker.Random.Int(1, 100) + ); + + context.ServiceCategories.Add(category); + await context.SaveChangesAsync(); + }); + + return category!; + } + + private async Task CreateTestServiceCategoriesAsync(int count) + { + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + for (int i = 0; i < count; i++) + { + var category = ServiceCategory.Create( + Faker.Commerce.Department() + $" {i}", + Faker.Lorem.Sentence(), + i + 1 + ); + + context.ServiceCategories.Add(category); + } + + await context.SaveChangesAsync(); + }); + } + + private async Task CreateTestServiceAsync(Guid categoryId) + { + Service? service = null; + + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + service = Service.Create( + new ServiceCategoryId(categoryId), + Faker.Commerce.ProductName(), + Faker.Commerce.ProductDescription(), + Faker.Random.Int(1, 100) + ); + + context.Services.Add(service); + await context.SaveChangesAsync(); + }); + + return service!; + } + + private async Task CreateTestServicesAsync(Guid categoryId, int count) + { + await WithServiceScopeAsync(async services => + { + var context = services.GetRequiredService(); + + for (int i = 0; i < count; i++) + { + var service = Service.Create( + new ServiceCategoryId(categoryId), + Faker.Commerce.ProductName() + $" {i}", + Faker.Commerce.ProductDescription(), + i + 1 + ); + + context.Services.Add(service); + } + + await context.SaveChangesAsync(); + }); + } + + #endregion +} diff --git a/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs b/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs index 476b4aaed..073f3b5fe 100644 --- a/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs +++ b/tests/MeAjudaAi.Integration.Tests/Base/ApiTestBase.cs @@ -1,5 +1,6 @@ using System.Text.Json; using MeAjudaAi.Integration.Tests.Infrastructure; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; using MeAjudaAi.Modules.Documents.Infrastructure.Persistence; using MeAjudaAi.Modules.Documents.Tests; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; @@ -50,6 +51,7 @@ public async ValueTask InitializeAsync() RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); + RemoveDbContextRegistrations(services); // Adiciona contextos de banco de dados para testes services.AddDbContext(options => @@ -88,6 +90,19 @@ public async ValueTask InitializeAsync() warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); + services.AddDbContext(options => + { + options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Catalogs.Infrastructure"); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "catalogs"); + }); + options.UseSnakeCaseNamingConvention(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + }); + // Adiciona mocks de serviços para testes services.AddDocumentsTestServices(); @@ -125,16 +140,18 @@ public async ValueTask InitializeAsync() var usersContext = scope.ServiceProvider.GetRequiredService(); var providersContext = scope.ServiceProvider.GetRequiredService(); var documentsContext = scope.ServiceProvider.GetRequiredService(); + var catalogsContext = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetService>(); // Aplica migrações exatamente como nos testes E2E - await ApplyMigrationsAsync(usersContext, providersContext, documentsContext, logger); + await ApplyMigrationsAsync(usersContext, providersContext, documentsContext, catalogsContext, logger); } private static async Task ApplyMigrationsAsync( UsersDbContext usersContext, ProvidersDbContext providersContext, DocumentsDbContext documentsContext, + CatalogsDbContext catalogsContext, ILogger? logger) { // Garante estado limpo do banco de dados (como nos testes E2E) @@ -153,11 +170,13 @@ private static async Task ApplyMigrationsAsync( await ApplyMigrationForContextAsync(usersContext, "Users", logger, "UsersDbContext primeiro (cria database e schema users)"); await ApplyMigrationForContextAsync(providersContext, "Providers", logger, "ProvidersDbContext (banco já existe, só precisa do schema providers)"); await ApplyMigrationForContextAsync(documentsContext, "Documents", logger, "DocumentsDbContext (banco já existe, só precisa do schema documents)"); + await ApplyMigrationForContextAsync(catalogsContext, "Catalogs", logger, "CatalogsDbContext (banco já existe, só precisa do schema catalogs)"); // Verifica se as tabelas existem await VerifyContextAsync(usersContext, "Users", () => usersContext.Users.CountAsync(), logger); await VerifyContextAsync(providersContext, "Providers", () => providersContext.Providers.CountAsync(), logger); await VerifyContextAsync(documentsContext, "Documents", () => documentsContext.Documents.CountAsync(), logger); + await VerifyContextAsync(catalogsContext, "Catalogs", () => catalogsContext.ServiceCategories.CountAsync(), logger); } public async ValueTask DisposeAsync() diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsApiTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsApiTests.cs new file mode 100644 index 000000000..1461b398d --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsApiTests.cs @@ -0,0 +1,268 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; + +namespace MeAjudaAi.Integration.Tests.Modules.Catalogs; + +/// +/// Testes de integração para a API do módulo Catalogs. +/// Valida endpoints, autenticação, autorização e respostas da API. +/// +public class CatalogsApiTests : ApiTestBase +{ + [Fact] + public async Task ServiceCategoriesEndpoint_ShouldBeAccessible() + { + // Act + var response = await Client.GetAsync("/api/v1/catalogs/categories"); + + // Assert - Endpoint should exist (not 404) and not crash (not 500) + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound, "Endpoint should be registered"); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed, "GET should be allowed"); + response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError, "Endpoint should not crash"); + + // May return Unauthorized (401) or Forbidden (403) if auth is required, or OK (200) + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + HttpStatusCode.OK); + } + + [Fact] + public async Task ServicesEndpoint_ShouldBeAccessible() + { + // Act + var response = await Client.GetAsync("/api/v1/catalogs/services"); + + // Assert + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError); + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + HttpStatusCode.OK); + } + + [Fact] + public async Task ServiceCategoriesEndpoint_WithAuthentication_ShouldReturnValidResponse() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // Act + var response = await Client.GetAsync("/api/v1/catalogs/categories"); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // Log error details if not successful + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"Status: {response.StatusCode}"); + Console.WriteLine($"Content: {content}"); + } + + response.StatusCode.Should().Be(HttpStatusCode.OK, + $"Admin users should receive a successful response. Error: {content}"); + + var categories = JsonSerializer.Deserialize(content); + + // Expect a consistent API response format + categories.ValueKind.Should().Be(JsonValueKind.Object, + "API should return a structured response object"); + categories.TryGetProperty("data", out var dataElement).Should().BeTrue( + "Response should contain 'data' property for consistency"); + dataElement.ValueKind.Should().BeOneOf(JsonValueKind.Array, JsonValueKind.Object); + } + + [Fact] + public async Task ServicesEndpoint_WithAuthentication_ShouldReturnValidResponse() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // Act + var response = await Client.GetAsync("/api/v1/catalogs/services"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK, + "Admin users should receive a successful response"); + + var content = await response.Content.ReadAsStringAsync(); + var services = JsonSerializer.Deserialize(content); + + // Expect a consistent API response format + services.ValueKind.Should().Be(JsonValueKind.Object, + "API should return a structured response object"); + services.TryGetProperty("data", out var dataElement).Should().BeTrue( + "Response should contain 'data' property for consistency"); + dataElement.ValueKind.Should().BeOneOf(JsonValueKind.Array, JsonValueKind.Object); + } + + [Fact] + public async Task GetServiceCategoryById_WithNonExistentId_ShouldReturnNotFound() + { + // Arrange + AuthConfig.ConfigureAdmin(); + var randomId = Guid.NewGuid(); + + // Act + var response = await Client.GetAsync($"/api/v1/catalogs/categories/{randomId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "API should return 404 when service category ID does not exist"); + } + + [Fact] + public async Task GetServiceById_WithNonExistentId_ShouldReturnNotFound() + { + // Arrange + AuthConfig.ConfigureAdmin(); + var randomId = Guid.NewGuid(); + + // Act + var response = await Client.GetAsync($"/api/v1/catalogs/services/{randomId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "API should return 404 when service ID does not exist"); + } + + [Fact] + public async Task CreateServiceCategory_WithValidData_ShouldReturnCreated() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var categoryData = new + { + name = $"Test Category {Guid.NewGuid():N}", + description = "Test Description", + displayOrder = 1 + }; + + // Act + var response = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.Created, + $"POST requests that create resources should return 201 Created. Response: {content}"); + + var responseJson = JsonSerializer.Deserialize(content); + var dataElement = GetResponseData(responseJson); + dataElement.TryGetProperty("id", out _).Should().BeTrue( + $"Response data should contain 'id' property. Full response: {content}"); + dataElement.TryGetProperty("name", out var nameProperty).Should().BeTrue(); + nameProperty.GetString().Should().Be(categoryData.name); + + // Cleanup + if (dataElement.TryGetProperty("id", out var idProperty)) + { + var categoryId = idProperty.GetString(); + await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + } + } + + [Fact] + public async Task CreateService_WithValidData_ShouldReturnCreated() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // First create a category + var categoryData = new + { + name = $"Test Category {Guid.NewGuid():N}", + description = "Test Description", + displayOrder = 1 + }; + + var categoryResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + categoryResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var categoryContent = await categoryResponse.Content.ReadAsStringAsync(); + var categoryJson = JsonSerializer.Deserialize(categoryContent); + var categoryDataElement = GetResponseData(categoryJson); + categoryDataElement.TryGetProperty("id", out var categoryIdProperty).Should().BeTrue(); + var categoryId = categoryIdProperty.GetString()!; + + try + { + // Now create a service + var serviceData = new + { + name = $"Test Service {Guid.NewGuid():N}", + description = "Test Service Description", + categoryId = categoryId + }; + + // Act + var response = await Client.PostAsJsonAsync("/api/v1/catalogs/services", serviceData); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.Created, + $"POST requests that create resources should return 201 Created. Response: {content}"); + + var responseJson = JsonSerializer.Deserialize(content); + var dataElement = GetResponseData(responseJson); + dataElement.TryGetProperty("id", out _).Should().BeTrue( + $"Response data should contain 'id' property. Full response: {content}"); + dataElement.TryGetProperty("name", out var nameProperty).Should().BeTrue(); + nameProperty.GetString().Should().Be(serviceData.name); + + // Cleanup service + if (dataElement.TryGetProperty("id", out var serviceIdProperty)) + { + var serviceId = serviceIdProperty.GetString(); + await Client.DeleteAsync($"/api/v1/catalogs/services/{serviceId}"); + } + } + finally + { + // Cleanup category + await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + } + } + + [Fact] + public async Task CatalogsEndpoints_AdminUser_ShouldNotReturnAuthorizationOrServerErrors() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var endpoints = new[] + { + "/api/v1/catalogs/categories", + "/api/v1/catalogs/services" + }; + + // Act & Assert + foreach (var endpoint in endpoints) + { + var response = await Client.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Endpoint {endpoint} returned {response.StatusCode}. Body: {body}"); + } + + response.StatusCode.Should().Be(HttpStatusCode.OK, + $"Authenticated admin requests to {endpoint} should succeed."); + } + } + + private static JsonElement GetResponseData(JsonElement response) + { + return response.TryGetProperty("data", out var dataElement) + ? dataElement + : response; + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDbContextTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDbContextTests.cs new file mode 100644 index 000000000..2cc545ef4 --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDbContextTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; +using MeAjudaAi.Modules.Catalogs.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Integration.Tests.Modules.Catalogs; + +/// +/// Testes de integração para o DbContext do módulo Catalogs. +/// Valida configurações do EF Core, relacionamentos e constraints. +/// +public class CatalogsDbContextTests : ApiTestBase +{ + [Fact] + public void CatalogsDbContext_ShouldBeRegistered() + { + // Arrange & Act + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetService(); + + // Assert + dbContext.Should().NotBeNull("CatalogsDbContext should be registered in DI"); + } + + [Fact] + public async Task ServiceCategories_Table_ShouldExist() + { + // Arrange + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Act + var canConnect = await dbContext.Database.CanConnectAsync(); + + // Assert + canConnect.Should().BeTrue("Database should be accessible"); + + // Check if we can query the table (will throw if table doesn't exist) + var count = await dbContext.ServiceCategories.CountAsync(); + count.Should().BeGreaterThanOrEqualTo(0, "ServiceCategories table should exist"); + } + + [Fact] + public async Task Services_Table_ShouldExist() + { + // Arrange + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Act + var canConnect = await dbContext.Database.CanConnectAsync(); + + // Assert + canConnect.Should().BeTrue("Database should be accessible"); + + // Check if we can query the table + var count = await dbContext.Services.CountAsync(); + count.Should().BeGreaterThanOrEqualTo(0, "Services table should exist"); + } + + [Fact] + public void Services_ShouldHaveForeignKeyToServiceCategories() + { + // Arrange + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Act + var serviceEntity = dbContext.Model.FindEntityType(typeof(MeAjudaAi.Modules.Catalogs.Domain.Entities.Service)); + var foreignKeys = serviceEntity?.GetForeignKeys(); + + // Assert + foreignKeys.Should().NotBeNull(); + foreignKeys.Should().NotBeEmpty("Services table should have foreign key constraint to ServiceCategories"); + } + + [Fact] + public void CatalogsSchema_ShouldExist() + { + // Arrange + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Act + var defaultSchema = dbContext.Model.GetDefaultSchema(); + + // Assert + defaultSchema.Should().Be("catalogs", "Catalogs schema should exist in database"); + } + + [Fact] + public async Task Database_ShouldAllowBasicOperations() + { + // Arrange + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Act & Assert - Should be able to execute queries + var canConnect = await dbContext.Database.CanConnectAsync(); + canConnect.Should().BeTrue("Should be able to connect to database"); + + // Should be able to begin transaction + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + transaction.Should().NotBeNull("Should be able to begin transaction"); + await transaction.RollbackAsync(); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDependencyInjectionTest.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDependencyInjectionTest.cs new file mode 100644 index 000000000..9f0c20695 --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsDependencyInjectionTest.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; +using MeAjudaAi.Modules.Catalogs.Application.Commands.Service; +using MeAjudaAi.Modules.Catalogs.Application.Commands.ServiceCategory; +using MeAjudaAi.Modules.Catalogs.Application.DTOs; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Integration.Tests.Modules.Catalogs; + +/// +/// 🧪 TESTE DIAGNÓSTICO PARA CATALOGS MODULE DEPENDENCY INJECTION +/// +/// Verifica se todos os command handlers do módulo Catalogs estão registrados +/// +public class CatalogsDependencyInjectionTest(ITestOutputHelper testOutput) : ApiTestBase +{ + [Fact] + public void Should_Have_CommandDispatcher_Registered() + { + // Arrange & Act + var commandDispatcher = Services.GetService(); + + // Assert + testOutput.WriteLine($"CommandDispatcher registration: {commandDispatcher != null}"); + commandDispatcher.Should().NotBeNull("ICommandDispatcher should be registered"); + } + + [Fact] + public void Should_Have_CreateServiceCategoryCommandHandler_Registered() + { + // Arrange & Act + // Try to resolve handler + var handler = Services.GetService>>(); + + // Assert + testOutput.WriteLine($"CreateServiceCategoryCommandHandler registration: {handler != null}"); + testOutput.WriteLine($"Handler type: {handler?.GetType().FullName}"); + handler.Should().NotBeNull("CreateServiceCategoryCommandHandler should be registered"); + } + + [Fact] + public void Should_Have_ServiceCategoryRepository_Registered() + { + // Arrange & Act + var repository = Services.GetService(); + + // Assert + testOutput.WriteLine($"IServiceCategoryRepository registration: {repository != null}"); + testOutput.WriteLine($"Repository type: {repository?.GetType().FullName}"); + repository.Should().NotBeNull("IServiceCategoryRepository should be registered"); + } + + [Fact] + public void Should_Have_CatalogsDbContext_Registered() + { + // Arrange & Act + var dbContext = Services.GetService(); + + // Assert + testOutput.WriteLine($"CatalogsDbContext registration: {dbContext != null}"); + dbContext.Should().NotBeNull("CatalogsDbContext should be registered"); + } + + [Fact] + public void Should_List_All_Registered_CommandHandlers() + { + // Arrange - Scan Catalogs assembly for command handler types + var catalogsAssembly = typeof(CreateServiceCategoryCommand).Assembly; + var commandHandlerType = typeof(ICommandHandler<,>); + + // Act - Find all types that implement ICommandHandler<,> + var handlerTypes = catalogsAssembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == commandHandlerType)) + .ToList(); + + testOutput.WriteLine($"Found {handlerTypes.Count} command handler types in Catalogs assembly:"); + + // Assert - Verify each handler can be resolved from DI + handlerTypes.Should().NotBeEmpty("Catalogs assembly should contain command handlers"); + + foreach (var handlerType in handlerTypes) + { + // Get the ICommandHandler interface this type implements + var handlerInterface = handlerType.GetInterfaces() + .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == commandHandlerType); + + testOutput.WriteLine($"- {handlerType.Name} implements {handlerInterface.Name}"); + + // Verify the handler can be resolved from DI + var resolvedHandler = Services.GetService(handlerInterface); + resolvedHandler.Should().NotBeNull($"{handlerType.Name} should be registered in DI container"); + } + + testOutput.WriteLine($"\n✅ All {handlerTypes.Count} command handlers are properly registered"); + } + + [Fact] + public async Task Should_Be_Able_To_Resolve_And_Execute_CreateServiceCategoryCommandHandler() + { + // Arrange + var commandDispatcher = Services.GetRequiredService(); + var command = new CreateServiceCategoryCommand( + Name: $"Test Category {Guid.NewGuid():N}", + Description: "Test Description", + DisplayOrder: 1 + ); + + // Act + Result? result = null; + Exception? exception = null; + + try + { + result = await commandDispatcher.SendAsync>(command); + } + catch (Exception ex) + { + exception = ex; + testOutput.WriteLine($"Exception: {ex.GetType().Name}"); + testOutput.WriteLine($"Message: {ex.Message}"); + testOutput.WriteLine($"StackTrace: {ex.StackTrace}"); + + if (ex.InnerException != null) + { + testOutput.WriteLine($"InnerException: {ex.InnerException.GetType().Name}"); + testOutput.WriteLine($"InnerMessage: {ex.InnerException.Message}"); + testOutput.WriteLine($"InnerStackTrace: {ex.InnerException.StackTrace}"); + } + } + + // Assert + testOutput.WriteLine($"Result IsSuccess: {result?.IsSuccess}"); + testOutput.WriteLine($"Result Value: {result?.Value}"); + testOutput.WriteLine($"Result Error: {result?.Error}"); + + exception.Should().BeNull("Command execution should not throw exception"); + result.Should().NotBeNull("Command should return a result"); + result.IsSuccess.Should().BeTrue("Command should succeed"); + result.Value.Should().NotBeNull("Result should contain created DTO"); + result.Value.Id.Should().NotBe(Guid.Empty, "Created entity should have a valid ID"); + result.Value.Name.Should().Be(command.Name, "Created entity name should match command"); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsIntegrationTests.cs new file mode 100644 index 000000000..a77d6f5c3 --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsIntegrationTests.cs @@ -0,0 +1,425 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; + +namespace MeAjudaAi.Integration.Tests.Modules.Catalogs; + +/// +/// Testes de integração completos para o módulo Catalogs. +/// Valida fluxos end-to-end de criação, atualização, consulta e remoção. +/// +public class CatalogsIntegrationTests(ITestOutputHelper testOutput) : ApiTestBase +{ + [Fact] + public async Task CreateServiceCategory_WithValidData_ShouldReturnCreated() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var categoryData = new + { + name = $"Test Category {Guid.NewGuid():N}", + description = "Test Category", + displayOrder = 1 + }; + + string? categoryId = null; + + try + { + // Act + var response = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.Created, + "POST requests that create resources should return 201 Created"); + + var responseJson = JsonSerializer.Deserialize(content); + var dataElement = GetResponseData(responseJson); + dataElement.TryGetProperty("id", out _).Should().BeTrue( + $"Response data should contain 'id' property. Full response: {content}"); + dataElement.TryGetProperty("name", out var nameProperty).Should().BeTrue(); + nameProperty.GetString().Should().Be(categoryData.name); + + if (dataElement.TryGetProperty("id", out var idProperty)) + { + categoryId = idProperty.GetString(); + } + } + finally + { + // Cleanup + if (categoryId is not null) + { + var deleteResponse = await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + if (!deleteResponse.IsSuccessStatusCode) + { + testOutput.WriteLine($"Cleanup failed: Could not delete category {categoryId}. Status: {deleteResponse.StatusCode}"); + } + } + } + } + + [Fact] + public async Task GetServiceCategories_ShouldReturnCategoriesList() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // Act + var response = await Client.GetAsync("/api/v1/catalogs/categories"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + + var categories = JsonSerializer.Deserialize(content); + categories.ValueKind.Should().Be(JsonValueKind.Object, + "API should return a structured response object"); + categories.TryGetProperty("data", out var dataElement).Should().BeTrue( + "Response should contain 'data' property for consistency"); + dataElement.ValueKind.Should().BeOneOf(JsonValueKind.Array, JsonValueKind.Object); + } + + [Fact] + public async Task GetServiceCategoryById_WithNonExistentId_ShouldReturnNotFound() + { + // Arrange + AuthConfig.ConfigureAdmin(); + var randomId = Guid.NewGuid(); + + // Act + var response = await Client.GetAsync($"/api/v1/catalogs/categories/{randomId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "API should return 404 when service category ID does not exist"); + } + + [Fact] + public async Task GetServiceById_WithNonExistentId_ShouldReturnNotFound() + { + // Arrange + AuthConfig.ConfigureAdmin(); + var randomId = Guid.NewGuid(); + + // Act + var response = await Client.GetAsync($"/api/v1/catalogs/services/{randomId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, + "API should return 404 when service ID does not exist"); + } + + [Fact] + public async Task CatalogsEndpoints_AdminUser_ShouldNotReturnAuthorizationOrServerErrors() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var endpoints = new[] + { + "/api/v1/catalogs/categories", + "/api/v1/catalogs/services" + }; + + // Act & Assert + foreach (var endpoint in endpoints) + { + var response = await Client.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + testOutput.WriteLine($"Endpoint {endpoint} returned {response.StatusCode}. Body: {body}"); + } + + response.StatusCode.Should().Be(HttpStatusCode.OK, + $"Authenticated admin requests to {endpoint} should succeed."); + } + } + + [Fact] + public async Task ServiceCategoryWorkflow_CreateUpdateDelete_ShouldWork() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var categoryData = new + { + name = $"Category {uniqueId}", + description = "Test Description", + displayOrder = 1 + }; + + string? categoryId = null; + + try + { + // Act 1: Create Category + var createResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + + // Assert 1: Creation successful + var createContent = await createResponse.Content.ReadAsStringAsync(); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, + $"Category creation should succeed. Response: {createContent}"); + + var createResponseJson = JsonSerializer.Deserialize(createContent); + var createdCategory = GetResponseData(createResponseJson); + createdCategory.TryGetProperty("id", out var idProperty).Should().BeTrue(); + categoryId = idProperty.GetString()!; + + // Act 2: Update Category + var updateData = new + { + name = $"Updated Category {uniqueId}", + description = "Updated Description", + displayOrder = 2 + }; + + var updateResponse = await Client.PutAsJsonAsync($"/api/v1/catalogs/categories/{categoryId}", updateData); + + // Assert 2: Update successful + updateResponse.StatusCode.Should().BeOneOf( + [HttpStatusCode.OK, HttpStatusCode.NoContent], + "Update should succeed for existing categories"); + + // Act 3: Get Category by ID + var getResponse = await Client.GetAsync($"/api/v1/catalogs/categories/{categoryId}"); + + // Assert 3: Can retrieve created category with updated fields + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var getContent = await getResponse.Content.ReadAsStringAsync(); + var getResponseJson = JsonSerializer.Deserialize(getContent); + var retrievedCategory = GetResponseData(getResponseJson); + retrievedCategory.TryGetProperty("id", out var retrievedIdProperty).Should().BeTrue(); + retrievedIdProperty.GetString().Should().Be(categoryId); + retrievedCategory.TryGetProperty("name", out var retrievedNameProperty).Should().BeTrue(); + retrievedNameProperty.GetString().Should().Be(updateData.name, "Updated name should be reflected"); + retrievedCategory.TryGetProperty("description", out var retrievedDescProperty).Should().BeTrue(); + retrievedDescProperty.GetString().Should().Be(updateData.description, "Updated description should be reflected"); + retrievedCategory.TryGetProperty("displayOrder", out var retrievedOrderProperty).Should().BeTrue(); + retrievedOrderProperty.GetInt32().Should().Be(updateData.displayOrder, "Updated displayOrder should be reflected"); + } + catch (Exception ex) + { + testOutput.WriteLine($"Category workflow test failed: {ex.Message}"); + throw; + } + finally + { + // Act 4: Delete Category (in finally to ensure cleanup) + if (categoryId is not null) + { + var deleteResponse = await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + + // Assert 4: Deletion successful + deleteResponse.StatusCode.Should().BeOneOf( + [HttpStatusCode.OK, HttpStatusCode.NoContent], + "Delete should succeed for existing categories"); + } + } + } + + [Fact] + public async Task ServiceWorkflow_CreateWithCategoryUpdateDelete_ShouldWork() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + + // First create a category + var categoryData = new + { + name = $"Test Category {uniqueId}", + description = "Test Description", + displayOrder = 1 + }; + + var categoryResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + var categoryContent = await categoryResponse.Content.ReadAsStringAsync(); + categoryResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var categoryJson = JsonSerializer.Deserialize(categoryContent); + var categoryDataElement = GetResponseData(categoryJson); + categoryDataElement.TryGetProperty("id", out var categoryIdProperty).Should().BeTrue(); + var categoryId = categoryIdProperty.GetString()!; + + try + { + // Act 1: Create Service + var serviceData = new + { + name = $"Test Service {uniqueId}", + description = "Test Service Description", + categoryId = categoryId + }; + + var createResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/services", serviceData); + + // Assert 1: Creation successful + var createContent = await createResponse.Content.ReadAsStringAsync(); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created, + $"Service creation should succeed. Response: {createContent}"); + + var createResponseJson = JsonSerializer.Deserialize(createContent); + var createdService = GetResponseData(createResponseJson); + createdService.TryGetProperty("id", out var serviceIdProperty).Should().BeTrue(); + var serviceId = serviceIdProperty.GetString()!; + + // Act 2: Update Service + var updateData = new + { + name = $"Updated Service {uniqueId}", + description = "Updated Service Description", + categoryId = categoryId, + isActive = false + }; + + var updateResponse = await Client.PutAsJsonAsync($"/api/v1/catalogs/services/{serviceId}", updateData); + + // Assert 2: Update successful + updateResponse.StatusCode.Should().BeOneOf( + [HttpStatusCode.OK, HttpStatusCode.NoContent], + "Update should succeed for existing services"); + + // Act 3: Get Service by ID + var getResponse = await Client.GetAsync($"/api/v1/catalogs/services/{serviceId}"); + + // Assert 3: Can retrieve created service with updated fields + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var getContent = await getResponse.Content.ReadAsStringAsync(); + var getResponseJson = JsonSerializer.Deserialize(getContent); + var retrievedService = GetResponseData(getResponseJson); + retrievedService.TryGetProperty("id", out var retrievedServiceIdProperty).Should().BeTrue(); + retrievedServiceIdProperty.GetString().Should().Be(serviceId); + retrievedService.TryGetProperty("name", out var retrievedNameProperty).Should().BeTrue(); + retrievedNameProperty.GetString().Should().Be(updateData.name, "Updated name should be reflected"); + retrievedService.TryGetProperty("description", out var retrievedDescProperty).Should().BeTrue(); + retrievedDescProperty.GetString().Should().Be(updateData.description, "Updated description should be reflected"); + + // Act 4: Delete Service + var deleteResponse = await Client.DeleteAsync($"/api/v1/catalogs/services/{serviceId}"); + + // Assert 4: Deletion successful + deleteResponse.StatusCode.Should().BeOneOf( + [HttpStatusCode.OK, HttpStatusCode.NoContent], + "Delete should succeed for existing services"); + } + catch (Exception ex) + { + testOutput.WriteLine($"Service workflow test failed: {ex.Message}"); + throw; + } + finally + { + // Cleanup category + var deleteCategoryResponse = await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + if (!deleteCategoryResponse.IsSuccessStatusCode) + { + testOutput.WriteLine($"Cleanup failed: Could not delete category {categoryId}. Status: {deleteCategoryResponse.StatusCode}"); + } + } + } + + [Fact] + public async Task GetServicesByCategoryId_ShouldReturnFilteredServices() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // First create a category + var categoryData = new + { + name = $"Test Category {Guid.NewGuid():N}", + description = "Test Description", + displayOrder = 1 + }; + + var categoryResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + categoryResponse.StatusCode.Should().Be(HttpStatusCode.Created, "Category creation should succeed"); + var categoryContent = await categoryResponse.Content.ReadAsStringAsync(); + var categoryJson = JsonSerializer.Deserialize(categoryContent); + var categoryDataElement = GetResponseData(categoryJson); + categoryDataElement.TryGetProperty("id", out var categoryIdProperty).Should().BeTrue(); + var categoryId = categoryIdProperty.GetString()!; + + try + { + // Create a service in the category + var serviceData = new + { + name = $"Test Service {Guid.NewGuid():N}", + description = "Test Service", + categoryId = categoryId + }; + + var serviceResponse = await Client.PostAsJsonAsync("/api/v1/catalogs/services", serviceData); + serviceResponse.StatusCode.Should().Be(HttpStatusCode.Created, "Service creation should succeed"); + + var serviceContent = await serviceResponse.Content.ReadAsStringAsync(); + var serviceJson = JsonSerializer.Deserialize(serviceContent); + var serviceDataElement = GetResponseData(serviceJson); + serviceDataElement.TryGetProperty("id", out var serviceIdProperty).Should().BeTrue(); + var serviceId = serviceIdProperty.GetString()!; + + try + { + // Act: Get services by category + var response = await Client.GetAsync($"/api/v1/catalogs/services/category/{categoryId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + var services = JsonSerializer.Deserialize(content); + + services.ValueKind.Should().Be(JsonValueKind.Object); + services.TryGetProperty("data", out var dataElement).Should().BeTrue(); + + // Should contain at least the service we just created + if (dataElement.ValueKind == JsonValueKind.Array) + { + dataElement.GetArrayLength().Should().BeGreaterThanOrEqualTo(1, + "Response should contain at least the created service"); + + // Verify the created service is in the results + var foundService = false; + foreach (var item in dataElement.EnumerateArray()) + { + if (item.TryGetProperty("id", out var itemId) && itemId.GetString() == serviceId) + { + foundService = true; + item.TryGetProperty("categoryId", out var itemCategoryId).Should().BeTrue(); + itemCategoryId.GetString().Should().Be(categoryId, + "Service should belong to the correct category"); + break; + } + } + foundService.Should().BeTrue($"Created service {serviceId} should be in the filtered results"); + } + } + finally + { + // Cleanup service + await Client.DeleteAsync($"/api/v1/catalogs/services/{serviceId}"); + } + } + finally + { + // Cleanup category + await Client.DeleteAsync($"/api/v1/catalogs/categories/{categoryId}"); + } + } + + private static JsonElement GetResponseData(JsonElement response) + { + return response.TryGetProperty("data", out var dataElement) + ? dataElement + : response; + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsResponseDebugTest.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsResponseDebugTest.cs new file mode 100644 index 000000000..1d437d02b --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Catalogs/CatalogsResponseDebugTest.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; +using MeAjudaAi.Shared.Serialization; + +namespace MeAjudaAi.Integration.Tests.Modules.Catalogs; + +public class CatalogsResponseDebugTest(ITestOutputHelper testOutput) : ApiTestBase +{ + [Fact(Skip = "Diagnostic test - enable only when debugging response format issues")] + [Trait("Category", "Debug")] + public async Task Debug_CreateServiceCategory_ResponseFormat() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + var categoryData = new + { + name = $"Debug Category {Guid.NewGuid():N}", + description = "Debug test", + displayOrder = 1 + }; + + // Act + var response = await Client.PostAsJsonAsync("/api/v1/catalogs/categories", categoryData); + + // Assert - Log everything + var content = await response.Content.ReadAsStringAsync(); + testOutput.WriteLine($"Status Code: {response.StatusCode}"); + testOutput.WriteLine($"Content Type: {response.Content.Headers.ContentType}"); + testOutput.WriteLine($"Raw Response: {content}"); + testOutput.WriteLine($"Response Length: {content.Length}"); + + JsonElement json; + try + { + // Use shared JSON deserialization for consistency with API serialization options + json = JsonSerializer.Deserialize(content, SerializationDefaults.Api); + testOutput.WriteLine($"JSON ValueKind: {json.ValueKind}"); + + if (json.ValueKind == JsonValueKind.Object) + { + testOutput.WriteLine("Properties:"); + foreach (var prop in json.EnumerateObject()) + { + testOutput.WriteLine($" {prop.Name}: {prop.Value.ValueKind} = {prop.Value}"); + } + } + } + catch (Exception ex) + { + testOutput.WriteLine($"JSON Parsing Error: {ex.Message}"); + throw; // Re-throw to fail the test + } + + // Validate expected DTO shape outside try/catch so assertions are not swallowed + if (json.ValueKind == JsonValueKind.Object) + { + json.TryGetProperty("id", out _).Should().BeTrue("DTO should have 'id' property"); + json.TryGetProperty("name", out _).Should().BeTrue("DTO should have 'name' property"); + } + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } +} diff --git a/src/Modules/Users/Tests/Infrastructure/TestCacheService.cs b/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestCacheService.cs similarity index 97% rename from src/Modules/Users/Tests/Infrastructure/TestCacheService.cs rename to tests/MeAjudaAi.Shared.Tests/Infrastructure/TestCacheService.cs index dbfc59f3f..92ffb7983 100644 --- a/src/Modules/Users/Tests/Infrastructure/TestCacheService.cs +++ b/tests/MeAjudaAi.Shared.Tests/Infrastructure/TestCacheService.cs @@ -1,13 +1,13 @@ using System.Collections.Concurrent; using MeAjudaAi.Shared.Caching; -namespace MeAjudaAi.Modules.Users.Tests.Infrastructure; +namespace MeAjudaAi.Shared.Tests.Infrastructure; /// /// Implementação simples de ICacheService para testes /// Usa ConcurrentDictionary em memória para simular cache /// -internal class TestCacheService : ICacheService +public class TestCacheService : ICacheService { private readonly ConcurrentDictionary _cache = new();