Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7d4c7fb
feat: implement Catalogs module with complete CRUD operations
Nov 18, 2025
d56dbab
Merge master into catalogs_module_implementation
Nov 18, 2025
3266102
remove arquivo desnecessario
Nov 18, 2025
1498f4f
fix: correct test data for Catalogs module requests
Nov 18, 2025
914fc23
fix(catalogs): correct authorization and API response format
Nov 18, 2025
859bbe3
refactor(catalogs): improve API consistency and service name validation
Nov 18, 2025
2cef125
refactor(catalogs): code quality improvements and test infrastructure…
Nov 18, 2025
c163a94
refactor(catalogs): apply code review suggestions for test quality
Nov 18, 2025
9632cbc
refactor(catalogs): apply comprehensive code review suggestions
Nov 18, 2025
7bf80df
docs: remove redundant README_TESTS.md from Catalogs module
Nov 18, 2025
7d2a9b4
fix(catalogs): improve test assertions and repository name normalization
Nov 18, 2025
db3ee27
refactor(catalogs): apply code review round 6 - consistency and robus…
Nov 18, 2025
73658d7
refactor(catalogs): apply code review round 7 - validation, diagnosti…
Nov 18, 2025
89bd2a8
refactor(catalogs): add input validation guards and batch query optim…
Nov 18, 2025
87f6d1f
test(catalogs): improve domain test coverage and robustness
Nov 18, 2025
ff80800
refactor(catalogs): fix validation, routing, and E2E test DTOs
Nov 18, 2025
bdf8248
chore: ignore launchSettings.json and fix empty GUID validation
Nov 18, 2025
840219d
refactor: improve code structure and standardization
Nov 18, 2025
af69fab
refactor: reorganize Catalogs handlers to match Users module structure
Nov 18, 2025
91d7803
docs: atualizar documentação dos módulos Catalogs e Location
Nov 18, 2025
bff62bd
style: aplicar dotnet format e corrigir imports dos Commands
Nov 19, 2025
60f3532
fix: adicionar validações Guid.Empty e corrigir documentação/testes
Nov 19, 2025
3fb3473
mais review
Nov 19, 2025
704c688
niitpicksom
Nov 19, 2025
2c26120
refactor: improve repository consistency and builder pattern
Nov 19, 2025
e51cb44
refactor: improve markdown formatting and test assertions
Nov 19, 2025
ccc633a
refactor: use nullable int for ServiceBuilder._displayOrder
Nov 19, 2025
943cfc0
refactor: add defensive length limits and improve test coverage
Nov 19, 2025
8cc6d40
style: fix whitespace formatting issues
Nov 19, 2025
231c608
test: strengthen E2E test assertions and improve determinism
Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor(catalogs): apply comprehensive code review suggestions
API Improvements:
- Change all update/delete/activate/deactivate endpoints to return 204 NoContent
- Change ValidateServicesAsync parameter from Guid[] to IReadOnlyCollection<Guid>
- Remove unused IServiceProvider from CatalogsModuleApi constructor

Test Quality:
- Tighten invalid-name test assertion to verify error message contains 'name'
- Call base.OnModuleInitializeAsync to preserve future base setup in integration tests
- Future-proof test infrastructure initialization

Performance & Documentation:
- Optimize ValidateServicesAsync to deduplicate input IDs (avoid duplicate processing)
- Add performance note to GetServiceCategoriesWithCountQueryHandler about N+1 pattern
- Add unit-of-work pattern note to ServiceRepository write methods
- Improve DeleteServiceCommandHandler TODO with detailed cross-module check guidance

Code Maintainability:
- Extract validation constants to ServiceCategoryValidation and ServiceValidation classes
- Add XML summary comments to all service commands for better discoverability
- Centralize max length values to avoid duplication and drift

All 94 unit tests and 30 integration tests passing.
  • Loading branch information
Filipe Frigini committed Nov 18, 2025
commit 9632cbc21509495c6f537de541848adbd4f98cb6
16 changes: 8 additions & 8 deletions src/Modules/Catalogs/API/Endpoints/ServiceCategoryEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public static void Map(IEndpointRouteBuilder app)
=> app.MapPut("/{id:guid}", UpdateAsync)
.WithName("UpdateServiceCategory")
.WithSummary("Atualizar categoria de serviço")
.Produces<Response<Unit>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.RequireAdmin();

private static async Task<IResult> UpdateAsync(
Expand All @@ -123,7 +123,7 @@ private static async Task<IResult> UpdateAsync(
{
var command = new UpdateServiceCategoryCommand(id, request.Name, request.Description, request.DisplayOrder);
var result = await commandDispatcher.SendAsync<UpdateServiceCategoryCommand, Result>(command, cancellationToken);
return Handle(result);
return HandleNoContent(result);
}
}

Expand All @@ -137,7 +137,7 @@ public static void Map(IEndpointRouteBuilder app)
=> app.MapDelete("/{id:guid}", DeleteAsync)
.WithName("DeleteServiceCategory")
.WithSummary("Deletar categoria de serviço")
.Produces<Response<Unit>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.RequireAdmin();

private static async Task<IResult> DeleteAsync(
Expand All @@ -147,7 +147,7 @@ private static async Task<IResult> DeleteAsync(
{
var command = new DeleteServiceCategoryCommand(id);
var result = await commandDispatcher.SendAsync<DeleteServiceCategoryCommand, Result>(command, cancellationToken);
return Handle(result);
return HandleNoContent(result);
}
}

Expand All @@ -161,7 +161,7 @@ public static void Map(IEndpointRouteBuilder app)
=> app.MapPost("/{id:guid}/activate", ActivateAsync)
.WithName("ActivateServiceCategory")
.WithSummary("Ativar categoria de serviço")
.Produces<Response<Unit>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.RequireAdmin();

private static async Task<IResult> ActivateAsync(
Expand All @@ -171,7 +171,7 @@ private static async Task<IResult> ActivateAsync(
{
var command = new ActivateServiceCategoryCommand(id);
var result = await commandDispatcher.SendAsync<ActivateServiceCategoryCommand, Result>(command, cancellationToken);
return Handle(result);
return HandleNoContent(result);
}
}

Expand All @@ -181,7 +181,7 @@ public static void Map(IEndpointRouteBuilder app)
=> app.MapPost("/{id:guid}/deactivate", DeactivateAsync)
.WithName("DeactivateServiceCategory")
.WithSummary("Desativar categoria de serviço")
.Produces<Response<Unit>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.RequireAdmin();

private static async Task<IResult> DeactivateAsync(
Expand All @@ -191,6 +191,6 @@ private static async Task<IResult> DeactivateAsync(
{
var command = new DeactivateServiceCategoryCommand(id);
var result = await commandDispatcher.SendAsync<DeactivateServiceCategoryCommand, Result>(command, cancellationToken);
return Handle(result);
return HandleNoContent(result);
}
}
6 changes: 5 additions & 1 deletion src/Modules/Catalogs/Application/CommandHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,11 @@ public async Task<Result> HandleAsync(DeleteServiceCommand request, Cancellation
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
// 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);

Expand Down
19 changes: 19 additions & 0 deletions src/Modules/Catalogs/Application/Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,45 @@ public sealed record DeactivateServiceCategoryCommand(Guid Id) : Command<Result>
// SERVICE COMMANDS
// ============================================================================

/// <summary>
/// Command to create a new service in a specific category.
/// </summary>
public sealed record CreateServiceCommand(
Guid CategoryId,
string Name,
string? Description,
int DisplayOrder = 0
) : Command<Result<ServiceDto>>;

/// <summary>
/// Command to update an existing service's details.
/// </summary>
public sealed record UpdateServiceCommand(
Guid Id,
string Name,
string? Description,
int DisplayOrder
) : Command<Result>;

/// <summary>
/// Command to delete a service from the catalog.
/// Note: Currently does not check for provider references (see handler TODO).
/// </summary>
public sealed record DeleteServiceCommand(Guid Id) : Command<Result>;

/// <summary>
/// Command to activate a service, making it available for use.
/// </summary>
public sealed record ActivateServiceCommand(Guid Id) : Command<Result>;

/// <summary>
/// Command to deactivate a service, removing it from active use.
/// </summary>
public sealed record DeactivateServiceCommand(Guid Id) : Command<Result>;

/// <summary>
/// Command to move a service to a different category.
/// </summary>
public sealed record ChangeServiceCategoryCommand(
Guid ServiceId,
Guid NewCategoryId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ namespace MeAjudaAi.Modules.Catalogs.Application.ModuleApi;
public sealed class CatalogsModuleApi(
IServiceCategoryRepository categoryRepository,
IServiceRepository serviceRepository,
IServiceProvider serviceProvider,
ILogger<CatalogsModuleApi> logger) : ICatalogsModuleApi
{
public string ModuleName => "Catalogs";
Expand Down Expand Up @@ -209,15 +208,16 @@ public async Task<Result<bool>> IsServiceActiveAsync(
}
Comment thread
frigini marked this conversation as resolved.

public async Task<Result<ModuleServiceValidationResultDto>> ValidateServicesAsync(
Guid[] serviceIds,
IReadOnlyCollection<Guid> serviceIds,
CancellationToken cancellationToken = default)
{
try
{
var invalidIds = new List<Guid>();
var inactiveIds = new List<Guid>();

foreach (var serviceId in serviceIds)
// Deduplicate input IDs to avoid processing the same ID multiple times
foreach (var serviceId in serviceIds.Distinct())
{
var serviceIdValue = ServiceId.From(serviceId);
var service = await serviceRepository.GetByIdAsync(serviceIdValue, cancellationToken);
Expand Down
3 changes: 3 additions & 0 deletions src/Modules/Catalogs/Application/QueryHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ public async Task<Result<IReadOnlyList<ServiceCategoryWithCountDto>>> HandleAsyn

var dtos = new List<ServiceCategoryWithCountDto>();

// 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(
Expand Down
17 changes: 13 additions & 4 deletions src/Modules/Catalogs/Domain/Entities/Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@

namespace MeAjudaAi.Modules.Catalogs.Domain.Entities;

/// <summary>
/// Validation constants for Service entity.
/// </summary>
public static class ServiceValidation
{
public const int MaxNameLength = 150;
public const int MaxDescriptionLength = 1000;
}

/// <summary>
/// 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.
Expand Down Expand Up @@ -137,13 +146,13 @@ private static void ValidateName(string name)
if (string.IsNullOrWhiteSpace(name))
throw new CatalogDomainException("Service name is required.");

if (name.Trim().Length > 150)
throw new CatalogDomainException("Service name cannot exceed 150 characters.");
if (name.Trim().Length > ServiceValidation.MaxNameLength)
throw new CatalogDomainException($"Service name cannot exceed {ServiceValidation.MaxNameLength} characters.");
}

private static void ValidateDescription(string? description)
{
if (description is not null && description.Trim().Length > 1000)
throw new CatalogDomainException("Service description cannot exceed 1000 characters.");
if (description is not null && description.Trim().Length > ServiceValidation.MaxDescriptionLength)
throw new CatalogDomainException($"Service description cannot exceed {ServiceValidation.MaxDescriptionLength} characters.");
}
}
17 changes: 13 additions & 4 deletions src/Modules/Catalogs/Domain/Entities/ServiceCategory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@

namespace MeAjudaAi.Modules.Catalogs.Domain.Entities;

/// <summary>
/// Validation constants for ServiceCategory entity.
/// </summary>
public static class ServiceCategoryValidation
{
public const int MaxNameLength = 100;
public const int MaxDescriptionLength = 500;
}

/// <summary>
/// Represents a service category in the catalog (e.g., "Limpeza", "Reparos").
/// Categories organize services into logical groups for easier discovery.
Expand Down Expand Up @@ -106,13 +115,13 @@ private static void ValidateName(string name)
if (string.IsNullOrWhiteSpace(name))
throw new CatalogDomainException("Category name is required.");

if (name.Trim().Length > 100)
throw new CatalogDomainException("Category name cannot exceed 100 characters.");
if (name.Trim().Length > ServiceCategoryValidation.MaxNameLength)
throw new CatalogDomainException($"Category name cannot exceed {ServiceCategoryValidation.MaxNameLength} characters.");
}

private static void ValidateDescription(string? description)
{
if (description is not null && description.Trim().Length > 500)
throw new CatalogDomainException("Category description cannot exceed 500 characters.");
if (description is not null && description.Trim().Length > ServiceCategoryValidation.MaxDescriptionLength)
throw new CatalogDomainException($"Category description cannot exceed {ServiceCategoryValidation.MaxDescriptionLength} characters.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ public async Task<int> CountByCategoryAsync(ServiceCategoryId categoryId, bool a
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);
Expand All @@ -85,11 +89,13 @@ public async Task UpdateAsync(Service service, CancellationToken cancellationTok

public async Task DeleteAsync(ServiceId id, CancellationToken cancellationToken = default)
{
// Reuse GetByIdAsync but note it's a tracked query for delete scenarios
var service = await GetByIdAsync(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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public class CatalogsModuleApiIntegrationTests : CatalogsIntegrationTestBase
{
private ICatalogsModuleApi _moduleApi = null!;

protected override Task OnModuleInitializeAsync(IServiceProvider serviceProvider)
protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider)
{
await base.OnModuleInitializeAsync(serviceProvider);
_moduleApi = GetService<ICatalogsModuleApi>();
return Task.CompletedTask;
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public class ServiceCategoryRepositoryIntegrationTests : CatalogsIntegrationTest
{
private IServiceCategoryRepository _repository = null!;

protected override Task OnModuleInitializeAsync(IServiceProvider serviceProvider)
protected override async Task OnModuleInitializeAsync(IServiceProvider serviceProvider)
{
await base.OnModuleInitializeAsync(serviceProvider);
_repository = GetService<IServiceCategoryRepository>();
return Task.CompletedTask;
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,6 @@ public async Task Handle_WithInvalidName_ShouldReturnFailure(string? invalidName
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().NotBeNull();
result.Error!.Message.Should().Contain("name", "validation error should mention the problematic field");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ Task<Result<bool>> IsServiceActiveAsync(
/// </summary>
/// <returns>Result containing validation outcome and list of invalid service IDs</returns>
Task<Result<ModuleServiceValidationResultDto>> ValidateServicesAsync(
Guid[] serviceIds,
IReadOnlyCollection<Guid> serviceIds,
CancellationToken cancellationToken = default);
}