From 083fcd7621c09170d0c2b3e7629fcf4832a6740b Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Mon, 12 Oct 2026 17:58:43 -0300 Subject: [PATCH 01/24] Add client list/search API and persistence Adds the paged client list endpoint with query validation, repository filtering/search, and status-based handling. This change introduces generated normalized search columns for name, email, and CPF, updates the API contract and docs, and adds persistence and handler tests covering sorting, paging, and validation behavior. --- .../references/persistence.md | 6 +- .../shared/api/generated/services-api.d.ts | 114 +++- .../src/shared/ui/button/index.tsx | 10 +- backend/docs/ARCHITECTURE.md | 14 +- .../Controllers/ClientsController.cs | 10 + .../Abstractions/IClientRepository.cs | 8 + .../ListClients/ClientListItemResponse.cs | 23 + .../Clients/ListClients/ListClientsQuery.cs | 14 + .../ListClients/ListClientsQueryHandler.cs | 70 +++ .../ListClients/ListClientsQueryValidator.cs | 43 ++ .../Configurations/ClientConfiguration.cs | 6 + ...2203000_AddClientSearchColumns.Designer.cs | 520 ++++++++++++++++++ .../20261012203000_AddClientSearchColumns.cs | 60 ++ .../ServicesDataContextModelSnapshot.cs | 19 +- .../Repositories/ClientRepository.cs | 45 ++ .../ClientListPersistenceTests.cs | 375 +++++++++++++ .../ListClientsQueryHandlerTests.cs | 238 ++++++++ .../ListClientsQueryValidatorTests.cs | 121 ++++ docs/API.md | 20 + ...regate-uniqueness-and-conflict-contract.md | 4 +- .../0063-clients-list-and-search-contract.md | 144 +++++ docs/adr/README.md | 5 +- 22 files changed, 1852 insertions(+), 17 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs create mode 100644 backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.Designer.cs create mode 100644 backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.cs create mode 100644 backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs create mode 100644 docs/adr/0063-clients-list-and-search-contract.md diff --git a/.claude/skills/agenza-backend-slice/references/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index cf9efc3d..68a00c17 100644 --- a/.claude/skills/agenza-backend-slice/references/persistence.md +++ b/.claude/skills/agenza-backend-slice/references/persistence.md @@ -39,8 +39,10 @@ that differs only by tracking (ARCHITECTURE, rules of thumb). the ones the aggregate no longer holds, add the ones the database does not have yet. `Include` only the root's own children. - Order every list and page explicitly, on a key that is unique or ends in one — otherwise rows repeat or vanish between pages. -- A value-object column is compared as a whole (`c.Cpf == cpf`); text search on it goes through - `EF.Property(e, "")`, because a query cannot reach `.Value` through a converter. +- A value-object column is compared as a whole (`c.Cpf == cpf`). A query cannot reach `.Value` through a + converter, and `EF.Property` over such a column throws, so text search or a case-insensitive order on it + needs a stored generated `string` column (`HasComputedColumnSql("lower(\"FullName\")", stored: true)`, read with + `EF.Property(e, "")`); see `ClientConfiguration` and ADR 0063. - The `DbContext` already scopes every query to the tenant and hides soft-deleted rows. A repository never writes a `TenantId` or `DeletedAt` predicate, never calls `IgnoreQueryFilters()`, never calls `SaveChanges`. diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 9e0fde18..790e24ce 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -459,7 +459,91 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: { + parameters: { + query?: { + Page?: number | string; + PageSize?: number | string; + Q?: string; + Status?: string; + }; + header?: never; + path: { + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse']; + 'application/json': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse']; + 'text/json': components['schemas']['ApiResponseOfPagedResultOfClientListItemResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + }; + }; put?: never; post: { parameters: { @@ -1813,6 +1897,14 @@ export interface components { traceId?: null | string; correlationId?: null | string; }; + ApiResponseOfPagedResultOfClientListItemResponse: { + data: null | components['schemas']['PagedResultOfClientListItemResponse']; + success?: boolean; + /** Format: date-time */ + timestamp?: string; + traceId?: null | string; + correlationId?: null | string; + }; ApiResponseOfPagedResultOfServiceResponse: { data: null | components['schemas']['PagedResultOfServiceResponse']; success?: boolean; @@ -1842,6 +1934,17 @@ export interface components { id: string; name: string; }; + ClientListItemResponse: { + /** Format: uuid */ + id: string; + fullName: string; + /** Format: date */ + birthDate: null | string; + phone: null | string; + email: null | string; + cpf: null | string; + status: string; + }; ClientResponse: { /** Format: uuid */ id: string; @@ -1915,6 +2018,15 @@ export interface components { phone: null | string; cpf: null | string; }; + PagedResultOfClientListItemResponse: { + items: components['schemas']['ClientListItemResponse'][]; + /** Format: int32 */ + totalCount: number | string; + /** Format: int32 */ + page: number | string; + /** Format: int32 */ + pageSize: number | string; + }; PagedResultOfServiceResponse: { items: components['schemas']['ServiceResponse'][]; /** Format: int32 */ diff --git a/apps/admin-frontend/src/shared/ui/button/index.tsx b/apps/admin-frontend/src/shared/ui/button/index.tsx index 1bf070d9..e72744c0 100644 --- a/apps/admin-frontend/src/shared/ui/button/index.tsx +++ b/apps/admin-frontend/src/shared/ui/button/index.tsx @@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from '@base-ui/react/button'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/shared/lib/utils'; -const buttonVariants = cva( +const buttonStyles = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { @@ -39,18 +39,22 @@ const buttonVariants = cva( }, ); +function buttonVariants(props?: Parameters[0]): string { + return cn(buttonStyles(props)); +} + function Button({ className, variant = 'default', size = 'default', ...props -}: ButtonPrimitive.Props & VariantProps) { +}: ButtonPrimitive.Props & VariantProps) { return ( ); diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index b522caf7..c51b3291 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -342,8 +342,10 @@ applies the domain behaviour, then stages persistence with `UpdateAsync`; the po tracking. An EF adapter makes any required state changes explicitly inside that operation. Paged reads go through `ListPagedAsync`. The tenant and soft-delete filters come from the `DbContext`: a repository never writes a `TenantId` or -`DeletedAt` predicate. A query cannot reach `.Value` through a converter: compare whole value objects, -order by the property, or use `EF.Property(e, "")` for text matching. Repositories +`DeletedAt` predicate. A query cannot reach `.Value` through a converter: compare whole value objects or order by the +property. Matching or sorting a value-object column as text — `EF.Property` over it throws — goes through a +stored generated `string` column (`lower("FullName")`) added by an additive migration +([0063](../../docs/adr/0063-clients-list-and-search-contract.md)). Repositories only stage (`Add`, `UpdateAsync`, `Remove`); the handler commits through `IUnitOfWork`, whose shape follows the service's real transactional need ([0005](../../docs/adr/0005-cqrs-vertical-slice-result-pattern.md)). @@ -382,8 +384,9 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult translation class. Each item of a list of enums has an `IsInEnum` rule with the domain's code ([0058](../../docs/adr/0058-closed-set-members-are-plain-enums.md)). - The wire is camelCase English; calendar dates are `DateOnly` (`yyyy-MM-dd`); instants are UTC. -- A paged list takes `Page`/`PageSize` with defaults and a validator bounding them, and returns - `PagedResult`; an unpaged list returns `IReadOnlyList`. +- A paged list takes `Page`/`PageSize` with defaults and a validator bounding them — the offset + `(Page - 1) * PageSize` has to fit an `int` too, because `ListPagedAsync` computes it in `int` — and returns + `PagedResult`; an unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items. A change to an endpoint's contract regenerates the frontend's types in the same change — skill [`agenza-api-contract`](../../.claude/skills/agenza-api-contract/SKILL.md). A change to a shape that @@ -454,7 +457,8 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| Delete, list, paging | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save; paged query + bounded validator + `PagedResult`) minus the rows above | | §4, §6 | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a validator with codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | This table is the only place that names reference files. When a newer slice supersedes one, change diff --git a/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs b/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs index 810e5c04..c72f6eed 100644 --- a/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs +++ b/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs @@ -6,6 +6,7 @@ using ServicesService.Application.Clients.CreateClient; using ServicesService.Application.Clients.DeactivateClient; using ServicesService.Application.Clients.GetClientById; +using ServicesService.Application.Clients.ListClients; using ServicesService.Application.Clients.ReactivateClient; using ServicesService.Application.Clients.UpdateClient; @@ -25,6 +26,15 @@ public ClientsController(IDispatcher dispatcher) _dispatcher = dispatcher; } + [HttpGet] + [ProducesResponseType>>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task List([FromQuery] ListClientsQuery query, CancellationToken cancellationToken) + { + var result = await _dispatcher.Query(query, cancellationToken); + return result.ToActionResult(this, clients => Ok(clients)); + } + [HttpPost] [RequestSizeLimit(MaxRequestBodyBytes)] [ProducesResponseType>(StatusCodes.Status201Created)] diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index bd89bbb6..1d36a1e7 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -4,6 +4,14 @@ public interface IClientRepository { Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int TotalCount)> ListAsync( + int page, + int pageSize, + string? text, + string? cpfDigits, + ClientStatus? status, + CancellationToken cancellationToken); + Task UpdateAsync(Client client, CancellationToken cancellationToken); // excludeClientId ignores the client being edited, so saving a client without changing its CPF isn't a self-conflict. diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs new file mode 100644 index 00000000..3e2a3136 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs @@ -0,0 +1,23 @@ +namespace ServicesService.Application.Clients.ListClients; + +public sealed record ClientListItemResponse( + Guid Id, + string FullName, + DateOnly? BirthDate, + string? Phone, + string? Email, + string? Cpf, + string Status) +{ + public static ClientListItemResponse FromClient(Client client) + { + return new ClientListItemResponse( + client.Id, + client.FullName.Value, + client.BirthDate?.Value, + client.Phone?.Value, + client.Email?.Value, + client.Cpf?.Value, + client.Status.ToString().ToLowerInvariant()); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs new file mode 100644 index 00000000..fe7dfac5 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -0,0 +1,14 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients.ListClients; + +public sealed record ListClientsQuery( + int Page = 1, + int PageSize = 20, + string? Q = null, + string? Status = ListClientsQuery.ActiveStatus) : IQuery> +{ + public const string ActiveStatus = "active"; + public const string InactiveStatus = "inactive"; + public const string AllStatus = "all"; +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs new file mode 100644 index 00000000..174d964e --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -0,0 +1,70 @@ +using Admin.SharedKernel; +using ServicesService.Application.Abstractions; + +namespace ServicesService.Application.Clients.ListClients; + +public sealed class ListClientsQueryHandler(IClientRepository clientRepository) + : IQueryHandler> +{ + public async Task>> Handle( + ListClientsQuery query, + CancellationToken cancellationToken) + { + var text = NormalizeText(query.Q); + var cpfDigits = ExtractCpfDigits(text); + var status = ToStatusFilter(query.Status); + + var (clients, totalCount) = await clientRepository.ListAsync( + query.Page, + query.PageSize, + text, + cpfDigits, + status, + cancellationToken); + + var items = clients.Select(ClientListItemResponse.FromClient).ToList(); + + return Result.Success(new PagedResult(items, totalCount, query.Page, query.PageSize)); + } + + private static string? NormalizeText(string? term) + { + if (string.IsNullOrWhiteSpace(term)) + { + return null; + } + + return term.Trim().ToLowerInvariant(); + } + + private static string? ExtractCpfDigits(string? text) + { + if (text is null) + { + return null; + } + + var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return digits; + } + + private static ClientStatus? ToStatusFilter(string? status) + { + if (string.Equals(status, ListClientsQuery.AllStatus, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (string.Equals(status, ListClientsQuery.InactiveStatus, StringComparison.OrdinalIgnoreCase)) + { + return ClientStatus.Inactive; + } + + return ClientStatus.Active; + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs new file mode 100644 index 00000000..55d39c4b --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -0,0 +1,43 @@ +using FluentValidation; + +namespace ServicesService.Application.Clients.ListClients; + +public sealed class ListClientsQueryValidator : AbstractValidator +{ + private const int MaxPageSize = 100; + + private static readonly string[] KnownStatuses = + [ + ListClientsQuery.ActiveStatus, + ListClientsQuery.InactiveStatus, + ListClientsQuery.AllStatus, + ]; + + public ListClientsQueryValidator() + { + RuleFor(query => query.Page) + .Cascade(CascadeMode.Stop) + .GreaterThanOrEqualTo(1) + .WithErrorCode("Client.PageInvalid") + .WithMessage("A página deve ser maior ou igual a 1.") + .Must((query, page) => (long)(page - 1) * query.PageSize <= int.MaxValue) + .WithErrorCode("Client.PageTooLarge") + .WithMessage("A página informada está além do limite permitido."); + + RuleFor(query => query.PageSize) + .InclusiveBetween(1, MaxPageSize) + .WithErrorCode("Client.PageSizeInvalid") + .WithMessage($"O tamanho da página deve ser entre 1 e {MaxPageSize}."); + + RuleFor(query => query.Q) + .MaximumLength(EmailAddress.MaxLength) + .WithErrorCode("Client.SearchTooLong") + .WithMessage($"A busca deve ter no máximo {EmailAddress.MaxLength} caracteres."); + + RuleFor(query => query.Status) + .Must(status => string.IsNullOrWhiteSpace(status) || KnownStatuses.Contains(status, StringComparer.OrdinalIgnoreCase)) + .WithErrorCode("Client.StatusFilterInvalid") + .WithMessage( + $"A situação deve ser {ListClientsQuery.ActiveStatus}, {ListClientsQuery.InactiveStatus} ou {ListClientsQuery.AllStatus}."); + } +} diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/ClientConfiguration.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/ClientConfiguration.cs index da716703..1e83a6ff 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/ClientConfiguration.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/ClientConfiguration.cs @@ -24,6 +24,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(c => c.AdministrativeNotes).HasMaxLength(AdministrativeNotes.MaxLength); builder.Property(c => c.Status).IsRequired().HasConversion().HasMaxLength(16); + // A query cannot read a value-object column as text, so the listing searches and sorts these generated + // string columns (docs/adr/0063). + builder.Property("NameNormalized").HasComputedColumnSql("lower(\"FullName\")", stored: true); + builder.Property("EmailNormalized").HasComputedColumnSql("lower(\"Email\")", stored: true); + builder.Property("CpfNormalized").HasComputedColumnSql("\"Cpf\"", stored: true); + // CPF collides among live clients whatever their status, e-mail only among live active ones. The database // decides concurrent writes (docs/adr/0044). builder.HasIndex(c => new { c.TenantId, c.Cpf }) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.Designer.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.Designer.cs new file mode 100644 index 00000000..d4fcfe35 --- /dev/null +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.Designer.cs @@ -0,0 +1,520 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServicesService.Infrastructure.Persistence; + +#nullable disable + +namespace ServicesService.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ServicesDataContext))] + [Migration("20261012203000_AddClientSearchColumns")] + partial class AddClientSearchColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("services") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceTags", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "ServiceId", "TagsId"); + + b.HasIndex("TenantId", "TagsId"); + + b.ToTable("ServiceTags", "services"); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("NameNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"Name\")", true); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "NameNormalized") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Categories", "services"); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdministrativeNotes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("Cpf") + .HasMaxLength(11) + .HasColumnType("character varying(11)"); + + b.Property("CpfNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("\"Cpf\"", true); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"Email\")", true); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("NameNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"FullName\")", true); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Cpf") + .IsUnique() + .HasFilter("\"Cpf\" IS NOT NULL AND \"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL AND \"Status\" = 'Active' AND \"DeletedAt\" IS NULL"); + + b.ToTable("Clients", "services", t => + { + t.HasCheckConstraint("CK_Clients_Status", "\"Status\" IN ('Active', 'Inactive')"); + }); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.ClientGuardian", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Cpf") + .HasMaxLength(11) + .HasColumnType("character varying(11)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Relationship") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ClientId"); + + b.ToTable("ClientGuardians", "services"); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.ClientReferenceContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Purposes") + .HasColumnType("integer"); + + b.Property("Relationship") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "ClientId"); + + b.ToTable("ClientReferenceContacts", "services", t => + { + t.HasCheckConstraint("CK_ClientReferenceContacts_Purposes", "\"Purposes\" BETWEEN 1 AND 7"); + }); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Code") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("MaxDiscountPercentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("MaxDurationMinutes") + .HasColumnType("integer"); + + b.Property("MinDurationMinutes") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("NameNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"Name\")", true); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "CategoryId"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "NameNormalized") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Services", "services"); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("NameNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"Name\")", true); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "NameNormalized") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Tags", "services"); + }); + + modelBuilder.Entity("ServicesService.Infrastructure.Persistence.TenantSequence", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("EntityName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastValue") + .HasColumnType("integer"); + + b.HasKey("TenantId", "EntityName"); + + b.ToTable("TenantSequences", "services"); + }); + + modelBuilder.Entity("ServiceTags", b => + { + b.HasOne("ServicesService.Domain.Entities.Service", null) + .WithMany() + .HasForeignKey("TenantId", "ServiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ServicesService.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TenantId", "TagsId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.ClientGuardian", b => + { + b.HasOne("ServicesService.Domain.Entities.Client", null) + .WithMany("Guardians") + .HasForeignKey("TenantId", "ClientId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.ClientReferenceContact", b => + { + b.HasOne("ServicesService.Domain.Entities.Client", null) + .WithMany("ReferenceContacts") + .HasForeignKey("TenantId", "ClientId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Service", b => + { + b.HasOne("ServicesService.Domain.Entities.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ServicesService.Domain.Entities.Client", b => + { + b.Navigation("Guardians"); + + b.Navigation("ReferenceContacts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.cs new file mode 100644 index 00000000..c3dbabca --- /dev/null +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261012203000_AddClientSearchColumns.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServicesService.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddClientSearchColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CpfNormalized", + schema: "services", + table: "Clients", + type: "text", + nullable: true, + computedColumnSql: "\"Cpf\"", + stored: true); + + migrationBuilder.AddColumn( + name: "EmailNormalized", + schema: "services", + table: "Clients", + type: "text", + nullable: true, + computedColumnSql: "lower(\"Email\")", + stored: true); + + migrationBuilder.AddColumn( + name: "NameNormalized", + schema: "services", + table: "Clients", + type: "text", + nullable: true, + computedColumnSql: "lower(\"FullName\")", + stored: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CpfNormalized", + schema: "services", + table: "Clients"); + + migrationBuilder.DropColumn( + name: "EmailNormalized", + schema: "services", + table: "Clients"); + + migrationBuilder.DropColumn( + name: "NameNormalized", + schema: "services", + table: "Clients"); + } + } +} diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/ServicesDataContextModelSnapshot.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/ServicesDataContextModelSnapshot.cs index 2e1047d9..9d3f564a 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/ServicesDataContextModelSnapshot.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/ServicesDataContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("services") - .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -108,6 +108,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(11) .HasColumnType("character varying(11)"); + b.Property("CpfNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("\"Cpf\"", true); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -124,11 +129,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(254) .HasColumnType("character varying(254)"); + b.Property("EmailNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"Email\")", true); + b.Property("FullName") .IsRequired() .HasMaxLength(150) .HasColumnType("character varying(150)"); + b.Property("NameNormalized") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("text") + .HasComputedColumnSql("lower(\"FullName\")", true); + b.Property("Phone") .HasMaxLength(20) .HasColumnType("character varying(20)"); @@ -170,7 +185,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ServicesService.Domain.Entities.ClientGuardian", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ClientId") @@ -229,7 +243,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ServicesService.Domain.Entities.ClientReferenceContact", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ClientId") diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 66b0267c..ea7c1e7d 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -20,6 +20,51 @@ public ClientRepository(ServicesDataContext dbContext) .FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); } + public Task<(IReadOnlyList Items, int TotalCount)> ListAsync( + int page, + int pageSize, + string? text, + string? cpfDigits, + ClientStatus? status, + CancellationToken cancellationToken) + { + return ListPagedAsync( + query => FilterAndOrder(query, text, cpfDigits, status), + page, + pageSize, + cancellationToken); + } + + private static IQueryable FilterAndOrder( + IQueryable query, + string? text, + string? cpfDigits, + ClientStatus? status) + { + if (status is { } wantedStatus) + { + query = query.Where(client => client.Status == wantedStatus); + } + + if (text is not null && cpfDigits is not null) + { + query = query.Where(client => + EF.Property(client, "NameNormalized").Contains(text) + || EF.Property(client, "EmailNormalized").Contains(text) + || EF.Property(client, "CpfNormalized").Contains(cpfDigits)); + } + else if (text is not null) + { + query = query.Where(client => + EF.Property(client, "NameNormalized").Contains(text) + || EF.Property(client, "EmailNormalized").Contains(text)); + } + + return query + .OrderBy(client => EF.Property(client, "NameNormalized")) + .ThenBy(client => client.Id); + } + public async Task UpdateAsync(Client client, CancellationToken cancellationToken) { await SyncContactsAsync(client.Id, client.Guardians, cancellationToken); diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs new file mode 100644 index 00000000..fa4afaa9 --- /dev/null +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -0,0 +1,375 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using ServicesService.Application.Abstractions; +using ServicesService.Infrastructure.Persistence; +using ServicesService.Infrastructure.Repositories; + +namespace ServicesService.PersistenceTests; + +public class ClientListPersistenceTests +{ + private const string MariaCpf = "52998224725"; + private const string JoseCpf = "12345678909"; + private const string PauloCpf = "11144477735"; + private static readonly DateOnly Today = new(2026, 10, 12); + + private static Client NewClient(string fullName, string? cpf = null, string? email = null) + { + var data = new ClientData( + FullName.Create(fullName).Value, + null, + null, + email is null ? null : EmailAddress.Create(email).Value, + cpf is null ? null : CpfNumber.Create(cpf).Value, + null, + [], + []); + return Client.Create(data, Today).Value; + } + + private static async Task Save(ServicesDataContext context, Client client) + { + context.Clients.Add(client); + var entry = context.Entry(client); + entry.Property("NameNormalized").CurrentValue = client.FullName.Value.ToLowerInvariant(); + entry.Property("EmailNormalized").CurrentValue = client.Email?.Value.ToLowerInvariant()!; + entry.Property("CpfNormalized").CurrentValue = client.Cpf?.Value!; + await context.SaveChangesAsync(); + } + + private static async Task> SaveAll(string databaseName, Guid tenantId, params Client[] clients) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + foreach (var client in clients) + { + await Save(context, client); + } + + return clients; + } + + private static async Task SetStatus(string databaseName, Guid tenantId, Guid clientId, ClientStatus status) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var tracked = await context.Clients.AsTracking().SingleAsync(c => c.Id == clientId); + context.Entry(tracked).Property(c => c.Status).CurrentValue = status; + await context.SaveChangesAsync(); + } + + private static async Task SoftDelete(string databaseName, Guid tenantId, Guid clientId) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var tracked = await context.Clients.AsTracking().SingleAsync(c => c.Id == clientId); + context.Clients.Remove(tracked); + await context.SaveChangesAsync(); + } + + private static async Task<(IReadOnlyList Items, int TotalCount)> List( + string databaseName, + Guid tenantId, + int page = 1, + int pageSize = 20, + string? text = null, + string? cpfDigits = null, + ClientStatus? status = null) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + return await new ClientRepository(context).ListAsync(page, pageSize, text, cpfDigits, status, CancellationToken.None); + } + + private static string[] Names((IReadOnlyList Items, int TotalCount) page) + { + return page.Items.Select(client => client.FullName.Value).ToArray(); + } + + [Fact] + public async Task List_NeverReturnsNorCountsAnotherTenantsClients() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + await SaveAll(databaseName, tenantA, NewClient("Ana Lima"), NewClient("Bruno Dias")); + await SaveAll(databaseName, tenantB, NewClient("Carla Souza", MariaCpf, "carla@example.com")); + + var asA = await List(databaseName, tenantA, status: null); + var asB = await List(databaseName, tenantB, status: null); + + Names(asA).Should().Equal("Ana Lima", "Bruno Dias"); + asA.TotalCount.Should().Be(2); + Names(asB).Should().Equal("Carla Souza"); + asB.TotalCount.Should().Be(1); + } + + [Fact] + public async Task List_NeverMatchesAnotherTenantsClientBySearchByName_ByEmailNorByCpf() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + await SaveAll(databaseName, tenantA, NewClient("Ana Lima")); + await SaveAll(databaseName, tenantB, NewClient("Maria Souza", MariaCpf, "maria@example.com")); + + var byName = await List(databaseName, tenantA, text: "maria"); + var byEmail = await List(databaseName, tenantA, text: "example.com"); + var byCpf = await List(databaseName, tenantA, text: MariaCpf, cpfDigits: MariaCpf); + + byName.Items.Should().BeEmpty(); + byName.TotalCount.Should().Be(0); + byEmail.TotalCount.Should().Be(0); + byCpf.TotalCount.Should().Be(0); + } + + [Fact] + public async Task List_WithNoTenantInContext_ReturnsNothing() + { + var databaseName = Guid.NewGuid().ToString(); + await SaveAll(databaseName, Guid.NewGuid(), NewClient("Ana Lima")); + var tenantProvider = Substitute.For(); + tenantProvider.TryGetTenantId(out Arg.Any()).Returns(false); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(databaseName).Options; + await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); + + var page = await new ClientRepository(tenantlessContext) + .ListAsync(1, 20, null, null, null, CancellationToken.None); + + page.Items.Should().BeEmpty(); + page.TotalCount.Should().Be(0); + } + + [Fact] + public async Task List_NeverReturnsNorCountsASoftDeletedClient_EvenWhenItMatchesTheSearch() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var kept = NewClient("Ana Lima", MariaCpf); + var deleted = NewClient("Ana Dias", JoseCpf); + await SaveAll(databaseName, tenantId, kept, deleted); + await SoftDelete(databaseName, tenantId, deleted.Id); + + var all = await List(databaseName, tenantId); + var searched = await List(databaseName, tenantId, text: "ana"); + var byCpf = await List(databaseName, tenantId, text: JoseCpf, cpfDigits: JoseCpf); + + Names(all).Should().Equal("Ana Lima"); + all.TotalCount.Should().Be(1); + Names(searched).Should().Equal("Ana Lima"); + searched.TotalCount.Should().Be(1); + byCpf.Items.Should().BeEmpty(); + byCpf.TotalCount.Should().Be(0); + } + + [Fact] + public async Task List_FiltersBySituation_AndAllIncludesActiveAndInactive() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var active = NewClient("Ana Lima"); + var inactive = NewClient("Bruno Dias"); + var other = NewClient("Carla Souza"); + await SaveAll(databaseName, tenantId, active, inactive, other); + await SetStatus(databaseName, tenantId, inactive.Id, ClientStatus.Inactive); + + var onlyActive = await List(databaseName, tenantId, status: ClientStatus.Active); + var onlyInactive = await List(databaseName, tenantId, status: ClientStatus.Inactive); + var everyone = await List(databaseName, tenantId, status: null); + + Names(onlyActive).Should().Equal("Ana Lima", "Carla Souza"); + onlyActive.TotalCount.Should().Be(2); + Names(onlyInactive).Should().Equal("Bruno Dias"); + onlyInactive.TotalCount.Should().Be(1); + Names(everyone).Should().Equal("Ana Lima", "Bruno Dias", "Carla Souza"); + everyone.TotalCount.Should().Be(3); + } + + [Fact] + public async Task List_OrdersByTheNormalizedNameNotByTheTypedCasing() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + await SaveAll( + databaseName, + tenantId, + NewClient("bruno Dias"), + NewClient("Carla Souza"), + NewClient("ANA Lima")); + + var page = await List(databaseName, tenantId); + + Names(page).Should().Equal("ANA Lima", "bruno Dias", "Carla Souza"); + } + + [Fact] + public async Task List_BreaksATieOfTheSameNameById() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var clients = Enumerable.Range(0, 6).Select(_ => NewClient("Maria Souza")).ToArray(); + await SaveAll(databaseName, tenantId, clients); + + var page = await List(databaseName, tenantId); + + page.Items.Select(client => client.Id).Should().Equal(clients.Select(client => client.Id).Order()); + } + + [Fact] + public async Task List_PagesWithoutRepeatingOrOmittingARecord() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var clients = Enumerable.Range(0, 7) + .Select(index => NewClient(index % 2 == 0 ? "Maria Souza" : $"Pessoa {index}")) + .ToArray(); + await SaveAll(databaseName, tenantId, clients); + + var first = await List(databaseName, tenantId, page: 1, pageSize: 3); + var second = await List(databaseName, tenantId, page: 2, pageSize: 3); + var third = await List(databaseName, tenantId, page: 3, pageSize: 3); + var past = await List(databaseName, tenantId, page: 4, pageSize: 3); + + first.Items.Should().HaveCount(3); + second.Items.Should().HaveCount(3); + third.Items.Should().HaveCount(1); + past.Items.Should().BeEmpty(); + new[] { first, second, third, past }.Select(page => page.TotalCount).Should().OnlyContain(total => total == 7); + var seen = first.Items.Concat(second.Items).Concat(third.Items).Select(client => client.Id).ToArray(); + seen.Should().OnlyHaveUniqueItems(); + seen.Should().BeEquivalentTo(clients.Select(client => client.Id)); + var onePage = await List(databaseName, tenantId, page: 1, pageSize: 20); + seen.Should().Equal(onePage.Items.Select(client => client.Id)); + } + + [Fact] + public async Task List_SearchesTheNameAnywhereIgnoringCase() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + await SaveAll( + databaseName, + tenantId, + NewClient("Maria SILVA Souza"), + NewClient("Silvana Reis"), + NewClient("Ana Lima")); + + var page = await List(databaseName, tenantId, text: "silva"); + + Names(page).Should().Equal("Maria SILVA Souza", "Silvana Reis"); + page.TotalCount.Should().Be(2); + } + + [Fact] + public async Task List_SearchesTheEmailAnywhereAndStillFindsClientsWithoutEmailOrCpfByName() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + await SaveAll( + databaseName, + tenantId, + NewClient("Ana Lima", email: "Ana.Lima@Example.com"), + NewClient("Bruno Dias", MariaCpf, "bruno@gmail.com"), + NewClient("Carla Souza")); + + var byEmail = await List(databaseName, tenantId, text: "example"); + var byProviderOnly = await List(databaseName, tenantId, text: "@gmail"); + var byNameOfAClientWithoutEmail = await List(databaseName, tenantId, text: "carla"); + + Names(byEmail).Should().Equal("Ana Lima"); + Names(byProviderOnly).Should().Equal("Bruno Dias"); + Names(byNameOfAClientWithoutEmail).Should().Equal("Carla Souza"); + } + + [Fact] + public async Task List_SearchesTheCpfDigitsAnywhere_OnlyWhenTheHandlerSentThem() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + await SaveAll( + databaseName, + tenantId, + NewClient("Maria Souza", MariaCpf), + NewClient("José Pereira", JoseCpf), + NewClient("Paulo Reis", PauloCpf), + NewClient("Ana Lima")); + + var whole = await List(databaseName, tenantId, text: "529.982.247-25", cpfDigits: MariaCpf); + var partial = await List(databaseName, tenantId, text: "982", cpfDigits: "982"); + var withoutDigits = await List(databaseName, tenantId, text: "982"); + + Names(whole).Should().Equal("Maria Souza"); + Names(partial).Should().Equal("Maria Souza"); + withoutDigits.Items.Should().BeEmpty(); + } + + [Fact] + public async Task List_CombinesTheSearchWithTheSituationAndThePage() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var clients = new[] + { + NewClient("Ana Silva"), + NewClient("Bia Silva"), + NewClient("Caio Silva"), + NewClient("Dani Silva"), + NewClient("Edu Souza"), + }; + await SaveAll(databaseName, tenantId, clients); + await SetStatus(databaseName, tenantId, clients[1].Id, ClientStatus.Inactive); + await SetStatus(databaseName, tenantId, clients[4].Id, ClientStatus.Inactive); + + var activeSilvaFirstPage = await List(databaseName, tenantId, page: 1, pageSize: 2, text: "silva", status: ClientStatus.Active); + var activeSilvaSecondPage = await List(databaseName, tenantId, page: 2, pageSize: 2, text: "silva", status: ClientStatus.Active); + var inactiveSilva = await List(databaseName, tenantId, text: "silva", status: ClientStatus.Inactive); + var anySilva = await List(databaseName, tenantId, text: "silva", status: null); + + Names(activeSilvaFirstPage).Should().Equal("Ana Silva", "Caio Silva"); + Names(activeSilvaSecondPage).Should().Equal("Dani Silva"); + activeSilvaSecondPage.TotalCount.Should().Be(3); + Names(inactiveSilva).Should().Equal("Bia Silva"); + anySilva.TotalCount.Should().Be(4); + } + + [Fact] + public async Task List_ReturnsTheRootWithoutLoadingItsContacts() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var data = new ClientData( + FullName.Create("Ana Lima").Value, + null, + null, + null, + null, + null, + [new GuardianData("Bia Lima", "Mãe", null, null)], + []); + await SaveAll(databaseName, tenantId, Client.Create(data, Today).Value); + + var page = await List(databaseName, tenantId); + + page.Items.Should().ContainSingle().Which.Guardians.Should().BeEmpty(); + } + + [Fact] + public void Model_SearchesAndSortsGeneratedLowercaseColumns() + { + using var context = PersistenceContext.Create(Guid.NewGuid().ToString(), Guid.NewGuid()); + var clients = context.GetService().Model.FindEntityType(typeof(Client))!; + + var expressions = new Dictionary + { + ["NameNormalized"] = "lower(\"FullName\")", + ["EmailNormalized"] = "lower(\"Email\")", + ["CpfNormalized"] = "\"Cpf\"", + }; + foreach (var (name, sql) in expressions) + { + var property = clients.FindProperty(name)!; + property.IsShadowProperty().Should().BeTrue(name); + property.ClrType.Should().Be(typeof(string), name); + property.GetComputedColumnSql().Should().Be(sql, name); + property.GetIsStored().Should().BeTrue(name); + property.IsNullable.Should().BeTrue(name); + } + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs new file mode 100644 index 00000000..7607e3d8 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -0,0 +1,238 @@ +using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients.ListClients; + +namespace ServicesService.Tests.Clients.ListClients; + +public class ListClientsQueryHandlerTests +{ + private readonly IClientRepository _repository = Substitute.For(); + private readonly ListClientsQueryHandler _handler; + + public ListClientsQueryHandlerTests() + { + _handler = new ListClientsQueryHandler(_repository); + Returns([], 0); + } + + private void Returns(IReadOnlyList clients, int totalCount) + { + _repository + .ListAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult((clients, totalCount))); + } + + private static Client CompleteClient() + { + return Client.Create( + ClientTestData.Data( + ClientTestData.Name("Paula Rocha"), + birthDate: ClientTestData.Birth(new DateOnly(2015, 3, 10)), + phone: ClientTestData.Phone(), + email: ClientTestData.Email("paula@example.com"), + cpf: ClientTestData.Cpf(), + notes: ClientTestData.Notes(), + guardians: [ClientTestData.Guardian("Ana Souza", "Mãe")], + referenceContacts: [ClientTestData.ReferenceContact(ContactPurpose.Emergency)]), + ClientTestData.Today).Value; + } + + private static Client MinimalClient(string name) + { + return Client.Create(ClientTestData.Data(ClientTestData.Name(name)), ClientTestData.Today).Value; + } + + private async Task AssertListed(string? text, string? cpfDigits, ClientStatus? status, int page = 1, int pageSize = 20) + { + await _repository.Received(1).ListAsync(page, pageSize, text, cpfDigits, status, Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsEveryListFieldOfTheClientWithItsWholeCpf() + { + var client = CompleteClient(); + Returns([client], 1); + + var result = await _handler.Handle(new ListClientsQuery(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + var item = result.Value.Items.Should().ContainSingle().Subject; + item.Id.Should().Be(client.Id); + item.FullName.Should().Be("Paula Rocha"); + item.BirthDate.Should().Be(new DateOnly(2015, 3, 10)); + item.Phone.Should().Be("(11) 99999-0000"); + item.Email.Should().Be("paula@example.com"); + item.Cpf.Should().Be("52998224725"); + item.Status.Should().Be("active"); + } + + [Fact] + public async Task Handle_ForAClientWithOnlyTheRequiredFields_ReturnsNullForTheOptionalOnes() + { + Returns([MinimalClient("Paula Rocha")], 1); + + var result = await _handler.Handle(new ListClientsQuery(), CancellationToken.None); + + var item = result.Value.Items.Should().ContainSingle().Subject; + item.BirthDate.Should().BeNull(); + item.Phone.Should().BeNull(); + item.Email.Should().BeNull(); + item.Cpf.Should().BeNull(); + } + + [Fact] + public async Task Handle_ForAnInactiveClient_ReturnsTheInactiveSituation() + { + var client = MinimalClient("Paula Rocha"); + client.Inactivate(); + Returns([client], 1); + + var result = await _handler.Handle(new ListClientsQuery(Status: "all"), CancellationToken.None); + + result.Value.Items.Should().ContainSingle().Which.Status.Should().Be("inactive"); + } + + [Fact] + public async Task Handle_KeepsTheOrderTheRepositoryReturned() + { + Returns([MinimalClient("Ana"), MinimalClient("Bia"), MinimalClient("Carla")], 3); + + var result = await _handler.Handle(new ListClientsQuery(), CancellationToken.None); + + result.Value.Items.Select(item => item.FullName).Should().Equal("Ana", "Bia", "Carla"); + } + + [Fact] + public async Task Handle_EchoesThePageTheTotalAndThePageSize() + { + Returns([MinimalClient("Ana"), MinimalClient("Bia")], 7); + + var result = await _handler.Handle(new ListClientsQuery(Page: 2, PageSize: 2), CancellationToken.None); + + result.Value.Page.Should().Be(2); + result.Value.PageSize.Should().Be(2); + result.Value.TotalCount.Should().Be(7); + result.Value.Items.Should().HaveCount(2); + await AssertListed(null, null, ClientStatus.Active, page: 2, pageSize: 2); + } + + [Fact] + public async Task Handle_WithAPagePastTheLast_ReturnsAnEmptyPageAndTheTotal() + { + Returns([], 3); + + var result = await _handler.Handle(new ListClientsQuery(Page: 9, PageSize: 20), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().BeEmpty(); + result.Value.TotalCount.Should().Be(3); + result.Value.Page.Should().Be(9); + } + + [Fact] + public async Task Handle_WithoutFilters_AsksForTheActiveOnesWithoutSearch() + { + await _handler.Handle(new ListClientsQuery(), CancellationToken.None); + + await AssertListed(null, null, ClientStatus.Active); + } + + [Theory] + [InlineData("active", ClientStatus.Active)] + [InlineData("ACTIVE", ClientStatus.Active)] + [InlineData("inactive", ClientStatus.Inactive)] + [InlineData("Inactive", ClientStatus.Inactive)] + [InlineData(null, ClientStatus.Active)] + [InlineData("", ClientStatus.Active)] + [InlineData(" ", ClientStatus.Active)] + public async Task Handle_MapsTheStatusFilterToTheClientStatus(string? status, ClientStatus expected) + { + await _handler.Handle(new ListClientsQuery(Status: status), CancellationToken.None); + + await AssertListed(null, null, expected); + } + + [Theory] + [InlineData("all")] + [InlineData("ALL")] + public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction(string status) + { + await _handler.Handle(new ListClientsQuery(Status: status), CancellationToken.None); + + await AssertListed(null, null, null); + } + + [Theory] + [InlineData(" Maria SILVA ", "maria silva")] + [InlineData("MARIA@Example.COM", "maria@example.com")] + [InlineData("josé", "josé")] + [InlineData("50%_off", "50%_off")] + public async Task Handle_SearchesTheTrimmedLowercaseText(string search, string expectedText) + { + await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + + await AssertListed(expectedText, null, ClientStatus.Active); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Handle_WithABlankSearch_DoesNotSearch(string? search) + { + await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + + await AssertListed(null, null, ClientStatus.Active); + } + + [Theory] + [InlineData("529.982.247-25", "529.982.247-25", "52998224725")] + [InlineData("52998224725", "52998224725", "52998224725")] + [InlineData(" 529 982 247 25 ", "529 982 247 25", "52998224725")] + [InlineData("123", "123", "123")] + [InlineData("456.789", "456.789", "456789")] + public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(string search, string expectedText, string expectedDigits) + { + await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + + await AssertListed(expectedText, expectedDigits, ClientStatus.Active); + } + + [Theory] + [InlineData("maria")] + [InlineData("maria.silva@example.com")] + [InlineData("ana-lima")] + [InlineData("a1")] + [InlineData("529.982.247-2x")] + [InlineData(".")] + [InlineData("-")] + public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(string search) + { + await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + + await AssertListed(search, null, ClientStatus.Active); + } + + [Fact] + public async Task Handle_CombinesTheSearchWithTheStatusFilter() + { + await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: "Silva", Status: "inactive"), CancellationToken.None); + + await AssertListed("silva", null, ClientStatus.Inactive, page: 3, pageSize: 50); + } + + [Fact] + public async Task Handle_OnlyReads() + { + await _handler.Handle(new ListClientsQuery(), CancellationToken.None); + + await _repository.DidNotReceive().GetByIdAsync(Arg.Any(), Arg.Any()); + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs new file mode 100644 index 00000000..51ba9634 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -0,0 +1,121 @@ +using ServicesService.Application.Clients.ListClients; + +namespace ServicesService.Tests.Clients.ListClients; + +public class ListClientsQueryValidatorTests +{ + private readonly ListClientsQueryValidator _validator = new(); + + public static TheoryData EveryRule() + { + return new TheoryData + { + { "page below one", new ListClientsQuery(Page: 0), "Page", "Client.PageInvalid" }, + { "negative page", new ListClientsQuery(Page: -3), "Page", "Client.PageInvalid" }, + { "page whose offset overflows", new ListClientsQuery(Page: int.MaxValue), "Page", "Client.PageTooLarge" }, + { "page just past the offset limit", new ListClientsQuery(Page: 21_474_838, PageSize: 100), "Page", "Client.PageTooLarge" }, + { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Client.PageSizeInvalid" }, + { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Client.PageSizeInvalid" }, + { "search above the longest searchable value", new ListClientsQuery(Q: new string('a', 255)), "Q", "Client.SearchTooLong" }, + { "unknown status", new ListClientsQuery(Status: "deleted"), "Status", "Client.StatusFilterInvalid" }, + { "numeric status", new ListClientsQuery(Status: "1"), "Status", "Client.StatusFilterInvalid" }, + { "status with surrounding text", new ListClientsQuery(Status: "active,inactive"), "Status", "Client.StatusFilterInvalid" }, + }; + } + + [Theory] + [MemberData(nameof(EveryRule))] + public void Validate_WithAnInvalidRequest_RejectsItWithTheRulesCodeOnItsProperty( + string caseName, + ListClientsQuery query, + string property, + string code) + { + var result = _validator.Validate(query); + + var error = result.Errors.Should().ContainSingle(caseName).Subject; + error.PropertyName.Should().Be(property); + error.ErrorCode.Should().Be(code); + } + + [Fact] + public void Validate_WithTheDefaultQuery_Passes() + { + var result = _validator.Validate(new ListClientsQuery()); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEveryLimitAtItsBoundary_Passes() + { + _validator.Validate(new ListClientsQuery(Page: 1, PageSize: 1)).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery(Page: 21_474_837, PageSize: 100)).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery(PageSize: 100)).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery(Q: new string('a', 254))).IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("active")] + [InlineData("inactive")] + [InlineData("all")] + [InlineData("ACTIVE")] + [InlineData("All")] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Validate_WithAKnownOrBlankStatus_Passes(string? status) + { + var result = _validator.Validate(new ListClientsQuery(Status: status)); + + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("maria")] + [InlineData("529.982.247-25")] + public void Validate_WithAnySearchWithinTheLimit_Passes(string? search) + { + var result = _validator.Validate(new ListClientsQuery(Q: search)); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() + { + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 100)); + + result.Errors.Select(error => error.ErrorCode).Should().Equal("Client.PageInvalid"); + } + + [Fact] + public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() + { + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: "x")); + + result.Errors.Select(error => (error.PropertyName, error.ErrorCode)).Should().BeEquivalentTo( + [ + ("Page", "Client.PageInvalid"), + ("PageSize", "Client.PageSizeInvalid"), + ("Status", "Client.StatusFilterInvalid"), + ]); + } + + [Fact] + public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() + { + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Q: new string('a', 255), Status: "x")); + + result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( + [ + "A página deve ser maior ou igual a 1.", + "O tamanho da página deve ser entre 1 e 100.", + "A busca deve ter no máximo 254 caracteres.", + "A situação deve ser active, inactive ou all.", + ]); + } +} diff --git a/docs/API.md b/docs/API.md index 1bdb4572..41ab777b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -71,6 +71,22 @@ o único campo que muda por endpoint. {"data":{"items":[{"id":"...","code":1,"name":"Teste","description":null,"durationMinutes":2,"minDurationMinutes":1,"maxDurationMinutes":3,"price":10.00,"maxDiscountPercentage":10.00,"categoryId":null,"categoryName":null,"tags":[]}],"totalCount":1,"page":1,"pageSize":20},"success":true, "...":"..."} ``` +**Lista paginada com busca e filtro** — o mesmo `PagedResult`, com `q` (texto) e `status` (string) além de +`page`/`pageSize`. Exemplo, `GET /api/v1/clients` (padrão: `page=1`, `pageSize=20`, `status=active`, sem `q`), +verificado em 2026-10-12 num PostgreSQL descartável com a stack real em portas privadas +([ADR 0063](adr/0063-clients-list-and-search-contract.md)): + +```json +{"data":{"items":[{"id":"01a131d6-2bd6-7fcc-a1eb-aeaff8ac3721","fullName":"a_b Cliente","birthDate":null,"phone":null,"email":null,"cpf":null,"status":"active"},{"id":"01a131d6-2b9a-7a60-9c63-949aab1e7054","fullName":"Álvaro Reis","birthDate":null,"phone":null,"email":"alvaro@example.com","cpf":"39053344705","status":"active"}],"totalCount":21,"page":1,"pageSize":20},"success":true,"...":"..."} +``` + +Uma página além da última não é erro: `GET /api/v1/clients?page=99` responde `200` com a lista vazia, o total real e a +página pedida: + +```json +{"data":{"items":[],"totalCount":21,"page":99,"pageSize":20},"success":true,"...":"..."} +``` + **Criação** (`201 Created`, header `Location` presente, `data` é o recurso criado): ``` @@ -259,6 +275,10 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Campos de contato inválidos | `POST /api/v1/clients` | 400 | `Validation.Failed` (`guardians[0].name`: `ClientContact.NameRequired`, `referenceContacts[0].purposes`: `ClientReferenceContact.PurposesRequired`…) | | CPF inválido, em qualquer formatação (§4.3) | `POST /api/v1/clients` (`cpf` ou `guardians[0].cpf`) | 400 | `Validation.Failed` (`cpf`: `Validation.Failed`, "O CPF informado é inválido.") | | `fullName` ausente do JSON | `POST /api/v1/clients` | 400 (§4.3: mensagem do framework, em inglês) | `Validation.Failed` (`fullName`: `Validation.Failed`) | +| Filtro de situação desconhecido | `GET /api/v1/clients?status=deleted` | 400 | `Validation.Failed` (`status`: `Client.StatusFilterInvalid`, "A situação deve ser active, inactive ou all.") | +| Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Client.PageInvalid`; `pageSize`: `Client.PageSizeInvalid`) | +| Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Client.PageTooLarge`) | +| Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | O detalhe do meio da tabela (`00000000-...-0000`) é a pegadinha mais fácil de esquecer: a constraint de rota `{id:guid}` só valida **formato**, então um GUID zerado passa pelo roteamento normalmente e só é rejeitado dentro do `DeleteTagCommandValidator` (`NotEmpty`) — chega como 400 de diff --git a/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md b/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md index da070747..a2e2494d 100644 --- a/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md +++ b/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md @@ -3,7 +3,9 @@ Status: accepted (2026-10); the PascalCase key casing of the conflict contract (`Cpf`, `Email`) is superseded by [ADR 0051](0051-camelcase-error-keys-on-the-wire.md), which writes them as `cpf`, `email` on the wire; the `ContactPurposes` value object, the `ContactPurposeNames` translation and the "not a JSON enum" rule for -reference-contact purposes are superseded by [ADR 0058](0058-closed-set-members-are-plain-enums.md) +reference-contact purposes are superseded by [ADR 0058](0058-closed-set-members-are-plain-enums.md); the advice to use +`EF.Property` for text matching on a value-object column does not hold (it throws) and is corrected by +[ADR 0063](0063-clients-list-and-search-contract.md) ## Context diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md new file mode 100644 index 00000000..1d647892 --- /dev/null +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -0,0 +1,144 @@ +# ADR 0063 — Clients: list and search contract + +Status: accepted (2026-10); extends [ADR 0044](0044-clients-aggregate-uniqueness-and-conflict-contract.md) (the +situation) and [ADR 0060](0060-clients-read-by-id-contract.md) (the CPF on the wire); corrects the guidance of +[`ARCHITECTURE`](../../backend/docs/ARCHITECTURE.md) §5 on `EF.Property` over a value-object column + +## Context + +#155 adds `GET /api/v1/clients?page=&pageSize=&q=&status=` to list and search the people a business serves. The +client aggregate, the situation, the soft-delete and tenant filters and the paged-list shape (`PagedResult`) +already existed. Open were: the shape of a list item, whether the CPF is masked, what `q` matches, what "normalized +name" means for the order, and how an invalid parameter answers. + +One finding shaped the persistence of the search. `FullName`, `Email` and `Cpf` are shared value objects with a +column conversion, and a query cannot read such a column as text. A probe with the repository's EF Core and Npgsql +versions showed: + +- `EF.Property(c, "FullName")` (the same for `Email` and `Cpf`) throws `InvalidOperationException: No + coercion operator is defined between types 'FullName' and 'String'`. `ARCHITECTURE` §5 and the slice skill said to + use it for text matching; they were wrong for a converted column. +- A second, string-typed property mapped to the same column (`HasColumnName("Email")`) does not alias it: EF renames + the real column (`Email1`) in the generated SQL. +- Ordering by the whole value object (`OrderBy(c => c.FullName)`) translates, but sorts by the typed casing, which + is not "normalized". + +## Decision + +**Endpoint.** `GET /api/v1/clients` with `page` (from 1), `pageSize` (default 20, 1 to 100), `q` and `status`. It +answers `200` with `ApiResponse>`: `items`, `totalCount`, `page`, `pageSize`. +The members of the query are `Page`, `PageSize`, `Q` and `Status`: a validation error is keyed by the property path +in camelCase, so the member is named after the wire parameter. The OpenAPI document lists the parameters in +PascalCase, like every other list, and the binding is case-insensitive. + +**The item.** `ClientListItemResponse`: `id`, `fullName`, `birthDate`, `phone`, `email`, `cpf`, `status`. No +guardians, reference contacts or notes, no `age` (ADR 0060). The repository returns the client roots without their +contacts loaded, and the mapping reads the root only; the list is not a place to load children. + +**The CPF is not masked.** It comes whole, digits only, as the detail returns it. This is a product decision of the +owner on 2026-10-12, taken against the text of #155 ("Mascarar CPF na lista") and the criterion of the parent #139 +("CPF não aparece integralmente em listagens"); both need to be reconciled with it. It follows ADR 0060: the project +has no roles, every authenticated user of the tenant sees the whole CPF in the detail, and the list adds no new kind +of reader. What changes is volume: up to 100 whole CPFs per response. + +**Situation.** `status` is a string on the wire: `active` (default), `inactive` or `all`, in any case. Omitted or +blank is `active`; a number is refused. `all` is "no restriction", and soft-deleted people never appear, whatever the +value. The validator checks it, not the model binder: an enum bound by the framework answers an English message under +the generic code, and the issue asks for a stable code. + +**Search.** `q` is trimmed; blank means no search. The handler lowercases it and matches it as "contains": + +- in the name and in the e-mail; +- in the CPF digits too, when the term without `.`, `-` and spaces is only digits. A masked `529.982.247-25` finds + that person; `982` finds the CPFs that contain 982 and, as any term, the names and e-mails that contain it. + +The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is +found by name. The handler derives the two plain values (`text`, `cpfDigits`); the repository port takes them. + +**Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not +change. "Normalized" is `lower("FullName")`, like `Service`, `Tag` and `Category`: accents stay, so `jose` does not +find `José` (it would find `jose@…` by e-mail), and the order of accented names follows the database collation +(`en_US.utf8` in the `postgres:18.3` image: `Álvaro` sorts with `Alvaro`). The id tie-break is the PostgreSQL `uuid` +byte order. + +**Three stored generated columns** are the string handle the search and the order need, added by the additive +migration `AddClientSearchColumns`: + +| Column | Expression | +| --- | --- | +| `NameNormalized` | `lower("FullName")` | +| `EmailNormalized` | `lower("Email")` | +| `CpfNormalized` | `"Cpf"` | + +They are shadow properties of type `string` in `ClientConfiguration`, so `EF.Property(c, "NameNormalized") +.Contains(text)` translates to `LIKE @p`, and EF escapes `%`, `_` and `\` in the term: they are literal. (The `ILike` +of `Service`, `Tag` and `Category` sends `ESCAPE ''`, so `%` and `_` are wildcards there. This change does not touch +them.) PostgreSQL fills the columns for existing rows when the migration runs, and `Down` drops only them. Each +client load now reads three more short strings, because EF loads every mapped column of an entity. +`lower()` follows the database ctype, as it already does for the service names. + +**Validation.** Gate 2, with the codes below and pt-BR messages naming the field: + +| Rule | Code | +| --- | --- | +| `page` at least 1 | `Client.PageInvalid` | +| `(page - 1) * pageSize` fits an `int` | `Client.PageTooLarge` | +| `pageSize` from 1 to 100 | `Client.PageSizeInvalid` | +| `q` up to 254 characters, the longest searchable value (an e-mail) | `Client.SearchTooLong` | +| `status` one of the three words, or blank | `Client.StatusFilterInvalid` | + +`Client.PageTooLarge` exists because `RepositoryBase.ListPagedAsync` computes the offset in `int`: the older list +`GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error`, and `page=43000000` silently +computes an offset of 5,032,604 instead of 4,299,999,900, so a large enough table would answer a wrong page instead of +an empty one. A framework binding failure (`page=abc`) +stays gate 1: `400 Validation.Failed` with the framework's English text. + +**A page past the last** answers `200` with empty `items`, the real `totalCount` and the requested `page`. + +**No index.** Measured on the disposable PostgreSQL below with 50,000 people in one tenant (a tenth inactive), the +list answered in 30 to 45 ms at the median through HTTP in every case — default, `status=all`, a name term with 882 +hits, an e-mail term with 15,000, a whole CPF, a partial CPF, a term with no hit — and 132 ms at page 2000. `EXPLAIN +ANALYZE` showed a sequential scan of 16 to 20 ms. An index (a trigram one, for `LIKE '%x%'`) needs a measured problem +and an ADR of its own. + +## Consequences + +- Wire: one new operation; `services-api.d.ts` is regenerated in this change. +- `ARCHITECTURE` §5, the `agenza-backend-slice` skill (`persistence.md`) and §10 are corrected: text matching on a + value-object column goes through a generated string column, and the clients list is the reference for list, paging + and search. +- `count` and the page are two statements without a transaction, so with concurrent writes the total and the page + can disagree, as in the other lists. +- The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL + would, and a model test pins the expressions. What the real columns produce was verified by hand. +- Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does. +- The frontend (#155 screen) is another change. It sends `status` and `q` as plain strings and branches on the error + codes above. + +Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real +identity and services on private ports and a real login, 89 checks: the default list, the exact item keys and values +(whole CPF, `null` for the absent fields), the three situations and their case, a blank and a numeric `status`; +name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a long +term; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the +last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant, +inactive and soft-deleted people never leaking into a list, a total or a search; and a list writing nothing. The +migration was applied over five legacy rows (mixed-case name and e-mail, no e-mail, no CPF, inactive, deleted, +another tenant), reverted and applied again, with the rows intact in between. + +## Considered and rejected + +- **Masking the CPF in the backend** (`***.456.789-**`, the format of the Portal da Transparência, under `cpf` or + `maskedCpf`, or revealing only three digits). The first recommendation; the owner decided against masking. +- **`status` as an enum bound by MVC.** English message and the generic code. +- **Ordering or matching by an expression over the value-object column**, and a **string alias property on the + same column.** Neither translates (see Context). +- **Exact match only for the CPF or the e-mail, or a prefix match.** Cheaper (fewer or no extra columns) but the + owner chose "contains" in the three; a prefix would miss the surname. +- **An accent-insensitive normalization**, by `lower(translate(...))` (the character list would live in the migration + and again in .NET, and the two could drift) or by the `unaccent` extension (a `CREATE EXTENSION` in the infra init, + an `IMMUTABLE` wrapper to fit a generated column, a new mechanism). Nobody asked for it yet. +- **One combined `SearchText` column.** A term with a space could match across two fields. +- **`EF.Functions.ILike` with an escape character.** It cannot reach a value-object column. +- **A fixed cap on the page** instead of the offset rule. Any number is arbitrary. +- **Fixing the offset in `RepositoryBase.ListPagedAsync` in this change.** It is a shared project and changes the + services, tags and categories lists; it deserves its own change. diff --git a/docs/adr/README.md b/docs/adr/README.md index 14707e10..fc5c991d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ superseded is historical evidence, not current implementation guidance. | Service and data topology | 0001, 0002, 0003, 0024, 0029 | | Backend CQRS, validation, Result flow | 0005, 0006, 0007, 0009, 0012 as narrowed by 0049, 0014, 0018, 0048, 0049 as narrowed by 0061, 0051, 0057 | | Tenant assignment and persistence | 0006, 0008, 0009, 0017, 0019, 0024, 0028, 0046 | -| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062 | +| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063 | | Dates, times and time zones | 0045 | | Testing and runtime smokes | 0004, 0015 as narrowed by 0019/0026, 0026, 0050 | | Authentication UX and AI delegation | 0020, 0022 | @@ -85,4 +85,5 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0059 aggregate data records and handler-owned domain calls · 0060 clients read by id: one response, no age on the wire, contacts ordered by name · 0061 validators write their own rules · -0062 clients deactivate and reactivate. +0062 clients deactivate and reactivate · +0063 clients list and search: generated string columns, whole CPF, status as a validated string. From ac2ee1de32c4d2f92df534350670551be1bf7bd6 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Mon, 12 Oct 2026 20:42:48 -0300 Subject: [PATCH 02/24] =?UTF-8?q?feat(services):=20generalizar=20a=20pagin?= =?UTF-8?q?a=C3=A7=C3=A3o=20com=20PageRequest=20e=20PagedResult=20(#155,?= =?UTF-8?q?=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lista de pessoas passa a receber um PageRequest e a devolver um PagedResult, em vez de dois int e uma tupla que cada handler remontava. - Admin.SharedKernel: PageRequest (limites, Offset seguro, OffsetFits e os códigos Paging.*) e PagedResult.Map. - RepositoryBase.ListPagedAsync(PageRequest, filtro+ordem, ct) devolve PagedResult. O helper antigo continua para ListServices. - Admin.SharedKernel.EntityFrameworkCore passa a referenciar o kernel. - ListClients: a porta recebe o PageRequest, o handler faz Map e o validator usa as constantes do kernel e os códigos Paging.PageInvalid, Paging.PageSizeInvalid e Paging.PageTooLarge. - Testes de PageRequest e PagedResult.Map; testes de pessoas atualizados. - ADR 0064; ADR 0063, ARCHITECTURE, skills e API.md ajustados. Co-Authored-By: Claude Sonnet 5.5 --- .../references/persistence.md | 2 +- .../references/use-case.md | 6 +- backend/docs/ARCHITECTURE.md | 16 ++-- .../Abstractions/IClientRepository.cs | 7 +- .../Clients/ListClients/ListClientsQuery.cs | 4 +- .../ListClients/ListClientsQueryHandler.cs | 12 +-- .../ListClients/ListClientsQueryValidator.cs | 19 ++-- .../Repositories/ClientRepository.cs | 9 +- .../ClientListPersistenceTests.cs | 11 ++- .../ListClientsQueryHandlerTests.cs | 12 ++- .../ListClientsQueryValidatorTests.cs | 18 ++-- ...in.SharedKernel.EntityFrameworkCore.csproj | 1 + .../RepositoryBase.cs | 23 ++++- .../PageRequestTests.cs | 93 ++++++++++++++++++ .../PagedResultTests.cs | 64 +++++++++++++ .../shared/Admin.SharedKernel/PageRequest.cs | 44 +++++++++ .../shared/Admin.SharedKernel/PagedResult.cs | 8 +- docs/API.md | 4 +- .../0063-clients-list-and-search-contract.md | 19 ++-- ...ged-reads-page-request-and-paged-result.md | 95 +++++++++++++++++++ docs/adr/README.md | 5 +- 21 files changed, 402 insertions(+), 70 deletions(-) create mode 100644 backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/PagedResultTests.cs create mode 100644 backend/shared/Admin.SharedKernel/PageRequest.cs create mode 100644 docs/adr/0064-paged-reads-page-request-and-paged-result.md diff --git a/.claude/skills/agenza-backend-slice/references/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index 68a00c17..12ca43a6 100644 --- a/.claude/skills/agenza-backend-slice/references/persistence.md +++ b/.claude/skills/agenza-backend-slice/references/persistence.md @@ -17,7 +17,7 @@ no `Remove` until a delete exists, no `ListAsync` until a list exists. | `CountByAsync(id)` | `int` | an "in use" rule | | `GetByIdsAsync(ids)` | `IReadOnlyList` | one query for a set — never one per row | | `ListAsync(filters)` | `IReadOnlyList` | an unpaged list, ordered | -| `ListAsync(page, pageSize, filters)` | `(IReadOnlyList Items, int TotalCount)` | a paged list, ordered | +| `ListAsync(PageRequest, filters)` | `PagedResult` | a paged list, ordered: the adapter calls `ListPagedAsync(page, filterAndOrder, ct)` | | `Add(entity)`, `UpdateAsync(entity)`, `Remove(entity)` | `void` / `Task` | stage; the handler commits | Parameters are ids, value objects for value-object columns and plain filter values — never a tenant, diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 196b0a10..7e2a0709 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -16,7 +16,8 @@ messages", plus the earlier slices for reads, edits and paging. This file is how nothing to register and no `Names` class. A list of enums gets an `IsInEnum` rule per item with the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. -- A list query carries its filters as optional members and `Page`/`PageSize` with defaults. +- A list query carries its filters as optional members and `Page`/`PageSize` with the `PageRequest` defaults; the + handler builds the `PageRequest` and the validator writes the three `Paging.*` rules (ADR 0064). ## 2. The validator — simple and clean @@ -113,7 +114,8 @@ No `try/catch`, no base class, no loader or service class between handlers. handler read. - The DTO decides nothing. A derived value the interface needs is exposed by the domain and copied here. -- A paged read returns `PagedResult<Response>`. +- A paged read returns `PagedResult<Response>`: `repository.ListAsync(…)` answers `PagedResult` and the + handler `Map`s it. ## 6. Red flags in a use case diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index c51b3291..d6c804b3 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -71,9 +71,9 @@ Shared projects in `backend/shared/` hold infrastructure, never business rules: | Project | Holds | Referenced by | | --- | --- | --- | -| `Admin.SharedKernel` | `Result`, `Error`/`ErrorType`/`FieldError`, CQRS contracts, `IDispatcher`, `PagedResult`, the JSON converters of the wire (value objects, enum names) | Application | +| `Admin.SharedKernel` | `Result`, `Error`/`ErrorType`/`FieldError`, CQRS contracts, `IDispatcher`, `PageRequest` and `PagedResult`, the JSON converters of the wire (value objects, enum names) | Application | | `Admin.SharedKernel.AspNetCore` | `ToActionResult`, `ApiResponse`, `ApiProblemDetails`, `AgenzaControllerBase`, `GenericExceptionHandler`, `AddWireJson()` | Api | -| `Admin.SharedKernel.EntityFrameworkCore` | `RepositoryBase`, `ApplyAuditableConventions` (soft-delete and tenant filters), the column conversion of the shared value objects | Infrastructure | +| `Admin.SharedKernel.EntityFrameworkCore` | `RepositoryBase` (with the paged read over `PageRequest`), `ApplyAuditableConventions` (soft-delete and tenant filters), the column conversion of the shared value objects | Infrastructure | | `Admin.SharedKernel.ValueObjects` | `IStringValueObject` and the string value objects every service shares (`CpfNumber`); no reference at all | Domain | | `Admin.Identity.Client` | JWT validation, `ITenantAccessor`, `ICurrentUserAccessor`, `TenantHeaderFilter`, `[IgnoreTenant]` | Infrastructure, Api | | `Admin.Logging` | the Serilog pipeline: console format, default levels, OTLP export, one line per request | `ServiceDefaults`, `AppHost` — never a layer | @@ -340,7 +340,7 @@ no tracking for reads. `ServicesDataContext` sets that behavior globally; shared it explicitly for a service outside that decision. A command loads its aggregate with `GetByIdAsync`, applies the domain behaviour, then stages persistence with `UpdateAsync`; the port does not expose EF tracking. An EF adapter makes any required state changes explicitly inside that operation. Paged reads -go through `ListPagedAsync`. The tenant +take a `PageRequest` and return a `PagedResult` through `ListPagedAsync` ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). The tenant and soft-delete filters come from the `DbContext`: a repository never writes a `TenantId` or `DeletedAt` predicate. A query cannot reach `.Value` through a converter: compare whole value objects or order by the property. Matching or sorting a value-object column as text — `EF.Property` over it throws — goes through a @@ -384,9 +384,11 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult translation class. Each item of a list of enums has an `IsInEnum` rule with the domain's code ([0058](../../docs/adr/0058-closed-set-members-are-plain-enums.md)). - The wire is camelCase English; calendar dates are `DateOnly` (`yyyy-MM-dd`); instants are UTC. -- A paged list takes `Page`/`PageSize` with defaults and a validator bounding them — the offset - `(Page - 1) * PageSize` has to fit an `int` too, because `ListPagedAsync` computes it in `int` — and returns - `PagedResult`; an unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items. +- A paged list takes `Page`/`PageSize` with the `PageRequest` defaults and a validator that writes its three rules + (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared + `Paging.*` codes; the handler builds a `PageRequest`, the port takes it and returns `PagedResult`, and the + handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` + with no items ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). A change to an endpoint's contract regenerates the frontend's types in the same change — skill [`agenza-api-contract`](../../.claude/skills/agenza-api-contract/SKILL.md). A change to a shape that @@ -457,7 +459,7 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a validator with codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `PageRequest` in and a `PagedResult` out, a validator with the `Paging.*` codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryHandler.cs` (page and size as `int`, a tuple back), `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | | Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index 1d36a1e7..284ad5f4 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -1,12 +1,13 @@ +using Admin.SharedKernel; + namespace ServicesService.Application.Abstractions; public interface IClientRepository { Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken); - Task<(IReadOnlyList Items, int TotalCount)> ListAsync( - int page, - int pageSize, + Task> ListAsync( + PageRequest page, string? text, string? cpfDigits, ClientStatus? status, diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index fe7dfac5..d00c1555 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -3,8 +3,8 @@ namespace ServicesService.Application.Clients.ListClients; public sealed record ListClientsQuery( - int Page = 1, - int PageSize = 20, + int Page = PageRequest.FirstPage, + int PageSize = PageRequest.DefaultPageSize, string? Q = null, string? Status = ListClientsQuery.ActiveStatus) : IQuery> { diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 174d964e..e74595cd 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -14,17 +14,11 @@ public async Task>> Handle( var cpfDigits = ExtractCpfDigits(text); var status = ToStatusFilter(query.Status); - var (clients, totalCount) = await clientRepository.ListAsync( - query.Page, - query.PageSize, - text, - cpfDigits, - status, - cancellationToken); + var page = new PageRequest(query.Page, query.PageSize); - var items = clients.Select(ClientListItemResponse.FromClient).ToList(); + var clients = await clientRepository.ListAsync(page, text, cpfDigits, status, cancellationToken); - return Result.Success(new PagedResult(items, totalCount, query.Page, query.PageSize)); + return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } private static string? NormalizeText(string? term) diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs index 55d39c4b..4f26279e 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -1,11 +1,10 @@ +using Admin.SharedKernel; using FluentValidation; namespace ServicesService.Application.Clients.ListClients; public sealed class ListClientsQueryValidator : AbstractValidator { - private const int MaxPageSize = 100; - private static readonly string[] KnownStatuses = [ ListClientsQuery.ActiveStatus, @@ -17,17 +16,17 @@ public ListClientsQueryValidator() { RuleFor(query => query.Page) .Cascade(CascadeMode.Stop) - .GreaterThanOrEqualTo(1) - .WithErrorCode("Client.PageInvalid") - .WithMessage("A página deve ser maior ou igual a 1.") - .Must((query, page) => (long)(page - 1) * query.PageSize <= int.MaxValue) - .WithErrorCode("Client.PageTooLarge") + .GreaterThanOrEqualTo(PageRequest.FirstPage) + .WithErrorCode(PageRequest.PageInvalidCode) + .WithMessage($"A página deve ser maior ou igual a {PageRequest.FirstPage}.") + .Must((query, page) => PageRequest.OffsetFits(page, query.PageSize)) + .WithErrorCode(PageRequest.PageTooLargeCode) .WithMessage("A página informada está além do limite permitido."); RuleFor(query => query.PageSize) - .InclusiveBetween(1, MaxPageSize) - .WithErrorCode("Client.PageSizeInvalid") - .WithMessage($"O tamanho da página deve ser entre 1 e {MaxPageSize}."); + .InclusiveBetween(1, PageRequest.MaxPageSize) + .WithErrorCode(PageRequest.PageSizeInvalidCode) + .WithMessage($"O tamanho da página deve ser entre 1 e {PageRequest.MaxPageSize}."); RuleFor(query => query.Q) .MaximumLength(EmailAddress.MaxLength) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index ea7c1e7d..516f59e9 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -1,3 +1,4 @@ +using Admin.SharedKernel; using Admin.SharedKernel.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ServicesService.Application.Abstractions; @@ -20,18 +21,16 @@ public ClientRepository(ServicesDataContext dbContext) .FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); } - public Task<(IReadOnlyList Items, int TotalCount)> ListAsync( - int page, - int pageSize, + public Task> ListAsync( + PageRequest page, string? text, string? cpfDigits, ClientStatus? status, CancellationToken cancellationToken) { return ListPagedAsync( - query => FilterAndOrder(query, text, cpfDigits, status), page, - pageSize, + query => FilterAndOrder(query, text, cpfDigits, status), cancellationToken); } diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs index fa4afaa9..edc83432 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -1,3 +1,4 @@ +using Admin.SharedKernel; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -65,7 +66,7 @@ private static async Task SoftDelete(string databaseName, Guid tenantId, Guid cl await context.SaveChangesAsync(); } - private static async Task<(IReadOnlyList Items, int TotalCount)> List( + private static async Task> List( string databaseName, Guid tenantId, int page = 1, @@ -75,10 +76,11 @@ private static async Task SoftDelete(string databaseName, Guid tenantId, Guid cl ClientStatus? status = null) { await using var context = PersistenceContext.Create(databaseName, tenantId); - return await new ClientRepository(context).ListAsync(page, pageSize, text, cpfDigits, status, CancellationToken.None); + return await new ClientRepository(context) + .ListAsync(new PageRequest(page, pageSize), text, cpfDigits, status, CancellationToken.None); } - private static string[] Names((IReadOnlyList Items, int TotalCount) page) + private static string[] Names(PagedResult page) { return page.Items.Select(client => client.FullName.Value).ToArray(); } @@ -131,7 +133,7 @@ public async Task List_WithNoTenantInContext_ReturnsNothing() await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); var page = await new ClientRepository(tenantlessContext) - .ListAsync(1, 20, null, null, null, CancellationToken.None); + .ListAsync(new PageRequest(1, 20), null, null, null, CancellationToken.None); page.Items.Should().BeEmpty(); page.TotalCount.Should().Be(0); @@ -232,6 +234,7 @@ public async Task List_PagesWithoutRepeatingOrOmittingARecord() third.Items.Should().HaveCount(1); past.Items.Should().BeEmpty(); new[] { first, second, third, past }.Select(page => page.TotalCount).Should().OnlyContain(total => total == 7); + new[] { first, second, third, past }.Select(page => (page.Page, page.PageSize)).Should().Equal((1, 3), (2, 3), (3, 3), (4, 3)); var seen = first.Items.Concat(second.Items).Concat(third.Items).Select(client => client.Id).ToArray(); seen.Should().OnlyHaveUniqueItems(); seen.Should().BeEquivalentTo(clients.Select(client => client.Id)); diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 7607e3d8..2a9202c9 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -1,3 +1,4 @@ +using Admin.SharedKernel; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.ListClients; @@ -18,13 +19,16 @@ private void Returns(IReadOnlyList clients, int totalCount) { _repository .ListAsync( - Arg.Any(), - Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult((clients, totalCount))); + .Returns(callInfo => + { + var page = callInfo.Arg(); + return Task.FromResult(new PagedResult(clients, totalCount, page.Page, page.PageSize)); + }); } private static Client CompleteClient() @@ -49,7 +53,7 @@ private static Client MinimalClient(string name) private async Task AssertListed(string? text, string? cpfDigits, ClientStatus? status, int page = 1, int pageSize = 20) { - await _repository.Received(1).ListAsync(page, pageSize, text, cpfDigits, status, Arg.Any()); + await _repository.Received(1).ListAsync(new PageRequest(page, pageSize), text, cpfDigits, status, Arg.Any()); } [Fact] diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs index 51ba9634..90c27e40 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -10,12 +10,12 @@ public static TheoryData EveryRule() { return new TheoryData { - { "page below one", new ListClientsQuery(Page: 0), "Page", "Client.PageInvalid" }, - { "negative page", new ListClientsQuery(Page: -3), "Page", "Client.PageInvalid" }, - { "page whose offset overflows", new ListClientsQuery(Page: int.MaxValue), "Page", "Client.PageTooLarge" }, - { "page just past the offset limit", new ListClientsQuery(Page: 21_474_838, PageSize: 100), "Page", "Client.PageTooLarge" }, - { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Client.PageSizeInvalid" }, - { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Client.PageSizeInvalid" }, + { "page below one", new ListClientsQuery(Page: 0), "Page", "Paging.PageInvalid" }, + { "negative page", new ListClientsQuery(Page: -3), "Page", "Paging.PageInvalid" }, + { "page whose offset overflows", new ListClientsQuery(Page: int.MaxValue), "Page", "Paging.PageTooLarge" }, + { "page just past the offset limit", new ListClientsQuery(Page: 21_474_838, PageSize: 100), "Page", "Paging.PageTooLarge" }, + { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Paging.PageSizeInvalid" }, + { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Paging.PageSizeInvalid" }, { "search above the longest searchable value", new ListClientsQuery(Q: new string('a', 255)), "Q", "Client.SearchTooLong" }, { "unknown status", new ListClientsQuery(Status: "deleted"), "Status", "Client.StatusFilterInvalid" }, { "numeric status", new ListClientsQuery(Status: "1"), "Status", "Client.StatusFilterInvalid" }, @@ -89,7 +89,7 @@ public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() { var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 100)); - result.Errors.Select(error => error.ErrorCode).Should().Equal("Client.PageInvalid"); + result.Errors.Select(error => error.ErrorCode).Should().Equal("Paging.PageInvalid"); } [Fact] @@ -99,8 +99,8 @@ public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() result.Errors.Select(error => (error.PropertyName, error.ErrorCode)).Should().BeEquivalentTo( [ - ("Page", "Client.PageInvalid"), - ("PageSize", "Client.PageSizeInvalid"), + ("Page", "Paging.PageInvalid"), + ("PageSize", "Paging.PageSizeInvalid"), ("Status", "Client.StatusFilterInvalid"), ]); } diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/Admin.SharedKernel.EntityFrameworkCore.csproj b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/Admin.SharedKernel.EntityFrameworkCore.csproj index 2629a431..3c474482 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/Admin.SharedKernel.EntityFrameworkCore.csproj +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/Admin.SharedKernel.EntityFrameworkCore.csproj @@ -17,6 +17,7 @@ + diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs index 59d99759..924a9cf7 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs @@ -48,6 +48,18 @@ protected async Task> ListAsync( return await query.ToListAsync(cancellationToken); } + protected async Task> ListPagedAsync( + PageRequest page, + Func, IQueryable> filterAndOrder, + CancellationToken cancellationToken) + { + var query = filterAndOrder(Set.AsNoTracking()); + + var (items, totalCount) = await ReadPageAsync(query, page.Offset, page.PageSize, cancellationToken); + + return new PagedResult(items, totalCount, page.Page, page.PageSize); + } + protected async Task<(IReadOnlyList Items, int TotalCount)> ListPagedAsync( Func, IQueryable>? order, int page, @@ -60,8 +72,17 @@ protected async Task> ListAsync( query = order(query); } + return await ReadPageAsync(query, (page - 1) * pageSize, pageSize, cancellationToken); + } + + private static async Task<(IReadOnlyList Items, int TotalCount)> ReadPageAsync( + IQueryable query, + int skip, + int take, + CancellationToken cancellationToken) + { var totalCount = await query.CountAsync(cancellationToken); - var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); + var items = await query.Skip(skip).Take(take).ToListAsync(cancellationToken); return (items, totalCount); } diff --git a/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs new file mode 100644 index 00000000..128e3bcd --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs @@ -0,0 +1,93 @@ +namespace Admin.SharedKernel.Tests; + +public class PageRequestTests +{ + [Fact] + public void Constructor_WithAValidPage_KeepsThePageAndThePageSize() + { + var request = new PageRequest(3, 50); + + request.Page.Should().Be(3); + request.PageSize.Should().Be(50); + } + + [Theory] + [InlineData(1, 20, 0)] + [InlineData(2, 20, 20)] + [InlineData(3, 50, 100)] + [InlineData(1, 1, 0)] + [InlineData(21_474_837, 100, 2_147_483_600)] + public void Offset_IsThePageBeforeTimesThePageSize(int page, int pageSize, int expected) + { + new PageRequest(page, pageSize).Offset.Should().Be(expected); + } + + [Theory] + [InlineData(0, 20)] + [InlineData(-1, 20)] + [InlineData(int.MinValue, 20)] + public void Constructor_WithAPageBelowTheFirst_Throws(int page, int pageSize) + { + var act = () => new PageRequest(page, pageSize); + + act.Should().Throw().WithParameterName("page"); + } + + [Theory] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void Constructor_WithAnEmptyPageSize_Throws(int page, int pageSize) + { + var act = () => new PageRequest(page, pageSize); + + act.Should().Throw().WithParameterName("pageSize"); + } + + [Theory] + [InlineData(int.MaxValue, 20)] + [InlineData(21_474_838, 100)] + [InlineData(43_000_000, 100)] + public void Constructor_WithAnOffsetThatDoesNotFitAnInt_Throws(int page, int pageSize) + { + var act = () => new PageRequest(page, pageSize); + + act.Should().Throw().WithParameterName("page"); + } + + [Theory] + [InlineData(1, 100, true)] + [InlineData(21_474_837, 100, true)] + [InlineData(21_474_838, 100, false)] + [InlineData(int.MaxValue, 1, true)] + [InlineData(int.MaxValue, 2, false)] + [InlineData(0, 100, true)] + [InlineData(1, 0, true)] + public void OffsetFits_IsTrueWhileThePageBeforeTimesThePageSizeFitsAnInt(int page, int pageSize, bool expected) + { + PageRequest.OffsetFits(page, pageSize).Should().Be(expected); + } + + [Fact] + public void TwoRequestsForTheSamePage_AreEqual() + { + new PageRequest(2, 20).Should().Be(new PageRequest(2, 20)); + new PageRequest(2, 20).Should().NotBe(new PageRequest(3, 20)); + new PageRequest(2, 20).Should().NotBe(new PageRequest(2, 10)); + } + + [Fact] + public void TheLimits_AreTheOnesTheListsPromise() + { + PageRequest.FirstPage.Should().Be(1); + PageRequest.DefaultPageSize.Should().Be(20); + PageRequest.MaxPageSize.Should().Be(100); + } + + [Fact] + public void TheErrorCodes_AreTheStableOnesClientsBranchOn() + { + PageRequest.PageInvalidCode.Should().Be("Paging.PageInvalid"); + PageRequest.PageSizeInvalidCode.Should().Be("Paging.PageSizeInvalid"); + PageRequest.PageTooLargeCode.Should().Be("Paging.PageTooLarge"); + } +} diff --git a/backend/shared/Admin.SharedKernel.Tests/PagedResultTests.cs b/backend/shared/Admin.SharedKernel.Tests/PagedResultTests.cs new file mode 100644 index 00000000..affce76b --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/PagedResultTests.cs @@ -0,0 +1,64 @@ +namespace Admin.SharedKernel.Tests; + +public class PagedResultTests +{ + [Fact] + public void Map_ConvertsEveryItemKeepingTheirOrder() + { + var page = new PagedResult([3, 1, 2], 10, 2, 3); + + var mapped = page.Map(number => $"#{number}"); + + mapped.Items.Should().Equal("#3", "#1", "#2"); + } + + [Fact] + public void Map_KeepsTheTotalThePageAndThePageSize() + { + var page = new PagedResult([1], 41, 5, 8); + + var mapped = page.Map(number => number * 2); + + mapped.TotalCount.Should().Be(41); + mapped.Page.Should().Be(5); + mapped.PageSize.Should().Be(8); + } + + [Fact] + public void Map_OfAnEmptyPage_IsAnEmptyPageWithTheSameTotal() + { + var page = new PagedResult([], 7, 9, 20); + + var mapped = page.Map(number => number.ToString()); + + mapped.Items.Should().BeEmpty(); + mapped.TotalCount.Should().Be(7); + mapped.Page.Should().Be(9); + } + + [Fact] + public void Map_LeavesTheSourcePageUntouched() + { + var page = new PagedResult([1, 2], 2, 1, 20); + + _ = page.Map(number => number + 1); + + page.Items.Should().Equal(1, 2); + } + + [Fact] + public void Map_CallsTheConversionOncePerItem() + { + var calls = 0; + var page = new PagedResult([1, 2, 3], 3, 1, 20); + + var mapped = page.Map(number => + { + calls++; + return number; + }); + + calls.Should().Be(3); + mapped.Items.Should().HaveCount(3); + } +} diff --git a/backend/shared/Admin.SharedKernel/PageRequest.cs b/backend/shared/Admin.SharedKernel/PageRequest.cs new file mode 100644 index 00000000..6a979713 --- /dev/null +++ b/backend/shared/Admin.SharedKernel/PageRequest.cs @@ -0,0 +1,44 @@ +namespace Admin.SharedKernel; + +public sealed record PageRequest +{ + public const int FirstPage = 1; + public const int DefaultPageSize = 20; + public const int MaxPageSize = 100; + + public const string PageInvalidCode = "Paging.PageInvalid"; + public const string PageSizeInvalidCode = "Paging.PageSizeInvalid"; + public const string PageTooLargeCode = "Paging.PageTooLarge"; + + public PageRequest(int page, int pageSize) + { + if (page < FirstPage) + { + throw new ArgumentOutOfRangeException(nameof(page), page, $"The page must be at least {FirstPage}."); + } + + if (pageSize < 1) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "The page size must be at least 1."); + } + + if (!OffsetFits(page, pageSize)) + { + throw new ArgumentOutOfRangeException(nameof(page), page, "The offset of the page does not fit an int."); + } + + Page = page; + PageSize = pageSize; + } + + public int Page { get; } + + public int PageSize { get; } + + public int Offset => (Page - 1) * PageSize; + + public static bool OffsetFits(int page, int pageSize) + { + return (long)(page - 1) * pageSize <= int.MaxValue; + } +} diff --git a/backend/shared/Admin.SharedKernel/PagedResult.cs b/backend/shared/Admin.SharedKernel/PagedResult.cs index f1dd8555..ab50e623 100644 --- a/backend/shared/Admin.SharedKernel/PagedResult.cs +++ b/backend/shared/Admin.SharedKernel/PagedResult.cs @@ -1,3 +1,9 @@ namespace Admin.SharedKernel; -public sealed record PagedResult(IReadOnlyList Items, int TotalCount, int Page, int PageSize); +public sealed record PagedResult(IReadOnlyList Items, int TotalCount, int Page, int PageSize) +{ + public PagedResult Map(Func map) + { + return new PagedResult(Items.Select(map).ToList(), TotalCount, Page, PageSize); + } +} diff --git a/docs/API.md b/docs/API.md index 41ab777b..49ac66bd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -276,8 +276,8 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | CPF inválido, em qualquer formatação (§4.3) | `POST /api/v1/clients` (`cpf` ou `guardians[0].cpf`) | 400 | `Validation.Failed` (`cpf`: `Validation.Failed`, "O CPF informado é inválido.") | | `fullName` ausente do JSON | `POST /api/v1/clients` | 400 (§4.3: mensagem do framework, em inglês) | `Validation.Failed` (`fullName`: `Validation.Failed`) | | Filtro de situação desconhecido | `GET /api/v1/clients?status=deleted` | 400 | `Validation.Failed` (`status`: `Client.StatusFilterInvalid`, "A situação deve ser active, inactive ou all.") | -| Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Client.PageInvalid`; `pageSize`: `Client.PageSizeInvalid`) | -| Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Client.PageTooLarge`) | +| Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | +| Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | O detalhe do meio da tabela (`00000000-...-0000`) é a pegadinha mais fácil de esquecer: a constraint de rota `{id:guid}` só valida **formato**, então um GUID zerado passa pelo roteamento diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 1d647892..5df279a7 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -81,13 +81,14 @@ client load now reads three more short strings, because EF loads every mapped co | Rule | Code | | --- | --- | -| `page` at least 1 | `Client.PageInvalid` | -| `(page - 1) * pageSize` fits an `int` | `Client.PageTooLarge` | -| `pageSize` from 1 to 100 | `Client.PageSizeInvalid` | +| `page` at least 1 | `Paging.PageInvalid` | +| `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | +| `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | | `q` up to 254 characters, the longest searchable value (an e-mail) | `Client.SearchTooLong` | | `status` one of the three words, or blank | `Client.StatusFilterInvalid` | -`Client.PageTooLarge` exists because `RepositoryBase.ListPagedAsync` computes the offset in `int`: the older list +The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). +`Paging.PageTooLarge` exists because `RepositoryBase.ListPagedAsync(order, page, pageSize)` computes the offset in `int`: the older list `GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error`, and `page=43000000` silently computes an offset of 5,032,604 instead of 4,299,999,900, so a large enough table would answer a wrong page instead of an empty one. A framework binding failure (`page=abc`) @@ -116,14 +117,16 @@ and an ADR of its own. codes above. Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real -identity and services on private ports and a real login, 89 checks: the default list, the exact item keys and values +identity and services on private ports and a real login, 89 checks on the list: the default list, the exact item keys and values (whole CPF, `null` for the absent fields), the three situations and their case, a blank and a numeric `status`; name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a long term; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant, inactive and soft-deleted people never leaking into a list, a total or a search; and a list writing nothing. The migration was applied over five legacy rows (mixed-case name and e-mail, no e-mail, no CPF, inactive, deleted, -another tenant), reverted and applied again, with the rows intact in between. +another tenant), reverted and applied again, with the rows intact in between, and all three migrations were applied +from an empty database when the service started. Ten more checks cover the edit, deactivate and reactivate path with +the generated columns present: they follow a rename, a new CPF and a new e-mail, and the list order follows them. ## Considered and rejected @@ -140,5 +143,5 @@ another tenant), reverted and applied again, with the rows intact in between. - **One combined `SearchText` column.** A term with a space could match across two fields. - **`EF.Functions.ILike` with an escape character.** It cannot reach a value-object column. - **A fixed cap on the page** instead of the offset rule. Any number is arbitrary. -- **Fixing the offset in `RepositoryBase.ListPagedAsync` in this change.** It is a shared project and changes the - services, tags and categories lists; it deserves its own change. +- **Fixing the offset of the older `RepositoryBase.ListPagedAsync(order, page, pageSize)` in this change.** It changes + the services list; converting it to `PageRequest` is a change of its own (ADR 0064). diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md new file mode 100644 index 00000000..f872220c --- /dev/null +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -0,0 +1,95 @@ +# ADR 0064 — Paged reads: one page request in, one paged result out + +Status: accepted (2026-10); extends [ADR 0061](0061-validators-write-their-own-rules.md) (the validator keeps writing +its own rules) and [ADR 0063](0063-clients-list-and-search-contract.md) (the first list to use it) + +## Context + +Two lists are paged: services and, with #155, clients. Each one repeated the same plumbing: + +- the port took `int page, int pageSize` and returned a tuple `(Items, TotalCount)`; +- `RepositoryBase.ListPagedAsync(order, page, pageSize)` computed the offset in `int`, so the older list + `GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error` and `page=43000000` silently + computes an offset of 5,032,604 instead of 4,299,999,900; +- the handler rebuilt the `PagedResult` envelope by hand from the tuple and the query; +- each validator wrote the limits as literals (`1`, `100`) and, for services, with the validator library's internal + codes. + +The next paged read would copy all of it. The change asks for the same behaviour to be reusable: the object the +repository receives, and the repository operation itself. + +## Decision + +**`PageRequest(Page, PageSize)`**, in `Admin.SharedKernel` next to `PagedResult`, is what a paged read hands to the +repository. Building one that cannot be served throws `ArgumentOutOfRangeException` (page below 1, size below 1, an +offset that does not fit an `int`): a programmer-error guard, because the validator already refused those inputs +with a `400`. It exposes `Offset`, the limits (`FirstPage` 1, `DefaultPageSize` 20, `MaxPageSize` 100), the stable +error codes below and `OffsetFits(page, pageSize)`, the one formula the validators and the constructor share. + +**`RepositoryBase.ListPagedAsync(PageRequest, filterAndOrder, ct)`** returns `PagedResult`: the items, the +real total, and the page and size it was asked for. The filter-and-order function is required: a page without an +explicit order is a bug. + +**`PagedResult.Map(...)`** converts the items and keeps the total, the page and the size, so a handler is +`repository.ListAsync(new PageRequest(query.Page, query.PageSize), , ct)` followed by +`.Map(ListItemResponse.From)`. + +**The port** of a paged list is `ListAsync(PageRequest page, , ct)` returning `PagedResult`. +The filters stay plain values per port: ids, value objects, strings, a status. Nothing generic filters for the +repository (see below). + +**Validation** stays in each validator ([ADR 0061](0061-validators-write-their-own-rules.md)): the limits come from +the constants of `PageRequest`, and the three rules carry codes that every list shares, so a client branches once: + +| Rule | Code | +| --- | --- | +| `page` at least 1 | `Paging.PageInvalid` | +| `pageSize` from 1 to `MaxPageSize` | `Paging.PageSizeInvalid` | +| `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | + +A page whose offset does not fit an `int` is a `400`, not an empty page: the client asked for something no table +holds, and the error says so. + +**The query stays flat.** `ListClientsQuery` keeps `Page` and `PageSize` as members, with `PageRequest` constants as +defaults, because the wire is `?page=&pageSize=`. The handler builds the `PageRequest`. + +**Only clients adopt it for now.** `ServiceRepository` and `ListServicesQueryHandler` keep the older +`ListPagedAsync(order, page, pageSize)`, which stays beside the new overload and shares its private slice-reading +step, so services behave exactly as before, including the two wrong answers above. Converting services means a +`PageRequest` in its port, a `Map` in its handler and the three rules with the shared codes in its validator. + +## Consequences + +- `Admin.SharedKernel.EntityFrameworkCore` now references `Admin.SharedKernel`, the direction that cannot leak EF + into Application (`Admin.SharedKernel` has no EF reference). `identity-service`'s Infrastructure already received it + through its Application project. +- Clients' error codes for the page rules are `Paging.*`, not `Client.Page*`. The frontend does not branch on the + services codes, so nothing there changes; the codes only existed on this unmerged branch for clients. +- Two ways to page exist in `RepositoryBase` until services is converted. The older one is the only unguarded one. +- A repository test cannot live in `Admin.SharedKernel.Tests` (it has no EF). The paged helper is proven through the + clients persistence tests (items, total, echoed page and size, a page past the last, no repeat or omission). +- The frontend's generated types do not change: `PagedResult` has the same schema. + +Verified on the same disposable PostgreSQL 18.3 as [ADR 0063](0063-clients-list-and-search-contract.md), on the final +code: the 89 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; +eleven checks that the services list did not change (five services created, paging with no repeat or omission, search, +a page past the last, `page=0` still `400`, and the `500` of `page=2147483647` still there); the volume run again with +50,000 people (30 to 45 ms at the median, 133 ms at page 2000); and the generated frontend types equal to a fresh +generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.SharedKernel.Tests`. + +## Considered and rejected + +- **A shared `PagedQueryValidator` (`Include`) over an `IPagedQuery` interface.** One source for the rules, but it + reopens [ADR 0061](0061-validators-write-their-own-rules.md) and adds an interface to the kernel for three + one-line rules. +- **A filter object or specification passed to the repository** (`ListAsync(PageRequest, ISpecification)`, or an + `Expression>`). It is what "filter any request" suggests, but the architecture keeps ports to plain + values ([ARCHITECTURE](../../backend/docs/ARCHITECTURE.md) §5): each list has its own filters, and the repository + stays the seam that knows how to apply them. +- **A base query record with `Page` and `PageSize`.** Binding positional members inherited through `[FromQuery]` + and a base type on every query go against "no base handler, no generic CRUD". +- **Clamping to an empty page in the helper, and dropping `Paging.PageTooLarge`.** Safe by construction for every + future list; the owner preferred the explicit `400`. +- **Codes per feature** (`Client.PageInvalid`, `Service.PageInvalid`). The same rule would have a code per list. +- **Converting services in the same change.** It touches an older slice and the `GET /services` contract inside the + #155 change; it is a change of its own. diff --git a/docs/adr/README.md b/docs/adr/README.md index fc5c991d..7e4c9179 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ superseded is historical evidence, not current implementation guidance. | Service and data topology | 0001, 0002, 0003, 0024, 0029 | | Backend CQRS, validation, Result flow | 0005, 0006, 0007, 0009, 0012 as narrowed by 0049, 0014, 0018, 0048, 0049 as narrowed by 0061, 0051, 0057 | | Tenant assignment and persistence | 0006, 0008, 0009, 0017, 0019, 0024, 0028, 0046 | -| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063 | +| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063, 0064 | | Dates, times and time zones | 0045 | | Testing and runtime smokes | 0004, 0015 as narrowed by 0019/0026, 0026, 0050 | | Authentication UX and AI delegation | 0020, 0022 | @@ -86,4 +86,5 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0060 clients read by id: one response, no age on the wire, contacts ordered by name · 0061 validators write their own rules · 0062 clients deactivate and reactivate · -0063 clients list and search: generated string columns, whole CPF, status as a validated string. +0063 clients list and search: generated string columns, whole CPF, status as a validated string · +0064 paged reads: one page request in, one paged result out. From d3aabf32a159052f22566cd5e9af4b62ca5d1eac Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Mon, 12 Oct 2026 22:06:44 -0300 Subject: [PATCH 03/24] =?UTF-8?q?refactor(services):=20tipar=20o=20filtro?= =?UTF-8?q?=20de=20situa=C3=A7=C3=A3o=20das=20pessoas=20com=20o=20enum=20C?= =?UTF-8?q?lientStatusFilter=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O filtro `status` da listagem era uma string validada à mão, com três constantes, uma lista de palavras conhecidas e comparações de texto no handler. Passa a ser o enum simples ClientStatusFilter, como manda a ADR 0058. - ClientStatusFilter { Active = 1, Inactive = 2, All = 4 }: ClientStatus não serve, porque não tem All e é persistido. Os valores são bits de propósito: o parser do framework junta nomes separados por vírgula com OR, e com 0, 1, 2 o texto `active,inactive` virava `inactive`. - ListClientsQuery.Status é ClientStatusFilter? (vazio continua `active`); o handler mapeia o enum para ClientStatus? e o validator mantém IsInEnum com Client.StatusFilterInvalid. - Um nome desconhecido, 0 ou uma lista de nomes falha no binding: 400 Validation.Failed com o texto do framework, como page=abc. Um número que é membro continua aceito pelo binder (limite conhecido, na ADR 0063). - services-api.d.ts regenerado: Status passa a ser 'active' | 'inactive' | 'all'. - Testes de validator e handler ajustados; ADR 0063 e API.md atualizados. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/api/generated/services-api.d.ts | 4 ++- .../Clients/ListClients/ClientStatusFilter.cs | 8 +++++ .../Clients/ListClients/ListClientsQuery.cs | 7 +--- .../ListClients/ListClientsQueryHandler.cs | 6 ++-- .../ListClients/ListClientsQueryValidator.cs | 12 ++----- .../ListClientsQueryHandlerTests.cs | 22 +++++-------- .../ListClientsQueryValidatorTests.cs | 24 +++++++------- docs/API.md | 2 +- .../0063-clients-list-and-search-contract.md | 32 +++++++++++++------ ...ged-reads-page-request-and-paged-result.md | 2 +- 10 files changed, 60 insertions(+), 59 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 790e24ce..b2e07546 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -465,7 +465,7 @@ export interface paths { Page?: number | string; PageSize?: number | string; Q?: string; - Status?: string; + Status?: components['schemas']['ClientStatusFilter']; }; header?: never; path: { @@ -1960,6 +1960,8 @@ export interface components { referenceContacts: components['schemas']['ReferenceContactResponse'][]; }; /** @enum {unknown} */ + ClientStatusFilter: 'active' | 'inactive' | 'all' | null; + /** @enum {unknown} */ ContactPurpose: 'emergency' | 'operationalSupport' | 'dailyCommunication'; CreateCategoryCommand: { name: string; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs new file mode 100644 index 00000000..9f819db3 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs @@ -0,0 +1,8 @@ +namespace ServicesService.Application.Clients.ListClients; + +public enum ClientStatusFilter +{ + Active = 1, + Inactive = 2, + All = 4, +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index d00c1555..eda6ca34 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -6,9 +6,4 @@ public sealed record ListClientsQuery( int Page = PageRequest.FirstPage, int PageSize = PageRequest.DefaultPageSize, string? Q = null, - string? Status = ListClientsQuery.ActiveStatus) : IQuery> -{ - public const string ActiveStatus = "active"; - public const string InactiveStatus = "inactive"; - public const string AllStatus = "all"; -} + ClientStatusFilter? Status = ClientStatusFilter.Active) : IQuery>; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index e74595cd..502e3b81 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -47,14 +47,14 @@ public async Task>> Handle( return digits; } - private static ClientStatus? ToStatusFilter(string? status) + private static ClientStatus? ToStatusFilter(ClientStatusFilter? status) { - if (string.Equals(status, ListClientsQuery.AllStatus, StringComparison.OrdinalIgnoreCase)) + if (status == ClientStatusFilter.All) { return null; } - if (string.Equals(status, ListClientsQuery.InactiveStatus, StringComparison.OrdinalIgnoreCase)) + if (status == ClientStatusFilter.Inactive) { return ClientStatus.Inactive; } diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs index 4f26279e..130b0899 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -5,13 +5,6 @@ namespace ServicesService.Application.Clients.ListClients; public sealed class ListClientsQueryValidator : AbstractValidator { - private static readonly string[] KnownStatuses = - [ - ListClientsQuery.ActiveStatus, - ListClientsQuery.InactiveStatus, - ListClientsQuery.AllStatus, - ]; - public ListClientsQueryValidator() { RuleFor(query => query.Page) @@ -34,9 +27,8 @@ public ListClientsQueryValidator() .WithMessage($"A busca deve ter no máximo {EmailAddress.MaxLength} caracteres."); RuleFor(query => query.Status) - .Must(status => string.IsNullOrWhiteSpace(status) || KnownStatuses.Contains(status, StringComparer.OrdinalIgnoreCase)) + .IsInEnum() .WithErrorCode("Client.StatusFilterInvalid") - .WithMessage( - $"A situação deve ser {ListClientsQuery.ActiveStatus}, {ListClientsQuery.InactiveStatus} ou {ListClientsQuery.AllStatus}."); + .WithMessage("A situação deve ser active, inactive ou all."); } } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 2a9202c9..d9d1eff5 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -96,7 +96,7 @@ public async Task Handle_ForAnInactiveClient_ReturnsTheInactiveSituation() client.Inactivate(); Returns([client], 1); - var result = await _handler.Handle(new ListClientsQuery(Status: "all"), CancellationToken.None); + var result = await _handler.Handle(new ListClientsQuery(Status: ClientStatusFilter.All), CancellationToken.None); result.Value.Items.Should().ContainSingle().Which.Status.Should().Be("inactive"); } @@ -147,26 +147,20 @@ public async Task Handle_WithoutFilters_AsksForTheActiveOnesWithoutSearch() } [Theory] - [InlineData("active", ClientStatus.Active)] - [InlineData("ACTIVE", ClientStatus.Active)] - [InlineData("inactive", ClientStatus.Inactive)] - [InlineData("Inactive", ClientStatus.Inactive)] + [InlineData(ClientStatusFilter.Active, ClientStatus.Active)] + [InlineData(ClientStatusFilter.Inactive, ClientStatus.Inactive)] [InlineData(null, ClientStatus.Active)] - [InlineData("", ClientStatus.Active)] - [InlineData(" ", ClientStatus.Active)] - public async Task Handle_MapsTheStatusFilterToTheClientStatus(string? status, ClientStatus expected) + public async Task Handle_MapsTheStatusFilterToTheClientStatus(ClientStatusFilter? status, ClientStatus expected) { await _handler.Handle(new ListClientsQuery(Status: status), CancellationToken.None); await AssertListed(null, null, expected); } - [Theory] - [InlineData("all")] - [InlineData("ALL")] - public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction(string status) + [Fact] + public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction() { - await _handler.Handle(new ListClientsQuery(Status: status), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Status: ClientStatusFilter.All), CancellationToken.None); await AssertListed(null, null, null); } @@ -225,7 +219,7 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { - await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: "Silva", Status: "inactive"), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: "Silva", Status: ClientStatusFilter.Inactive), CancellationToken.None); await AssertListed("silva", null, ClientStatus.Inactive, page: 3, pageSize: 50); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs index 90c27e40..ee49de54 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -17,9 +17,11 @@ public static TheoryData EveryRule() { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Paging.PageSizeInvalid" }, { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Paging.PageSizeInvalid" }, { "search above the longest searchable value", new ListClientsQuery(Q: new string('a', 255)), "Q", "Client.SearchTooLong" }, - { "unknown status", new ListClientsQuery(Status: "deleted"), "Status", "Client.StatusFilterInvalid" }, - { "numeric status", new ListClientsQuery(Status: "1"), "Status", "Client.StatusFilterInvalid" }, - { "status with surrounding text", new ListClientsQuery(Status: "active,inactive"), "Status", "Client.StatusFilterInvalid" }, + { "a status past the last member", new ListClientsQuery(Status: (ClientStatusFilter)7), "Status", "Client.StatusFilterInvalid" }, + { "a negative status", new ListClientsQuery(Status: (ClientStatusFilter)(-1)), "Status", "Client.StatusFilterInvalid" }, + { "zero, which is no member", new ListClientsQuery(Status: default(ClientStatusFilter)), "Status", "Client.StatusFilterInvalid" }, + { "two names joined by the parser", new ListClientsQuery(Status: ClientStatusFilter.Active | ClientStatusFilter.Inactive), "Status", "Client.StatusFilterInvalid" }, + { "every name joined by the parser", new ListClientsQuery(Status: ClientStatusFilter.Active | ClientStatusFilter.Inactive | ClientStatusFilter.All), "Status", "Client.StatusFilterInvalid" }, }; } @@ -56,15 +58,11 @@ public void Validate_WithEveryLimitAtItsBoundary_Passes() } [Theory] - [InlineData("active")] - [InlineData("inactive")] - [InlineData("all")] - [InlineData("ACTIVE")] - [InlineData("All")] - [InlineData("")] - [InlineData(" ")] + [InlineData(ClientStatusFilter.Active)] + [InlineData(ClientStatusFilter.Inactive)] + [InlineData(ClientStatusFilter.All)] [InlineData(null)] - public void Validate_WithAKnownOrBlankStatus_Passes(string? status) + public void Validate_WithAMemberOfTheEnumOrNoStatus_Passes(ClientStatusFilter? status) { var result = _validator.Validate(new ListClientsQuery(Status: status)); @@ -95,7 +93,7 @@ public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() [Fact] public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: "x")); + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: (ClientStatusFilter)9)); result.Errors.Select(error => (error.PropertyName, error.ErrorCode)).Should().BeEquivalentTo( [ @@ -108,7 +106,7 @@ public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() [Fact] public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Q: new string('a', 255), Status: "x")); + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Q: new string('a', 255), Status: (ClientStatusFilter)9)); result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( [ diff --git a/docs/API.md b/docs/API.md index 49ac66bd..d0808e3b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -275,7 +275,7 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Campos de contato inválidos | `POST /api/v1/clients` | 400 | `Validation.Failed` (`guardians[0].name`: `ClientContact.NameRequired`, `referenceContacts[0].purposes`: `ClientReferenceContact.PurposesRequired`…) | | CPF inválido, em qualquer formatação (§4.3) | `POST /api/v1/clients` (`cpf` ou `guardians[0].cpf`) | 400 | `Validation.Failed` (`cpf`: `Validation.Failed`, "O CPF informado é inválido.") | | `fullName` ausente do JSON | `POST /api/v1/clients` | 400 (§4.3: mensagem do framework, em inglês) | `Validation.Failed` (`fullName`: `Validation.Failed`) | -| Filtro de situação desconhecido | `GET /api/v1/clients?status=deleted` | 400 | `Validation.Failed` (`status`: `Client.StatusFilterInvalid`, "A situação deve ser active, inactive ou all.") | +| Filtro de situação desconhecido (§4.3) | `GET /api/v1/clients?status=deleted` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`status`: `Validation.Failed`, "The value 'deleted' is not valid.") | | Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | | Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 5df279a7..eced1c4b 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -41,10 +41,19 @@ owner on 2026-10-12, taken against the text of #155 ("Mascarar CPF na lista") an has no roles, every authenticated user of the tenant sees the whole CPF in the detail, and the list adds no new kind of reader. What changes is volume: up to 100 whole CPFs per response. -**Situation.** `status` is a string on the wire: `active` (default), `inactive` or `all`, in any case. Omitted or -blank is `active`; a number is refused. `all` is "no restriction", and soft-deleted people never appear, whatever the -value. The validator checks it, not the model binder: an enum bound by the framework answers an English message under -the generic code, and the issue asks for a stable code. +**Situation.** `status` is a plain enum, `ClientStatusFilter { Active = 1, Inactive = 2, All = 4 }`, named in +camelCase on the wire ([ADR 0058](0058-closed-set-members-are-plain-enums.md)): `active` (default), `inactive` or +`all`, in any case. Omitted or blank is `active`. `all` is "no restriction", and soft-deleted people never appear, +whatever the value. `ClientStatus` cannot be the type: it has no `All` and it is persisted. The generated contract +publishes `'active' | 'inactive' | 'all'`. The values are bits on purpose: the framework's parser joins names +separated by a comma with an OR, so with `0, 1, 2` the text `active,inactive` would become `inactive`; with `1, 2, 4` +every join is a value no member has, and the binder refuses it. + +The binder, not the validator, answers a bad value: an unknown name, `0`, a number past the members or a joined list +fails at binding (gate 1) with `400 Validation.Failed` under `status` and the framework's English text, like +`page=abc`. The validator keeps an `IsInEnum` rule with `Client.StatusFilterInvalid` for a value that reaches it +anyway. A number that is a member is accepted by the binder (`status=4` is `all`): a known limit, left as it is, +because refusing it needs a binder of its own. **Search.** `q` is trimmed; blank means no search. The handler lowercases it and matches it as "contains": @@ -85,7 +94,7 @@ client load now reads three more short strings, because EF loads every mapped co | `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | | `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | | `q` up to 254 characters, the longest searchable value (an e-mail) | `Client.SearchTooLong` | -| `status` one of the three words, or blank | `Client.StatusFilterInvalid` | +| `status` a member of `ClientStatusFilter`, or absent | `Client.StatusFilterInvalid` | The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). `Paging.PageTooLarge` exists because `RepositoryBase.ListPagedAsync(order, page, pageSize)` computes the offset in `int`: the older list @@ -113,12 +122,12 @@ and an ADR of its own. - The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL would, and a model test pins the expressions. What the real columns produce was verified by hand. - Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does. -- The frontend (#155 screen) is another change. It sends `status` and `q` as plain strings and branches on the error - codes above. +- The frontend (#155 screen) is another change. It sends `status` (the generated union) and `q` and branches on the error + codes above; a bad `status` cannot come from its typed client. Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real -identity and services on private ports and a real login, 89 checks on the list: the default list, the exact item keys and values -(whole CPF, `null` for the absent fields), the three situations and their case, a blank and a numeric `status`; +identity and services on private ports and a real login, 90 checks on the list: the default list, the exact item keys and values +(whole CPF, `null` for the absent fields), the three situations and their case, a blank `status`, an unknown name, a joined list, `0` and `7`, and a member given as a number; name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a long term; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant, @@ -132,7 +141,10 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, - **Masking the CPF in the backend** (`***.456.789-**`, the format of the Portal da Transparência, under `cpf` or `maskedCpf`, or revealing only three digits). The first recommendation; the owner decided against masking. -- **`status` as an enum bound by MVC.** English message and the generic code. +- **`status` as a string checked by the validator** (the first version of this change). A pt-BR message with a code of + its own, but three constants, a list of known words and string comparisons in the handler, a contract that said + `string`, and a rule that ADR 0058 already settles for every enum. Changed after review. +- **Enum values `0, 1, 2`.** A comma-joined list collides with a member (`active,inactive` becomes `inactive`). - **Ordering or matching by an expression over the value-object column**, and a **string alias property on the same column.** Neither translates (see Context). - **Exact match only for the CPF or the e-mail, or a prefix match.** Cheaper (fewer or no extra columns) but the diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index f872220c..d4124194 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -71,7 +71,7 @@ step, so services behave exactly as before, including the two wrong answers abov - The frontend's generated types do not change: `PagedResult` has the same schema. Verified on the same disposable PostgreSQL 18.3 as [ADR 0063](0063-clients-list-and-search-contract.md), on the final -code: the 89 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; +code: the 90 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; eleven checks that the services list did not change (five services created, paging with no repeat or omission, search, a page past the last, `page=0` still `400`, and the `500` of `page=2147483647` still there); the volume run again with 50,000 people (30 to 45 ms at the median, 133 ms at page 2000); and the generated frontend types equal to a fresh From b7e4e2db7e142ce82acf40ce0b634c4e3cd0cfd7 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 08:43:58 -0300 Subject: [PATCH 04/24] refactor(services): ler o termo de busca de pessoas como o value object SearchText (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O `q` da listagem era uma string que o handler aparava e punha em minúsculas, com a regra de tamanho num validator. Passa a ser o value object compartilhado SearchText, ligado pela query string, como os demais textos compartilhados. - SearchText (Admin.SharedKernel.ValueObjects): trim, minúsculas invariantes, até 254 caracteres e mensagem em pt-BR; vazio vira ausente no binding. - StringValueObjectTypeConverter + StringValueObjectModelBinderProvider (Admin.SharedKernel.AspNetCore, registrado por AddWireJson): o MVC não ligava value object pela query, e o API explorer achatava o tipo e publicava `Q.Value`. Um tipo só liga por URL se declarar o conversor (opt-in); CPF, e-mail, nome, telefone e observações não declaram, e um teste fixa isso. - ListClientsQuery.Q é SearchText?; o validator perde a regra de tamanho (Client.SearchTooLong sai) e o handler perde a normalização. Um `q` longo demais falha no binding: 400 Validation.Failed sob `q`, com a mensagem do tipo. - O OpenAPI continua publicando `Q` como string; os tipos do frontend não mudam. - Testes de SearchText, do conversor, do provedor e do ligador; contrato dos tipos compartilhados. ADR 0065 (ajusta a 0055), ADR 0063, ARCHITECTURE, skill e API.md atualizados. Co-Authored-By: Claude Sonnet 5.5 --- .../references/value-objects.md | 11 +- backend/docs/ARCHITECTURE.md | 7 +- .../Clients/ListClients/ListClientsQuery.cs | 2 +- .../ListClients/ListClientsQueryHandler.cs | 12 +- .../ListClients/ListClientsQueryValidator.cs | 5 - .../ListClientsQueryHandlerTests.cs | 26 +-- .../ListClientsQueryValidatorTests.cs | 18 +- .../MvcBuilderExtensions.cs | 2 + .../StringValueObjectModelBinder.cs | 36 ++++ .../StringValueObjectModelBinderProvider.cs | 33 +++ .../Admin.SharedKernel.Tests/RequiredName.cs | 40 ++++ .../SearchTextTests.cs | 91 ++++++++ .../StringValueObjectContractTests.cs | 24 +++ .../StringValueObjectModelBinderTests.cs | 199 ++++++++++++++++++ .../StringValueObjectTypeConverterTests.cs | 64 ++++++ .../SearchText.cs | 42 ++++ .../StringValueObjectTypeConverter.cs | 39 ++++ docs/API.md | 3 +- docs/adr/0055-shared-string-value-objects.md | 4 +- .../0063-clients-list-and-search-contract.md | 21 +- ...tring-value-objects-in-a-query-or-route.md | 88 ++++++++ docs/adr/README.md | 5 +- 22 files changed, 705 insertions(+), 67 deletions(-) create mode 100644 backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinder.cs create mode 100644 backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinderProvider.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/RequiredName.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/StringValueObjectTypeConverterTests.cs create mode 100644 backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs create mode 100644 backend/shared/Admin.SharedKernel.ValueObjects/StringValueObjectTypeConverter.cs create mode 100644 docs/adr/0065-string-value-objects-in-a-query-or-route.md diff --git a/.claude/skills/agenza-backend-slice/references/value-objects.md b/.claude/skills/agenza-backend-slice/references/value-objects.md index 16e4d4ae..79d71e0f 100644 --- a/.claude/skills/agenza-backend-slice/references/value-objects.md +++ b/.claude/skills/agenza-backend-slice/references/value-objects.md @@ -16,8 +16,10 @@ Ask in order and stop at the first no: 3. **Does a second service use it?** ARCHITECTURE §1 says anything added to a shared project lands in every service and "needs a second real caller, not an anticipated one"; ADR 0055 shares a type because its format is the same everywhere. If only one service uses it today, say so in the PR. -4. **Is it a scalar member of a JSON request body?** That is the only place the kernel binds it. A route or - a query does not bind it (ADR 0055), and a list of them is untested. +4. **Is it a scalar member of a JSON request body, or a text that may travel in a URL?** The kernel binds every + string type from a body. A query or a route binds a type only if it declares + `[TypeConverter(typeof(StringValueObjectTypeConverter))]` (ADR 0065): a search text does; personal data + (CPF, e-mail, name, phone, notes) never does, because a URL ends up in logs. A list of them is untested. ## 2. Order of work @@ -31,6 +33,7 @@ Ask in order and stop at the first no: - `BlankIsAbsent` stays `true` (blank binds to `null`, so the member is nullable). Declare `false` only for a required member that should answer with its own message (`FullName`). - A date takes `today` as a parameter (`Create(date, today)`); the type never reads the clock. + - Declare the type converter only when rule 4 allows the type in a URL; the contract tests pin which types do. - Implement `Create` and `Restore` as public static members. An explicit interface implementation compiles, but the EF converter looks them up by name and fails when the model is built. 2. **Its tests**, in `backend/shared/Admin.SharedKernel.Tests/Tests.cs`: every rule with its message, @@ -54,8 +57,8 @@ Ask in order and stop at the first no: - Binding stops at the first invalid value, one field at a time. A missing or `null` member gets the framework's English "required" message before the type runs. -- A failed `Create` reaches the client as the message of a `JsonException` under the field, with code - `Validation.Failed` (`docs/API.md` §4.3). The type has no `DomainError`, and the validator does not +- A failed `Create` reaches the client as the message of a `JsonException` (a body) or a model error (a query or + a route) under the field, with code `Validation.Failed` (`docs/API.md` §4.3). The type has no `DomainError`, and the validator does not repeat its rules. - The EF convention covers only the types of `Admin.SharedKernel.ValueObjects`; JSON and OpenAPI recognize any type that implements the contract. Keep the type in that project. diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index d6c804b3..92dedc4e 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -141,7 +141,7 @@ same aggregate are accepted, and so is an input rule repeated between their vali **Inputs and outputs are explicit records.** The input is the command or query record itself and its nested `Input` records: primitives, strings, `DateOnly`, ids and the shared string value objects -(`CpfNumber`, `FullName`, `PhoneNumber`, `EmailAddress`, `BirthDate`, `AdministrativeNotes`) and enums (§6) — no other domain type, +(`CpfNumber`, `FullName`, `PhoneNumber`, `EmailAddress`, `BirthDate`, `AdministrativeNotes`, `SearchText`) and enums (§6) — no other domain type, no tenant. The output is `Response` with a static `From(entity, …)`, built from the aggregate plus what the handler read explicitly for it; another aggregate appears as a small `Summary`. In a @@ -211,7 +211,10 @@ rule that failed; no `DomainResult`, no `DomainError`, no `Parse`. A blank strin conversion (`AddValueObjectConversions`, called from the `DbContext`'s `ConfigureConventions`). Only `HasMaxLength` is written in the entity configuration. A command may carry one (`CpfNumber? Cpf`, `FullName FullName`); it needs no validator rule and no call in `ToModel`. Binding stops at the first invalid value, so a body with -two invalid fields answers one at a time. To add one: the type in that project and its tests in +two invalid fields answers one at a time. A string one is read from a query or a route only when it declares +`[TypeConverter(typeof(StringValueObjectTypeConverter))]` (`SearchText` does; personal data never does, because a URL +ends up in logs): a bad value answers `400 Validation.Failed` under the parameter with its own message +([0065](../../docs/adr/0065-string-value-objects-in-a-query-or-route.md)). To add one: the type in that project and its tests in `Admin.SharedKernel.Tests`. A date whose rules depend on the day (`BirthDate`) implements `IDateValueObject` instead — `Create(date, today)`, with `today` read at binding from the container's `TimeProvider` (the same one the handlers use, [0045](../../docs/adr/0045-backend-works-in-utc.md)) and the same diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index eda6ca34..0d396e56 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -5,5 +5,5 @@ namespace ServicesService.Application.Clients.ListClients; public sealed record ListClientsQuery( int Page = PageRequest.FirstPage, int PageSize = PageRequest.DefaultPageSize, - string? Q = null, + SearchText? Q = null, ClientStatusFilter? Status = ClientStatusFilter.Active) : IQuery>; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 502e3b81..63882047 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -10,7 +10,7 @@ public async Task>> Handle( ListClientsQuery query, CancellationToken cancellationToken) { - var text = NormalizeText(query.Q); + var text = query.Q?.Value; var cpfDigits = ExtractCpfDigits(text); var status = ToStatusFilter(query.Status); @@ -21,16 +21,6 @@ public async Task>> Handle( return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } - private static string? NormalizeText(string? term) - { - if (string.IsNullOrWhiteSpace(term)) - { - return null; - } - - return term.Trim().ToLowerInvariant(); - } - private static string? ExtractCpfDigits(string? text) { if (text is null) diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs index 130b0899..53f86be7 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -21,11 +21,6 @@ public ListClientsQueryValidator() .WithErrorCode(PageRequest.PageSizeInvalidCode) .WithMessage($"O tamanho da página deve ser entre 1 e {PageRequest.MaxPageSize}."); - RuleFor(query => query.Q) - .MaximumLength(EmailAddress.MaxLength) - .WithErrorCode("Client.SearchTooLong") - .WithMessage($"A busca deve ter no máximo {EmailAddress.MaxLength} caracteres."); - RuleFor(query => query.Status) .IsInEnum() .WithErrorCode("Client.StatusFilterInvalid") diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index d9d1eff5..dab8e8d0 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -46,6 +46,11 @@ private static Client CompleteClient() ClientTestData.Today).Value; } + private static SearchText Search(string raw) + { + return SearchText.Create(raw).Value; + } + private static Client MinimalClient(string name) { return Client.Create(ClientTestData.Data(ClientTestData.Name(name)), ClientTestData.Today).Value; @@ -170,24 +175,13 @@ public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction() [InlineData("MARIA@Example.COM", "maria@example.com")] [InlineData("josé", "josé")] [InlineData("50%_off", "50%_off")] - public async Task Handle_SearchesTheTrimmedLowercaseText(string search, string expectedText) + public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, string expectedText) { - await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); await AssertListed(expectedText, null, ClientStatus.Active); } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task Handle_WithABlankSearch_DoesNotSearch(string? search) - { - await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); - - await AssertListed(null, null, ClientStatus.Active); - } - [Theory] [InlineData("529.982.247-25", "529.982.247-25", "52998224725")] [InlineData("52998224725", "52998224725", "52998224725")] @@ -196,7 +190,7 @@ public async Task Handle_WithABlankSearch_DoesNotSearch(string? search) [InlineData("456.789", "456.789", "456789")] public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(string search, string expectedText, string expectedDigits) { - await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); await AssertListed(expectedText, expectedDigits, ClientStatus.Active); } @@ -211,7 +205,7 @@ public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(s [InlineData("-")] public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(string search) { - await _handler.Handle(new ListClientsQuery(Q: search), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); await AssertListed(search, null, ClientStatus.Active); } @@ -219,7 +213,7 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { - await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: "Silva", Status: ClientStatusFilter.Inactive), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: Search("Silva"), Status: ClientStatusFilter.Inactive), CancellationToken.None); await AssertListed("silva", null, ClientStatus.Inactive, page: 3, pageSize: 50); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs index ee49de54..14b6c40b 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -16,7 +16,6 @@ public static TheoryData EveryRule() { "page just past the offset limit", new ListClientsQuery(Page: 21_474_838, PageSize: 100), "Page", "Paging.PageTooLarge" }, { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Paging.PageSizeInvalid" }, { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Paging.PageSizeInvalid" }, - { "search above the longest searchable value", new ListClientsQuery(Q: new string('a', 255)), "Q", "Client.SearchTooLong" }, { "a status past the last member", new ListClientsQuery(Status: (ClientStatusFilter)7), "Status", "Client.StatusFilterInvalid" }, { "a negative status", new ListClientsQuery(Status: (ClientStatusFilter)(-1)), "Status", "Client.StatusFilterInvalid" }, { "zero, which is no member", new ListClientsQuery(Status: default(ClientStatusFilter)), "Status", "Client.StatusFilterInvalid" }, @@ -54,7 +53,6 @@ public void Validate_WithEveryLimitAtItsBoundary_Passes() _validator.Validate(new ListClientsQuery(Page: 1, PageSize: 1)).IsValid.Should().BeTrue(); _validator.Validate(new ListClientsQuery(Page: 21_474_837, PageSize: 100)).IsValid.Should().BeTrue(); _validator.Validate(new ListClientsQuery(PageSize: 100)).IsValid.Should().BeTrue(); - _validator.Validate(new ListClientsQuery(Q: new string('a', 254))).IsValid.Should().BeTrue(); } [Theory] @@ -69,19 +67,6 @@ public void Validate_WithAMemberOfTheEnumOrNoStatus_Passes(ClientStatusFilter? s result.IsValid.Should().BeTrue(); } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("maria")] - [InlineData("529.982.247-25")] - public void Validate_WithAnySearchWithinTheLimit_Passes(string? search) - { - var result = _validator.Validate(new ListClientsQuery(Q: search)); - - result.IsValid.Should().BeTrue(); - } - [Fact] public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() { @@ -106,13 +91,12 @@ public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() [Fact] public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Q: new string('a', 255), Status: (ClientStatusFilter)9)); + var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: (ClientStatusFilter)9)); result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( [ "A página deve ser maior ou igual a 1.", "O tamanho da página deve ser entre 1 e 100.", - "A busca deve ter no máximo 254 caracteres.", "A situação deve ser active, inactive ou all.", ]); } diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs index 5cff25bd..8804f6a1 100644 --- a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs +++ b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs @@ -16,6 +16,8 @@ public static IMvcBuilder AddWireJson(this IMvcBuilder builder) builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddEnumNameConverter()); + builder.AddMvcOptions(options => options.ModelBinderProviders.Insert(0, new StringValueObjectModelBinderProvider())); + return builder; } diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinder.cs b/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinder.cs new file mode 100644 index 00000000..fa93833b --- /dev/null +++ b/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinder.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Admin.SharedKernel.AspNetCore; + +internal sealed class StringValueObjectModelBinder : IModelBinder + where T : class, IStringValueObject +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + return Task.CompletedTask; + } + + bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); + + var raw = valueProviderResult.FirstValue; + if (string.IsNullOrWhiteSpace(raw) && T.BlankIsAbsent) + { + bindingContext.Result = ModelBindingResult.Success(null); + return Task.CompletedTask; + } + + var result = T.Create(raw); + if (result.IsFailure) + { + bindingContext.ModelState.TryAddModelError(modelName, result.Error); + return Task.CompletedTask; + } + + bindingContext.Result = ModelBindingResult.Success(result.Value); + return Task.CompletedTask; + } +} diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinderProvider.cs b/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinderProvider.cs new file mode 100644 index 00000000..5fbabc5d --- /dev/null +++ b/backend/shared/Admin.SharedKernel.AspNetCore/StringValueObjectModelBinderProvider.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.ComponentModel; + +namespace Admin.SharedKernel.AspNetCore; + +internal sealed class StringValueObjectModelBinderProvider : IModelBinderProvider +{ + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + if (context.BindingInfo.BindingSource == BindingSource.Body) + { + return null; + } + + var modelType = context.Metadata.ModelType; + if (!StringValueObjects.Is(modelType) || !ReadsFromText(modelType)) + { + return null; + } + + var binderType = typeof(StringValueObjectModelBinder<>).MakeGenericType(modelType); + + return (IModelBinder)Activator.CreateInstance(binderType)!; + } + + private static bool ReadsFromText(Type modelType) + { + var converterType = TypeDescriptor.GetConverter(modelType).GetType(); + + return converterType.IsGenericType + && converterType.GetGenericTypeDefinition() == typeof(StringValueObjectTypeConverter<>); + } +} diff --git a/backend/shared/Admin.SharedKernel.Tests/RequiredName.cs b/backend/shared/Admin.SharedKernel.Tests/RequiredName.cs new file mode 100644 index 00000000..1f3b1cf9 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/RequiredName.cs @@ -0,0 +1,40 @@ +using System.ComponentModel; + +namespace Admin.SharedKernel.Tests; + +[TypeConverter(typeof(StringValueObjectTypeConverter))] +public sealed record RequiredName : IStringValueObject +{ + public const int MaxLength = 5; + + public static bool BlankIsAbsent => false; + + public string Value { get; } + + private RequiredName(string value) + { + Value = value; + } + + public static ParseResult Create(string? raw) + { + var trimmed = raw?.Trim() ?? string.Empty; + + if (trimmed.Length == 0) + { + return ParseResult.Failure("O nome é obrigatório."); + } + + if (trimmed.Length > MaxLength) + { + return ParseResult.Failure("O nome deve ter no máximo 5 caracteres."); + } + + return ParseResult.Success(new RequiredName(trimmed)); + } + + public static RequiredName Restore(string value) + { + return new RequiredName(value); + } +} diff --git a/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs new file mode 100644 index 00000000..c35f03ef --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs @@ -0,0 +1,91 @@ +namespace Admin.SharedKernel.Tests; + +public class SearchTextTests +{ + [Theory] + [InlineData("maria", "maria")] + [InlineData(" Maria SILVA ", "maria silva")] + [InlineData("MARIA@Example.COM", "maria@example.com")] + [InlineData("529.982.247-25", "529.982.247-25")] + [InlineData("José", "josé")] + [InlineData("50%_off", "50%_off")] + public void Create_TrimsAndLowercasesTheText(string raw, string expected) + { + var result = SearchText.Create(raw); + + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(expected); + } + + [Fact] + public void Create_KeepsTheInnerSpacingAndTheAccents() + { + SearchText.Create(" Ana Lúcia ").Value.Value.Should().Be("ana lúcia"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void Create_WithNoText_FailsWithAMessage(string? raw) + { + var result = SearchText.Create(raw); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("Informe um texto para a busca."); + } + + [Fact] + public void Create_AcceptsTheLongestText() + { + SearchText.Create(new string('a', SearchText.MaxLength)).IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Create_RefusesATextPastTheLimit() + { + var result = SearchText.Create(new string('a', SearchText.MaxLength + 1)); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("A busca deve ter no máximo 254 caracteres."); + } + + [Fact] + public void Create_MeasuresTheTextAfterTheTrim() + { + var padded = $" {new string('a', SearchText.MaxLength)} "; + + SearchText.Create(padded).IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Create_NeverEchoesTheValueInTheError() + { + SearchText.Create(new string('z', SearchText.MaxLength + 1)).Error.Should().NotContain("zzz"); + } + + [Fact] + public void Restore_DoesNotValidateNorNormalize() + { + SearchText.Restore(" KEEP ME ").Value.Should().Be(" KEEP ME "); + } + + [Fact] + public void BlankIsAbsent_IsTheDefault() + { + BlankIsAbsent().Should().BeTrue(); + } + + [Fact] + public void TwoTextsThatNormalizeAlike_AreEqual() + { + SearchText.Create(" MARIA ").Value.Should().Be(SearchText.Create("maria").Value); + } + + private static bool BlankIsAbsent() + where T : class, IStringValueObject + { + return T.BlankIsAbsent; + } +} diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs index 19cb443b..d1f546a0 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs @@ -1,3 +1,6 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.ComponentModel; + namespace Admin.SharedKernel.Tests; public class StringValueObjectContractTests @@ -27,4 +30,25 @@ public void Create_WithNullOrBlank_FailsWithAMessage(Type valueObject) ((string)result.GetType().GetProperty(nameof(ParseResult.Error))!.GetValue(result)!).Should().NotBeEmpty(); } } + + [Fact] + public void SearchText_DeclaresTheConverter_SoTheFrameworkReadsItAsOneValueNamedQ() + { + TypeDescriptor.GetConverter(typeof(SearchText)).CanConvertFrom(typeof(string)).Should().BeTrue(); + + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(typeof(SearchText)); + + metadata.IsComplexType.Should().BeFalse(); + } + + [Theory] + [InlineData(typeof(CpfNumber))] + [InlineData(typeof(EmailAddress))] + [InlineData(typeof(FullName))] + [InlineData(typeof(PhoneNumber))] + [InlineData(typeof(AdministrativeNotes))] + public void PersonalData_DoesNotDeclareTheConverter_SoItNeverTravelsInAUrl(Type valueObject) + { + TypeDescriptor.GetConverter(valueObject).CanConvertFrom(typeof(string)).Should().BeFalse(); + } } diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs new file mode 100644 index 00000000..e6f3dfba --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs @@ -0,0 +1,199 @@ +using Admin.SharedKernel.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using System.Globalization; + +namespace Admin.SharedKernel.Tests; + +public class StringValueObjectModelBinderTests +{ + private static readonly EmptyModelMetadataProvider MetadataProvider = new(); + + private static IModelBinderProvider Provider() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddControllers().AddWireJson(); + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + return options.ModelBinderProviders[0]; + } + + private static IModelBinder? BinderFor(Type type, BindingSource? source) + { + var context = new ProviderContext(MetadataProvider.GetMetadataForType(type), new BindingInfo { BindingSource = source }); + + return Provider().GetBinder(context); + } + + private static async Task Bind(string? value, bool present = true) + { + var binder = BinderFor(typeof(T), BindingSource.Query)!; + var values = present + ? new Dictionary { ["q"] = value } + : new Dictionary(); + var valueProvider = new QueryStringValueProvider(BindingSource.Query, new QueryCollection(values), CultureInfo.InvariantCulture); + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + actionContext, + valueProvider, + MetadataProvider.GetMetadataForType(typeof(T)), + new BindingInfo { BindingSource = BindingSource.Query }, + "q"); + + await binder.BindModelAsync(bindingContext); + + return bindingContext; + } + + [Fact] + public void AddWireJson_PutsTheValueObjectBinderBeforeEveryDefaultOne() + { + Provider().GetType().Name.Should().Be("StringValueObjectModelBinderProvider"); + } + + [Theory] + [InlineData(typeof(SearchText))] + [InlineData(typeof(RequiredName))] + public void Provider_BindsAStringValueObjectThatDeclaresTheConverterFromTheQueryTheRouteOrAProperty(Type valueObject) + { + BinderFor(valueObject, BindingSource.Query).Should().NotBeNull(); + BinderFor(valueObject, BindingSource.Path).Should().NotBeNull(); + BinderFor(valueObject, null).Should().NotBeNull(); + } + + [Theory] + [InlineData(typeof(CpfNumber))] + [InlineData(typeof(EmailAddress))] + [InlineData(typeof(FullName))] + [InlineData(typeof(PhoneNumber))] + [InlineData(typeof(AdministrativeNotes))] + public void Provider_NeverBindsPersonalDataFromAUrl(Type valueObject) + { + BinderFor(valueObject, BindingSource.Query).Should().BeNull(); + BinderFor(valueObject, BindingSource.Path).Should().BeNull(); + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(Guid))] + [InlineData(typeof(DateOnly))] + [InlineData(typeof(BirthDate))] + [InlineData(typeof(Level))] + public void Provider_LeavesEveryOtherTypeToTheFramework(Type type) + { + BinderFor(type, BindingSource.Query).Should().BeNull(); + } + + [Fact] + public void Provider_LeavesABodyParameterToTheJsonConverters() + { + BinderFor(typeof(SearchText), BindingSource.Body).Should().BeNull(); + } + + [Theory] + [InlineData("maria", "maria")] + [InlineData(" Maria SILVA ", "maria silva")] + public async Task Binder_BindsTheNormalizedValue(string raw, string expected) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeTrue(); + ((SearchText)context.Result.Model!).Value.Should().Be(expected); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Fact] + public async Task Binder_UsesTheRulesOfTheValueObject() + { + var context = await Bind(" Maria "); + + ((RequiredName)context.Result.Model!).Value.Should().Be("Maria"); + } + + [Fact] + public async Task Binder_WithoutTheParameter_BindsNothing() + { + var context = await Bind(null, present: false); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Binder_WithABlankValue_BindsNullBecauseBlankIsAbsent(string raw) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeTrue(); + context.Result.Model.Should().BeNull(); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Fact] + public async Task Binder_WithABlankValueOfATypeThatRequiresOne_FailsWithItsMessage() + { + var context = await Bind(" "); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState["q"]!.Errors.Should().ContainSingle().Which.ErrorMessage.Should().Be("O nome é obrigatório."); + } + + [Fact] + public async Task Binder_WithAnInvalidValue_AddsTheMessageOfTheValueObjectUnderTheParameter() + { + var context = await Bind(new string('a', SearchText.MaxLength + 1)); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState["q"]!.Errors.Should().ContainSingle() + .Which.ErrorMessage.Should().Be("A busca deve ter no máximo 254 caracteres."); + } + + [Fact] + public async Task Binder_NeverEchoesTheInvalidValueInTheError() + { + var context = await Bind("secret-value"); + + var message = context.ModelState["q"]!.Errors.Single().ErrorMessage; + + message.Should().Be("O nome deve ter no máximo 5 caracteres."); + message.Should().NotContain("secret"); + } + + [Fact] + public async Task Binder_AnswersTheCanonicalShapeThroughTheErrorMapper() + { + var context = await Bind(new string('a', SearchText.MaxLength + 1)); + + var error = ModelStateErrorMapper.ToError(context.ModelState); + + error.FieldErrors!["q"].Should().ContainSingle() + .Which.Should().Be(new FieldError("Validation.Failed", "A busca deve ter no máximo 254 caracteres.")); + } + + private enum Level + { + Low, + High, + } + + private sealed class ProviderContext(ModelMetadata metadata, BindingInfo bindingInfo) : ModelBinderProviderContext + { + public override BindingInfo BindingInfo => bindingInfo; + + public override ModelMetadata Metadata => metadata; + + public override IModelMetadataProvider MetadataProvider => new EmptyModelMetadataProvider(); + + public override IModelBinder CreateBinder(ModelMetadata metadata) => throw new NotSupportedException(); + } +} diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectTypeConverterTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectTypeConverterTests.cs new file mode 100644 index 00000000..471c5671 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectTypeConverterTests.cs @@ -0,0 +1,64 @@ +using System.ComponentModel; +using System.Globalization; + +namespace Admin.SharedKernel.Tests; + +public class StringValueObjectTypeConverterTests +{ + private static readonly TypeConverter Search = TypeDescriptor.GetConverter(typeof(SearchText)); + + [Fact] + public void CanConvertFrom_AcceptsAString() + { + Search.CanConvertFrom(typeof(string)).Should().BeTrue(); + } + + [Theory] + [InlineData(typeof(int))] + [InlineData(typeof(Guid))] + [InlineData(typeof(DateOnly))] + public void CanConvertFrom_RefusesAnythingElse(Type source) + { + Search.CanConvertFrom(source).Should().BeFalse(); + } + + [Fact] + public void ConvertFrom_BuildsTheNormalizedValueObject() + { + var converted = Search.ConvertFrom(null, CultureInfo.InvariantCulture, " Maria SILVA "); + + converted.Should().BeOfType().Which.Value.Should().Be("maria silva"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ConvertFrom_ABlankValue_IsNullWhenBlankIsAbsent(string blank) + { + Search.ConvertFrom(null, CultureInfo.InvariantCulture, blank).Should().BeNull(); + } + + [Fact] + public void ConvertFrom_ABlankValue_FailsWhenTheTypeRequiresOne() + { + var convert = () => TypeDescriptor.GetConverter(typeof(RequiredName)).ConvertFrom(null, CultureInfo.InvariantCulture, " "); + + convert.Should().Throw().WithMessage("O nome é obrigatório."); + } + + [Fact] + public void ConvertFrom_AnInvalidValue_ThrowsAFormatExceptionWithTheMessageOfTheValueObject() + { + var convert = () => TypeDescriptor.GetConverter(typeof(SearchText)).ConvertFrom(null, CultureInfo.InvariantCulture, new string('a', SearchText.MaxLength + 1)); + + convert.Should().Throw().WithMessage("A busca deve ter no máximo 254 caracteres."); + } + + [Fact] + public void ConvertFrom_ANonStringSource_IsNotSupported() + { + var convert = () => Search.ConvertFrom(null, CultureInfo.InvariantCulture, 42); + + convert.Should().Throw(); + } +} diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs new file mode 100644 index 00000000..6d0422e4 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace Admin.SharedKernel.ValueObjects; + +[TypeConverter(typeof(StringValueObjectTypeConverter))] +public sealed record SearchText : IStringValueObject +{ + public const int MaxLength = 254; + + private const string NotText = "Informe um texto para a busca."; + + private static readonly string TooLong = $"A busca deve ter no máximo {MaxLength} caracteres."; + + public string Value { get; } + + private SearchText(string value) + { + Value = value; + } + + public static ParseResult Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return ParseResult.Failure(NotText); + } + + var normalized = raw.Trim().ToLowerInvariant(); + + if (normalized.Length > MaxLength) + { + return ParseResult.Failure(TooLong); + } + + return ParseResult.Success(new SearchText(normalized)); + } + + public static SearchText Restore(string value) + { + return new SearchText(value); + } +} diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/StringValueObjectTypeConverter.cs b/backend/shared/Admin.SharedKernel.ValueObjects/StringValueObjectTypeConverter.cs new file mode 100644 index 00000000..5b2e7218 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.ValueObjects/StringValueObjectTypeConverter.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using System.Globalization; + +namespace Admin.SharedKernel.ValueObjects; + +public sealed class StringValueObjectTypeConverter : TypeConverter + where T : class, IStringValueObject +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + if (sourceType == typeof(string)) + { + return true; + } + + return base.CanConvertFrom(context, sourceType); + } + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is not string raw) + { + return base.ConvertFrom(context, culture, value); + } + + if (string.IsNullOrWhiteSpace(raw) && T.BlankIsAbsent) + { + return null; + } + + var result = T.Create(raw); + if (result.IsFailure) + { + throw new FormatException(result.Error); + } + + return result.Value; + } +} diff --git a/docs/API.md b/docs/API.md index d0808e3b..8a58b5f5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -187,7 +187,7 @@ Só `Tenant.ContextMismatch` (§2) usa essa forma menor — sem `traceId`/`corre Se o **model binder** do `[ApiController]` rejeita o corpo antes de qualquer `IValidator`/handler rodar — uma propriedade obrigatória do record **totalmente ausente** do JSON (não vazia: ausente), JSON malformado, um valor de tipo errado ou um value object compartilhado inválido -(`CpfNumber`, `FullName`, `PhoneNumber`, `EmailAddress`, `BirthDate`, `AdministrativeNotes`; [ADR 0055](adr/0055-shared-string-value-objects.md)) — a resposta usa a **forma +(`CpfNumber`, `FullName`, `PhoneNumber`, `EmailAddress`, `BirthDate`, `AdministrativeNotes`, `SearchText`; [ADR 0055](adr/0055-shared-string-value-objects.md)) — a resposta usa a **forma canônica de §4.1** (`AddModelStateProblemDetails`), não o `ValidationProblemDetails` do framework. O que muda é o conteúdo: @@ -279,6 +279,7 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | | Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | +| Busca acima de 254 caracteres (§4.3, [ADR 0065](adr/0065-string-value-objects-in-a-query-or-route.md)) | `GET /api/v1/clients?q=…` | 400 (mensagem do value object, em pt-BR) | `Validation.Failed` (`q`: `Validation.Failed`, "A busca deve ter no máximo 254 caracteres.") | O detalhe do meio da tabela (`00000000-...-0000`) é a pegadinha mais fácil de esquecer: a constraint de rota `{id:guid}` só valida **formato**, então um GUID zerado passa pelo roteamento normalmente e só é rejeitado dentro do `DeleteTagCommandValidator` (`NotEmpty`) — chega como 400 de diff --git a/docs/adr/0055-shared-string-value-objects.md b/docs/adr/0055-shared-string-value-objects.md index a906d745..1fdb915c 100644 --- a/docs/adr/0055-shared-string-value-objects.md +++ b/docs/adr/0055-shared-string-value-objects.md @@ -2,7 +2,9 @@ Status: accepted (2026-10); amends [ADR 0001](0001-context-aggregated-services.md) (shared code is infrastructure only) and item 8 of [ADR 0049](0049-conventions-for-new-backend-slices.md) (a command carries no domain type) **for these -types only**; they do not use `DomainResult`/`DomainError` of [ADR 0014](0014-result-pattern-domain-and-persistence-no-exceptions.md) +types only**; they do not use `DomainResult`/`DomainError` of [ADR 0014](0014-result-pattern-domain-and-persistence-no-exceptions.md); the consequence that a value object does not +bind from a route or a query is amended, by opt-in for each type, by +[ADR 0065](0065-string-value-objects-in-a-query-or-route.md) ## Context diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index eced1c4b..6aa59071 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -55,14 +55,16 @@ fails at binding (gate 1) with `400 Validation.Failed` under `status` and the fr anyway. A number that is a member is accepted by the binder (`status=4` is `all`): a known limit, left as it is, because refusing it needs a binder of its own. -**Search.** `q` is trimmed; blank means no search. The handler lowercases it and matches it as "contains": +**Search.** `q` is a `SearchText` ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)): the framework reads +it from the query string trimmed and lowercased, and a blank one is absent, which means no search. The handler matches +it as "contains": - in the name and in the e-mail; - in the CPF digits too, when the term without `.`, `-` and spaces is only digits. A masked `529.982.247-25` finds that person; `982` finds the CPFs that contain 982 and, as any term, the names and e-mails that contain it. The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is -found by name. The handler derives the two plain values (`text`, `cpfDigits`); the repository port takes them. +found by name. The handler derives the two plain values (`text`, from `query.Q`, and `cpfDigits`); the repository port takes them. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not change. "Normalized" is `lower("FullName")`, like `Service`, `Tag` and `Category`: accents stay, so `jose` does not @@ -93,9 +95,11 @@ client load now reads three more short strings, because EF loads every mapped co | `page` at least 1 | `Paging.PageInvalid` | | `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | | `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | -| `q` up to 254 characters, the longest searchable value (an e-mail) | `Client.SearchTooLong` | | `status` a member of `ClientStatusFilter`, or absent | `Client.StatusFilterInvalid` | +`q` has no validator rule: `SearchText` refuses a term past 254 characters, the longest searchable value (an e-mail), at +binding (gate 1), as `400 Validation.Failed` under `q` with its own pt-BR message. + The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). `Paging.PageTooLarge` exists because `RepositoryBase.ListPagedAsync(order, page, pageSize)` computes the offset in `int`: the older list `GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error`, and `page=43000000` silently @@ -126,10 +130,11 @@ and an ADR of its own. codes above; a bad `status` cannot come from its typed client. Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real -identity and services on private ports and a real login, 90 checks on the list: the default list, the exact item keys and values +identity and services on private ports and a real login, 94 checks on the list: the default list, the exact item keys and values (whole CPF, `null` for the absent fields), the three situations and their case, a blank `status`, an unknown name, a joined list, `0` and `7`, and a member given as a number; -name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a long -term; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the +name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a term past +the limit (refused at binding, with the message of the value object and without echoing it), a padded one of exactly +254 characters, `q` given twice and `?Q=`; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant, inactive and soft-deleted people never leaking into a list, a total or a search; and a list writing nothing. The migration was applied over five legacy rows (mixed-case name and e-mail, no e-mail, no CPF, inactive, deleted, @@ -152,7 +157,9 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, - **An accent-insensitive normalization**, by `lower(translate(...))` (the character list would live in the migration and again in .NET, and the two could drift) or by the `unaccent` extension (a `CREATE EXTENSION` in the infra init, an `IMMUTABLE` wrapper to fit a generated column, a new mechanism). Nobody asked for it yet. -- **One combined `SearchText` column.** A term with a space could match across two fields. +- **One combined search column.** A term with a space could match across two fields. +- **`q` as a plain string normalized in the handler** (the first version of this change). The rule sat in the use case and + in a validator rule instead of in a type; changed after review (ADR 0065). - **`EF.Functions.ILike` with an escape character.** It cannot reach a value-object column. - **A fixed cap on the page** instead of the offset rule. Any number is arbitrary. - **Fixing the offset of the older `RepositoryBase.ListPagedAsync(order, page, pageSize)` in this change.** It changes diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md new file mode 100644 index 00000000..82cdafb8 --- /dev/null +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -0,0 +1,88 @@ +# ADR 0065 — A string value object can be read from a query or a route, by opt-in + +Status: accepted (2026-10); amends [ADR 0055](0055-shared-string-value-objects.md), whose consequence was that a shared +value object does not bind from a route or a query + +## Context + +The search term of the clients list (`q`) is free text that has to be normalized for the application: trimmed, +lowercased, blank meaning "no search", and bounded in length. As a plain `string?` the handler did the normalization +and a validator rule did the bound. The architecture asks for the opposite: a request carries the shared string value +objects, which own their rules ([ADR 0055](0055-shared-string-value-objects.md), +[ARCHITECTURE](../../backend/docs/ARCHITECTURE.md) §2 and §3). + +The kernel reads those types from a JSON body only. Probes with this repository's ASP.NET Core and OpenAPI setup: + +- A value object as a member of a `[FromQuery]` record is bound as a complex object. `?q=maria` does not reach it. +- The API explorer flattens such a member, so the document published `Q.Value` as the query parameter instead of `Q`. + A client generated from it would send `?Q.Value=maria`. +- MVC binds an `IParsable` type from a query, but with its own English message that echoes the value; ADR 0055 rejected + `IParsable` for that reason, and also because "a CPF in a URL is not wanted": a URL ends up in access logs. + +## Decision + +**`SearchText`**, a shared string value object in `Admin.SharedKernel.ValueObjects`: trimmed, lowercased with the +invariant culture, at most 254 characters, and its pt-BR message when it is too long. `Create` of a blank text fails like +every string value object; the binder turns a blank into "absent" (`BlankIsAbsent`, the default), so the member is +nullable. The 254 is the longest thing a list searches (an e-mail). + +**The opt-in is a type converter.** `StringValueObjectTypeConverter`, in the same project and built only on the +base class library, converts a `string` through `Create`. A type declares +`[TypeConverter(typeof(StringValueObjectTypeConverter))]` to say that it may travel in a URL. The declaration does +two things: + +- MVC and the API explorer read the type as one value, so the document says `Q`, a `string`, with no extra schema; +- the kernel's model binder binds exactly the types that declare it. + +**`StringValueObjectModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore` and registered first by `AddWireJson()`, so +no service has to remember it, gives a declaring type a binder that: + +- binds nothing when the parameter is absent; +- binds `null` for a blank value when the type says `BlankIsAbsent`; +- otherwise calls `Create`, and a failure becomes a model error with the type's own message. The canonical problem + shape answers it as `400 Validation.Failed` under the parameter ([`docs/API.md`](../API.md) §4.3), and the message + never carries the value. + +A parameter that comes from the body is left to the JSON converters. + +**Personal data stays out of URLs.** `CpfNumber`, `EmailAddress`, `FullName`, `PhoneNumber` and +`AdministrativeNotes` do not declare the converter, and a test pins it. A new type that may appear in a URL declares it on +purpose. + +**`ListClientsQuery.Q` is a `SearchText?`.** The validator lost its length rule and the handler its normalization: the +handler reads `query.Q?.Value`. The digits of a CPF mask stay in the handler, because they are a rule of the clients +search and not of a text. + +## Consequences + +- A term longer than 254 characters now fails at binding: `400 Validation.Failed` under `q`, with the message "A busca + deve ter no máximo 254 caracteres.". The code `Client.SearchTooLong` of the first version never reached a merged build. +- The generated frontend types do not change: `q` is still a `string`. +- Other free-text searches (`Search` of services, tags and categories, each trimmed in its repository) can adopt + `SearchText` when their lists are touched. That is the second caller that justifies the shared type; none is converted here. +- The probe of the document is a test: `StringValueObjectContractTests` fails if `SearchText` becomes a complex type + to MVC again, and the provider tests fail if personal data starts binding from a URL. +- A date value object is not bound from a URL. This ADR covers strings. +- `AddWireJson()` is called by every service, so the provider reaches `identity-service` too; it binds nothing there, + since no parameter of that service is a declaring type. + +Verified by hand on a disposable PostgreSQL 18.3 with the real services and a real login: 94 checks on the list, +including a term of 255 characters (`400`, the message of the value object, the term not echoed), a padded term of +254 characters after the trim, `q` given twice, `?Q=` in another case and a blank `q`; the generated document with +`Q` as a `string`; and the generated frontend types equal to a fresh generation. + +## Considered and rejected + +- **Binding every shared string value object from a URL.** One line of registration and nothing to opt into, but it + opens `[FromQuery] CpfNumber` for any future action, against ADR 0055. +- **`IParsable`.** English text that echoes the value, and no message of the type's own (ADR 0055). +- **A `TypeConverter` alone, without a binder.** MVC turns a conversion failure into its English text, or keeps the + exception without a message; the problem shape would not show the message of the value object. +- **An OpenAPI transformer that renames `Q.Value` to `Q`.** Name surgery on a flattened parameter, with no hook for + the type behind it. +- **`TypeDescriptor.AddAttributes` for every value object at startup.** Process-wide state, and it opens every type, + personal data included. +- **A client-specific term type carrying the text and the CPF digits.** A request carries the shared string value + objects and nothing else of the domain, and the CPF digits are a rule of one search. +- **`q` as a plain string normalized in the handler** (the first version of this change). It works, but the rule sits + in the use case and in a validator rule instead of in the type, which is how every other shared text is carried. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7e4c9179..d9837e04 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ superseded is historical evidence, not current implementation guidance. | Service and data topology | 0001, 0002, 0003, 0024, 0029 | | Backend CQRS, validation, Result flow | 0005, 0006, 0007, 0009, 0012 as narrowed by 0049, 0014, 0018, 0048, 0049 as narrowed by 0061, 0051, 0057 | | Tenant assignment and persistence | 0006, 0008, 0009, 0017, 0019, 0024, 0028, 0046 | -| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063, 0064 | +| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063, 0064, 0065 | | Dates, times and time zones | 0045 | | Testing and runtime smokes | 0004, 0015 as narrowed by 0019/0026, 0026, 0050 | | Authentication UX and AI delegation | 0020, 0022 | @@ -87,4 +87,5 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0061 validators write their own rules · 0062 clients deactivate and reactivate · 0063 clients list and search: generated string columns, whole CPF, status as a validated string · -0064 paged reads: one page request in, one paged result out. +0064 paged reads: one page request in, one paged result out · +0065 string value objects read from a query or a route, by opt-in. From 0f61beb1e8871c4ae1a5d5036a36b115ec203d5c Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 09:13:42 -0300 Subject: [PATCH 05/24] refactor(services): aceitar texto vazio no SearchText, a busca vazia (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um filtro de busca vazio é uma busca sem nada, e é o que o usuário manda quando filtra por outro campo. O `Create` do SearchText recusava texto em branco, como os demais value objects de string, o que não faz sentido para este tipo. - SearchText.Create(null), Create("") e Create(" ") passam a devolver o valor vazio (IsEmpty). O tipo mantém BlankIsAbsent no padrão: o binder e o conversor seguem transformando um parâmetro em branco em `null`, e o handler trata ausente, em branco e vazio do mesmo jeito: não filtra por texto. - A exceção ao contrato dos value objects de string fica explícita em StringValueObjectContractTests, que lista os tipos que aceitam texto em branco. - O conversor JSON pegava a mensagem de um token que não é string em Create(null).Error; se Create(null) tem sucesso, usa uma mensagem genérica. - Testes do SearchText vazio, do handler e do conversor JSON; ADR 0065, ARCHITECTURE §3 e a skill de value objects atualizados. Co-Authored-By: Claude Sonnet 5.5 --- .../references/value-objects.md | 3 ++- backend/docs/ARCHITECTURE.md | 4 +++- .../ListClients/ListClientsQueryHandler.cs | 12 +++++++++++- .../ListClients/ListClientsQueryHandlerTests.cs | 8 ++++++++ .../Admin.SharedKernel.Tests/SearchTextTests.cs | 13 ++++++++++--- .../StringValueObjectContractTests.cs | 16 +++++++++++++++- .../StringValueObjectJsonConverterTests.cs | 16 ++++++++++++++++ .../SearchText.cs | 11 +++-------- .../StringValueObjectJsonConverter.cs | 13 ++++++++++++- ...5-string-value-objects-in-a-query-or-route.md | 16 +++++++++++++--- 10 files changed, 93 insertions(+), 19 deletions(-) diff --git a/.claude/skills/agenza-backend-slice/references/value-objects.md b/.claude/skills/agenza-backend-slice/references/value-objects.md index 79d71e0f..e57adbfe 100644 --- a/.claude/skills/agenza-backend-slice/references/value-objects.md +++ b/.claude/skills/agenza-backend-slice/references/value-objects.md @@ -28,7 +28,8 @@ Ask in order and stop at the first no: - `Create(raw)` returns a `ParseResult`: trim and normalize, then validate; one pt-BR message per rule, and a message never carries the value (it reaches the logs and the client). - `Create(null)`, `""` and whitespace must fail — the converter takes the message of a non-string - token from `Create(null)`. + token from `Create(null)`. The one exception is a search text, whose blank is the empty search + (`SearchText`, ADR 0065); `StringValueObjectContractTests` lists the types that accept a blank. - `Restore(stored)` only rebuilds; no rule runs. Limits are `public const`. - `BlankIsAbsent` stays `true` (blank binds to `null`, so the member is nullable). Declare `false` only for a required member that should answer with its own message (`FullName`). diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 92dedc4e..ba80c7d1 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -204,7 +204,9 @@ phone, e-mail, birth date, administrative notes). They behave like a scalar on the wire: a `sealed record` implementing `IStringValueObject`, with `Value`, `Restore` and a `Create(raw)` that returns a `ParseResult` — the value, or the pt-BR message of the rule that failed; no `DomainResult`, no `DomainError`, no `Parse`. A blank string is not a value — -`Create("")` fails — and an optional member is nullable. What a blank means is the type's to say: +`Create("")` fails — and an optional member is nullable. The one exception is `SearchText`: an empty search is a search +with nothing in it, so its `Create` accepts a blank and returns the empty value +([0065](../../docs/adr/0065-string-value-objects-in-a-query-or-route.md)). What a blank means is the type's to say: `BlankIsAbsent` (default `true`) binds it to `null`, for an optional field; a type that declares it `false` (`FullName`) answers its own message instead. The kernel does the rest for every implementer: the JSON converter (`AddValueObjectConverters`), the OpenAPI `string` schema (`MapValueObjectsToStrings`) and the column diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 63882047..9a0d5846 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -10,7 +10,7 @@ public async Task>> Handle( ListClientsQuery query, CancellationToken cancellationToken) { - var text = query.Q?.Value; + var text = TextOf(query.Q); var cpfDigits = ExtractCpfDigits(text); var status = ToStatusFilter(query.Status); @@ -21,6 +21,16 @@ public async Task>> Handle( return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } + private static string? TextOf(SearchText? search) + { + if (search is null || search.IsEmpty) + { + return null; + } + + return search.Value; + } + private static string? ExtractCpfDigits(string? text) { if (text is null) diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index dab8e8d0..f3416afc 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -210,6 +210,14 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str await AssertListed(search, null, ClientStatus.Active); } + [Fact] + public async Task Handle_WithAnEmptySearchText_DoesNotSearch() + { + await _handler.Handle(new ListClientsQuery(Q: Search(" ")), CancellationToken.None); + + await AssertListed(null, null, ClientStatus.Active); + } + [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { diff --git a/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs index c35f03ef..bec83204 100644 --- a/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs @@ -28,12 +28,19 @@ public void Create_KeepsTheInnerSpacingAndTheAccents() [InlineData("")] [InlineData(" ")] [InlineData("\t\n")] - public void Create_WithNoText_FailsWithAMessage(string? raw) + public void Create_WithNoText_IsTheEmptySearch(string? raw) { var result = SearchText.Create(raw); - result.IsFailure.Should().BeTrue(); - result.Error.Should().Be("Informe um texto para a busca."); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().BeEmpty(); + result.Value.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Create_WithText_IsNotEmpty() + { + SearchText.Create(" a ").Value.IsEmpty.Should().BeFalse(); } [Fact] diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs index d1f546a0..5454286e 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs @@ -5,10 +5,12 @@ namespace Admin.SharedKernel.Tests; public class StringValueObjectContractTests { + private static readonly Type[] AcceptBlankText = [typeof(SearchText)]; + public static TheoryData SharedValueObjects() { var data = new TheoryData(); - foreach (var type in StringValueObjects.InThisProject()) + foreach (var type in StringValueObjects.InThisProject().Except(AcceptBlankText)) { data.Add(type); } @@ -31,6 +33,18 @@ public void Create_WithNullOrBlank_FailsWithAMessage(Type valueObject) } } + [Fact] + public void SearchText_AcceptsABlankText_BecauseAnEmptySearchIsASearchWithNothingInIt() + { + foreach (var raw in new string?[] { null, "", " " }) + { + var result = SearchText.Create(raw); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEmpty.Should().BeTrue(); + } + } + [Fact] public void SearchText_DeclaresTheConverter_SoTheFrameworkReadsItAsOneValueNamedQ() { diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs index f1c7f3a5..54a1b32c 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs @@ -39,6 +39,22 @@ public static ParseResult Create(string? raw) public static ThreeLetters Restore(string value) => new(value); } + private sealed record SearchSample(SearchText? Query); + + [Fact] + public void Read_ANonStringTokenForATypeThatAcceptsBlank_StillAnswersWithAMessage() + { + var read = () => JsonSerializer.Deserialize("{ \"query\": 5 }", Options); + + read.Should().Throw().WithMessage("O valor informado deve ser um texto."); + } + + [Fact] + public void Read_ABlankStringForATypeThatAcceptsBlank_IsAbsent() + { + JsonSerializer.Deserialize("{ \"query\": \" \" }", Options)!.Query.Should().BeNull(); + } + private sealed record RequiredSample(MustBeFilled? Field); private sealed record MustBeFilled : IStringValueObject diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs index 6d0422e4..fbac4c10 100644 --- a/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs +++ b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs @@ -7,12 +7,12 @@ public sealed record SearchText : IStringValueObject { public const int MaxLength = 254; - private const string NotText = "Informe um texto para a busca."; - private static readonly string TooLong = $"A busca deve ter no máximo {MaxLength} caracteres."; public string Value { get; } + public bool IsEmpty => Value.Length == 0; + private SearchText(string value) { Value = value; @@ -20,12 +20,7 @@ private SearchText(string value) public static ParseResult Create(string? raw) { - if (string.IsNullOrWhiteSpace(raw)) - { - return ParseResult.Failure(NotText); - } - - var normalized = raw.Trim().ToLowerInvariant(); + var normalized = raw?.Trim().ToLowerInvariant() ?? string.Empty; if (normalized.Length > MaxLength) { diff --git a/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs b/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs index d3d1cfc4..b71abb41 100644 --- a/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs +++ b/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs @@ -11,7 +11,7 @@ internal sealed class StringValueObjectJsonConverter : JsonConverter { if (reader.TokenType != JsonTokenType.String) { - throw new JsonException(T.Create(null).Error); + throw new JsonException(NotText()); } var text = reader.GetString(); @@ -31,6 +31,17 @@ internal sealed class StringValueObjectJsonConverter : JsonConverter return result.IsSuccess ? result.Value : throw new JsonException(result.Error); } + private static string NotText() + { + var result = T.Create(null); + if (result.IsFailure) + { + return result.Error; + } + + return "O valor informado deve ser um texto."; + } + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { writer.WriteStringValue(value.Value); diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index 82cdafb8..bc77ea65 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -22,9 +22,19 @@ The kernel reads those types from a JSON body only. Probes with this repository' ## Decision **`SearchText`**, a shared string value object in `Admin.SharedKernel.ValueObjects`: trimmed, lowercased with the -invariant culture, at most 254 characters, and its pt-BR message when it is too long. `Create` of a blank text fails like -every string value object; the binder turns a blank into "absent" (`BlankIsAbsent`, the default), so the member is -nullable. The 254 is the longest thing a list searches (an e-mail). +invariant culture, at most 254 characters, and its pt-BR message when it is too long. The 254 is the longest thing a +list searches (an e-mail). + +**A blank text is a valid `SearchText`.** Every other string value object refuses a blank in `Create`, because a blank +is not a value of a CPF or a name. An empty search is different: it is a search with nothing in it, and a user filtering +by another field sends exactly that. `Create(null)`, `Create("")` and `Create(" ")` succeed with the empty value +(`IsEmpty`). The type keeps `BlankIsAbsent` at its default, so the binder and the converter still turn a blank +parameter into `null`; the member is nullable, and an absent parameter, a blank one and an empty instance all mean "do +not filter by text". The handler reads the three the same way. + +The exception is explicit in `StringValueObjectContractTests`, which lists the types that accept a blank text. The only +generic code that assumed `Create(null)` fails, the JSON converter's answer to a token that is not a string, now falls +back to "O valor informado deve ser um texto." when `Create(null)` succeeds. **The opt-in is a type converter.** `StringValueObjectTypeConverter`, in the same project and built only on the base class library, converts a `string` through `Create`. A type declares From 20e7179f39964db9b6ba133f34a00108a5568be0 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 09:30:58 -0300 Subject: [PATCH 06/24] =?UTF-8?q?refactor(services):=20renomear=20o=20par?= =?UTF-8?q?=C3=A2metro=20de=20busca=20de=20pessoas=20de=20q=20para=20searc?= =?UTF-8?q?h=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O membro ListClientsQuery.Q era também o nome do parâmetro na URL e a chave do erro de binding, e uma letra só não diz o que é no backend. Passa a ser Search, o nome que as listas de serviços, etiquetas e categorias já usam. - ListClientsQuery.Search é SearchText?; o handler lê query.Search. - O fio vira ?search=. O ?q= antigo não dá erro: nada o liga e a lista vem sem filtro. O texto da #155, que escreve `q`, precisa ser atualizado, como o do CPF. - services-api.d.ts: Q?: string vira Search?: string (ninguém consome ainda). - A chave do erro de binding do termo longo demais vira `search`. - Testes do handler, ADRs 0063 e 0065 e API.md ajustados. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/api/generated/services-api.d.ts | 2 +- .../Clients/ListClients/ListClientsQuery.cs | 2 +- .../ListClients/ListClientsQueryHandler.cs | 2 +- .../ListClientsQueryHandlerTests.cs | 12 ++++---- docs/API.md | 6 ++-- .../0063-clients-list-and-search-contract.md | 28 ++++++++++--------- ...tring-value-objects-in-a-query-or-route.md | 28 +++++++++---------- 7 files changed, 41 insertions(+), 39 deletions(-) diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index b2e07546..604f9617 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -464,7 +464,7 @@ export interface paths { query?: { Page?: number | string; PageSize?: number | string; - Q?: string; + Search?: string; Status?: components['schemas']['ClientStatusFilter']; }; header?: never; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 0d396e56..7ad3834b 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -5,5 +5,5 @@ namespace ServicesService.Application.Clients.ListClients; public sealed record ListClientsQuery( int Page = PageRequest.FirstPage, int PageSize = PageRequest.DefaultPageSize, - SearchText? Q = null, + SearchText? Search = null, ClientStatusFilter? Status = ClientStatusFilter.Active) : IQuery>; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 9a0d5846..5e6bfd9c 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -10,7 +10,7 @@ public async Task>> Handle( ListClientsQuery query, CancellationToken cancellationToken) { - var text = TextOf(query.Q); + var text = TextOf(query.Search); var cpfDigits = ExtractCpfDigits(text); var status = ToStatusFilter(query.Status); diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index f3416afc..7c85f385 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -46,7 +46,7 @@ private static Client CompleteClient() ClientTestData.Today).Value; } - private static SearchText Search(string raw) + private static SearchText SearchTextOf(string raw) { return SearchText.Create(raw).Value; } @@ -177,7 +177,7 @@ public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction() [InlineData("50%_off", "50%_off")] public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, string expectedText) { - await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); await AssertListed(expectedText, null, ClientStatus.Active); } @@ -190,7 +190,7 @@ public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, str [InlineData("456.789", "456.789", "456789")] public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(string search, string expectedText, string expectedDigits) { - await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); await AssertListed(expectedText, expectedDigits, ClientStatus.Active); } @@ -205,7 +205,7 @@ public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(s [InlineData("-")] public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(string search) { - await _handler.Handle(new ListClientsQuery(Q: Search(search)), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); await AssertListed(search, null, ClientStatus.Active); } @@ -213,7 +213,7 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str [Fact] public async Task Handle_WithAnEmptySearchText_DoesNotSearch() { - await _handler.Handle(new ListClientsQuery(Q: Search(" ")), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(" ")), CancellationToken.None); await AssertListed(null, null, ClientStatus.Active); } @@ -221,7 +221,7 @@ public async Task Handle_WithAnEmptySearchText_DoesNotSearch() [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { - await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Q: Search("Silva"), Status: ClientStatusFilter.Inactive), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Search: SearchTextOf("Silva"), Status: ClientStatusFilter.Inactive), CancellationToken.None); await AssertListed("silva", null, ClientStatus.Inactive, page: 3, pageSize: 50); } diff --git a/docs/API.md b/docs/API.md index 8a58b5f5..232fdb92 100644 --- a/docs/API.md +++ b/docs/API.md @@ -71,8 +71,8 @@ o único campo que muda por endpoint. {"data":{"items":[{"id":"...","code":1,"name":"Teste","description":null,"durationMinutes":2,"minDurationMinutes":1,"maxDurationMinutes":3,"price":10.00,"maxDiscountPercentage":10.00,"categoryId":null,"categoryName":null,"tags":[]}],"totalCount":1,"page":1,"pageSize":20},"success":true, "...":"..."} ``` -**Lista paginada com busca e filtro** — o mesmo `PagedResult`, com `q` (texto) e `status` (string) além de -`page`/`pageSize`. Exemplo, `GET /api/v1/clients` (padrão: `page=1`, `pageSize=20`, `status=active`, sem `q`), +**Lista paginada com busca e filtro** — o mesmo `PagedResult`, com `search` (texto) e `status` (string) além de +`page`/`pageSize`. Exemplo, `GET /api/v1/clients` (padrão: `page=1`, `pageSize=20`, `status=active`, sem `search`), verificado em 2026-10-12 num PostgreSQL descartável com a stack real em portas privadas ([ADR 0063](adr/0063-clients-list-and-search-contract.md)): @@ -279,7 +279,7 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | | Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | -| Busca acima de 254 caracteres (§4.3, [ADR 0065](adr/0065-string-value-objects-in-a-query-or-route.md)) | `GET /api/v1/clients?q=…` | 400 (mensagem do value object, em pt-BR) | `Validation.Failed` (`q`: `Validation.Failed`, "A busca deve ter no máximo 254 caracteres.") | +| Busca acima de 254 caracteres (§4.3, [ADR 0065](adr/0065-string-value-objects-in-a-query-or-route.md)) | `GET /api/v1/clients?search=…` | 400 (mensagem do value object, em pt-BR) | `Validation.Failed` (`q`: `Validation.Failed`, "A busca deve ter no máximo 254 caracteres.") | O detalhe do meio da tabela (`00000000-...-0000`) é a pegadinha mais fácil de esquecer: a constraint de rota `{id:guid}` só valida **formato**, então um GUID zerado passa pelo roteamento normalmente e só é rejeitado dentro do `DeleteTagCommandValidator` (`NotEmpty`) — chega como 400 de diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 6aa59071..b204ff1f 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -6,9 +6,9 @@ situation) and [ADR 0060](0060-clients-read-by-id-contract.md) (the CPF on the w ## Context -#155 adds `GET /api/v1/clients?page=&pageSize=&q=&status=` to list and search the people a business serves. The +#155 adds `GET /api/v1/clients?page=&pageSize=&search=&status=` to list and search the people a business serves. The client aggregate, the situation, the soft-delete and tenant filters and the paged-list shape (`PagedResult`) -already existed. Open were: the shape of a list item, whether the CPF is masked, what `q` matches, what "normalized +already existed. Open were: the shape of a list item, whether the CPF is masked, what `search` matches, what "normalized name" means for the order, and how an invalid parameter answers. One finding shaped the persistence of the search. `FullName`, `Email` and `Cpf` are shared value objects with a @@ -25,11 +25,13 @@ versions showed: ## Decision -**Endpoint.** `GET /api/v1/clients` with `page` (from 1), `pageSize` (default 20, 1 to 100), `q` and `status`. It +**Endpoint.** `GET /api/v1/clients` with `page` (from 1), `pageSize` (default 20, 1 to 100), `search` and `status`. It answers `200` with `ApiResponse>`: `items`, `totalCount`, `page`, `pageSize`. -The members of the query are `Page`, `PageSize`, `Q` and `Status`: a validation error is keyed by the property path +The members of the query are `Page`, `PageSize`, `Search` and `Status`: a validation error is keyed by the property path in camelCase, so the member is named after the wire parameter. The OpenAPI document lists the parameters in -PascalCase, like every other list, and the binding is case-insensitive. +PascalCase, like every other list, and the binding is case-insensitive. The search parameter is `search`, as in the +services, tags and categories lists, and not the `q` the issue #155 wrote; the text of the issue needs the same update +as the CPF. The old `?q=` is not an error: nothing binds it and the list is not filtered. **The item.** `ClientListItemResponse`: `id`, `fullName`, `birthDate`, `phone`, `email`, `cpf`, `status`. No guardians, reference contacts or notes, no `age` (ADR 0060). The repository returns the client roots without their @@ -55,7 +57,7 @@ fails at binding (gate 1) with `400 Validation.Failed` under `status` and the fr anyway. A number that is a member is accepted by the binder (`status=4` is `all`): a known limit, left as it is, because refusing it needs a binder of its own. -**Search.** `q` is a `SearchText` ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)): the framework reads +**Search.** `search` is a `SearchText` ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)): the framework reads it from the query string trimmed and lowercased, and a blank one is absent, which means no search. The handler matches it as "contains": @@ -64,7 +66,7 @@ it as "contains": that person; `982` finds the CPFs that contain 982 and, as any term, the names and e-mails that contain it. The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is -found by name. The handler derives the two plain values (`text`, from `query.Q`, and `cpfDigits`); the repository port takes them. +found by name. The handler derives the two plain values (`text`, from `query.Search`, and `cpfDigits`); the repository port takes them. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not change. "Normalized" is `lower("FullName")`, like `Service`, `Tag` and `Category`: accents stay, so `jose` does not @@ -97,8 +99,8 @@ client load now reads three more short strings, because EF loads every mapped co | `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | | `status` a member of `ClientStatusFilter`, or absent | `Client.StatusFilterInvalid` | -`q` has no validator rule: `SearchText` refuses a term past 254 characters, the longest searchable value (an e-mail), at -binding (gate 1), as `400 Validation.Failed` under `q` with its own pt-BR message. +`search` has no validator rule: `SearchText` refuses a term past 254 characters, the longest searchable value (an e-mail), at +binding (gate 1), as `400 Validation.Failed` under `search` with its own pt-BR message. The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). `Paging.PageTooLarge` exists because `RepositoryBase.ListPagedAsync(order, page, pageSize)` computes the offset in `int`: the older list @@ -126,15 +128,15 @@ and an ADR of its own. - The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL would, and a model test pins the expressions. What the real columns produce was verified by hand. - Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does. -- The frontend (#155 screen) is another change. It sends `status` (the generated union) and `q` and branches on the error +- The frontend (#155 screen) is another change. It sends `status` (the generated union) and `search` and branches on the error codes above; a bad `status` cannot come from its typed client. Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real -identity and services on private ports and a real login, 94 checks on the list: the default list, the exact item keys and values +identity and services on private ports and a real login, 97 checks on the list: the default list, the exact item keys and values (whole CPF, `null` for the absent fields), the three situations and their case, a blank `status`, an unknown name, a joined list, `0` and `7`, and a member given as a number; name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a term past the limit (refused at binding, with the message of the value object and without echoing it), a padded one of exactly -254 characters, `q` given twice and `?Q=`; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the +254 characters, `search` given twice, `?Search=` in another case and the old `?q=` ignored; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant, inactive and soft-deleted people never leaking into a list, a total or a search; and a list writing nothing. The migration was applied over five legacy rows (mixed-case name and e-mail, no e-mail, no CPF, inactive, deleted, @@ -158,7 +160,7 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, and again in .NET, and the two could drift) or by the `unaccent` extension (a `CREATE EXTENSION` in the infra init, an `IMMUTABLE` wrapper to fit a generated column, a new mechanism). Nobody asked for it yet. - **One combined search column.** A term with a space could match across two fields. -- **`q` as a plain string normalized in the handler** (the first version of this change). The rule sat in the use case and +- **`search` as a plain string normalized in the handler** (the first version of this change). The rule sat in the use case and in a validator rule instead of in a type; changed after review (ADR 0065). - **`EF.Functions.ILike` with an escape character.** It cannot reach a value-object column. - **A fixed cap on the page** instead of the offset rule. Any number is arbitrary. diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index bc77ea65..1b4c7390 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -5,7 +5,7 @@ value object does not bind from a route or a query ## Context -The search term of the clients list (`q`) is free text that has to be normalized for the application: trimmed, +The search term of the clients list (`search`) is free text that has to be normalized for the application: trimmed, lowercased, blank meaning "no search", and bounded in length. As a plain `string?` the handler did the normalization and a validator rule did the bound. The architecture asks for the opposite: a request carries the shared string value objects, which own their rules ([ADR 0055](0055-shared-string-value-objects.md), @@ -13,9 +13,9 @@ objects, which own their rules ([ADR 0055](0055-shared-string-value-objects.md), The kernel reads those types from a JSON body only. Probes with this repository's ASP.NET Core and OpenAPI setup: -- A value object as a member of a `[FromQuery]` record is bound as a complex object. `?q=maria` does not reach it. -- The API explorer flattens such a member, so the document published `Q.Value` as the query parameter instead of `Q`. - A client generated from it would send `?Q.Value=maria`. +- A value object as a member of a `[FromQuery]` record is bound as a complex object. `?search=maria` does not reach it. +- The API explorer flattens such a member, so the document published `Search.Value` as the query parameter instead of `Search`. + A client generated from it would send `?Search.Value=maria`. - MVC binds an `IParsable` type from a query, but with its own English message that echoes the value; ADR 0055 rejected `IParsable` for that reason, and also because "a CPF in a URL is not wanted": a URL ends up in access logs. @@ -41,7 +41,7 @@ base class library, converts a `string` through `Create`. A type declares `[TypeConverter(typeof(StringValueObjectTypeConverter))]` to say that it may travel in a URL. The declaration does two things: -- MVC and the API explorer read the type as one value, so the document says `Q`, a `string`, with no extra schema; +- MVC and the API explorer read the type as one value, so the document says `Search`, a `string`, with no extra schema; - the kernel's model binder binds exactly the types that declare it. **`StringValueObjectModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore` and registered first by `AddWireJson()`, so @@ -59,15 +59,15 @@ A parameter that comes from the body is left to the JSON converters. `AdministrativeNotes` do not declare the converter, and a test pins it. A new type that may appear in a URL declares it on purpose. -**`ListClientsQuery.Q` is a `SearchText?`.** The validator lost its length rule and the handler its normalization: the -handler reads `query.Q?.Value`. The digits of a CPF mask stay in the handler, because they are a rule of the clients +**`ListClientsQuery.Search` is a `SearchText?`.** The validator lost its length rule and the handler its normalization: the +handler reads `query.Search?.Value`. The digits of a CPF mask stay in the handler, because they are a rule of the clients search and not of a text. ## Consequences -- A term longer than 254 characters now fails at binding: `400 Validation.Failed` under `q`, with the message "A busca +- A term longer than 254 characters now fails at binding: `400 Validation.Failed` under `search`, with the message "A busca deve ter no máximo 254 caracteres.". The code `Client.SearchTooLong` of the first version never reached a merged build. -- The generated frontend types do not change: `q` is still a `string`. +- The generated frontend types change only by the rename of the member: `Search?: string`, as in the other lists. - Other free-text searches (`Search` of services, tags and categories, each trimmed in its repository) can adopt `SearchText` when their lists are touched. That is the second caller that justifies the shared type; none is converted here. - The probe of the document is a test: `StringValueObjectContractTests` fails if `SearchText` becomes a complex type @@ -76,10 +76,10 @@ search and not of a text. - `AddWireJson()` is called by every service, so the provider reaches `identity-service` too; it binds nothing there, since no parameter of that service is a declaring type. -Verified by hand on a disposable PostgreSQL 18.3 with the real services and a real login: 94 checks on the list, +Verified by hand on a disposable PostgreSQL 18.3 with the real services and a real login: 97 checks on the list, including a term of 255 characters (`400`, the message of the value object, the term not echoed), a padded term of -254 characters after the trim, `q` given twice, `?Q=` in another case and a blank `q`; the generated document with -`Q` as a `string`; and the generated frontend types equal to a fresh generation. +254 characters after the trim, `search` given twice, `?Search=` in another case and a blank `search`; the generated document with +`Search` as a `string`; and the generated frontend types equal to a fresh generation. ## Considered and rejected @@ -88,11 +88,11 @@ including a term of 255 characters (`400`, the message of the value object, the - **`IParsable`.** English text that echoes the value, and no message of the type's own (ADR 0055). - **A `TypeConverter` alone, without a binder.** MVC turns a conversion failure into its English text, or keeps the exception without a message; the problem shape would not show the message of the value object. -- **An OpenAPI transformer that renames `Q.Value` to `Q`.** Name surgery on a flattened parameter, with no hook for +- **An OpenAPI transformer that renames `Search.Value` to `Search`.** Name surgery on a flattened parameter, with no hook for the type behind it. - **`TypeDescriptor.AddAttributes` for every value object at startup.** Process-wide state, and it opens every type, personal data included. - **A client-specific term type carrying the text and the CPF digits.** A request carries the shared string value objects and nothing else of the domain, and the CPF digits are a rule of one search. -- **`q` as a plain string normalized in the handler** (the first version of this change). It works, but the rule sits +- **`search` as a plain string normalized in the handler** (the first version of this change). It works, but the rule sits in the use case and in a validator rule instead of in the type, which is how every other shared text is carried. From c53576e5596bda9797b0a46fb27d2a4afce30ed1 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 09:35:35 -0300 Subject: [PATCH 07/24] =?UTF-8?q?refactor(services):=20reunir=20p=C3=A1gin?= =?UTF-8?q?a=20e=20filtros=20da=20lista=20de=20pessoas=20em=20ClientListFi?= =?UTF-8?q?lter=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListAsync recebia a página e cada critério como parâmetro solto (page, text, cpfDigits, status), e cada critério novo exigiria mais um parâmetro. A página e os filtros passam a viajar num objeto só. - PageRequest deixa de ser sealed: o filtro de uma lista o estende e a guarda do construtor vem junto com a página. Só o filtro de uma lista o estende; uma query ou um comando nunca, porque carregam entrada ainda não validada. - ClientListFilter : PageRequest (Application/Clients) carrega Text, CpfDigits e Status. Create(page, pageSize, SearchText?, ClientStatus?) normaliza o texto e extrai os dígitos do CPF, que saem do handler. - IClientRepository.ListAsync(ClientListFilter, ct); o repositório passa o filtro a ListPagedAsync, que lê só a página dele, e separa a busca em Search. O SQL não muda: nome, e-mail e CPF por LIKE nas colunas geradas. - O handler monta o filtro e faz Map: quatro linhas de orquestração. - ClientListFilterTests recebe a matriz de normalização que estava nos testes do handler; PageRequestTests cobre um record que o estende. - ADR 0064, ARCHITECTURE §5 e §10 e as skills ajustados. Co-Authored-By: Claude Sonnet 5.5 --- .../references/persistence.md | 6 +- .../references/use-case.md | 3 +- backend/docs/ARCHITECTURE.md | 8 +- .../Abstractions/IClientRepository.cs | 8 +- .../Clients/ClientListFilter.cs | 53 ++++++++ .../ListClients/ListClientsQueryHandler.cs | 32 +---- .../Repositories/ClientRepository.cs | 49 +++---- .../ClientListPersistenceTests.cs | 45 +++---- .../Clients/ClientListFilterTests.cs | 126 ++++++++++++++++++ .../ListClientsQueryHandlerTests.cs | 33 +++-- .../PageRequestTests.cs | 33 +++++ .../shared/Admin.SharedKernel/PageRequest.cs | 2 +- .../0063-clients-list-and-search-contract.md | 4 +- ...ged-reads-page-request-and-paged-result.md | 32 +++-- 14 files changed, 314 insertions(+), 120 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs diff --git a/.claude/skills/agenza-backend-slice/references/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index 12ca43a6..e2e93eda 100644 --- a/.claude/skills/agenza-backend-slice/references/persistence.md +++ b/.claude/skills/agenza-backend-slice/references/persistence.md @@ -17,11 +17,11 @@ no `Remove` until a delete exists, no `ListAsync` until a list exists. | `CountByAsync(id)` | `int` | an "in use" rule | | `GetByIdsAsync(ids)` | `IReadOnlyList` | one query for a set — never one per row | | `ListAsync(filters)` | `IReadOnlyList` | an unpaged list, ordered | -| `ListAsync(PageRequest, filters)` | `PagedResult` | a paged list, ordered: the adapter calls `ListPagedAsync(page, filterAndOrder, ct)` | +| `ListAsync(ListFilter)` | `PagedResult` | a paged list, ordered: the filter extends `PageRequest` and carries the list's plain filters; the adapter calls `ListPagedAsync(filter, filterAndOrder, ct)` | | `Add(entity)`, `UpdateAsync(entity)`, `Remove(entity)` | `void` / `Task` | stage; the handler commits | -Parameters are ids, value objects for value-object columns and plain filter values — never a tenant, -an expression, an `IQueryable` or a specification. Results are the root with its own children — never a +Parameters are ids, value objects for value-object columns and plain filter values — a list with several of them takes +one record of them (`ListFilter`) — never a tenant, an expression, an `IQueryable` or a specification. Results are the root with its own children — never a DTO, a projection or another aggregate. `GetByIdAsync` returns an aggregate without tracking. In a service with a global no-tracking default, diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 7e2a0709..1f847b04 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -17,7 +17,8 @@ messages", plus the earlier slices for reads, edits and paging. This file is how the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. - A list query carries its filters as optional members and `Page`/`PageSize` with the `PageRequest` defaults; the - handler builds the `PageRequest` and the validator writes the three `Paging.*` rules (ADR 0064). + handler builds the `ListFilter` (a `PageRequest` with the list's filters) and the validator writes the three + `Paging.*` rules (ADR 0064). ## 2. The validator — simple and clean diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index ba80c7d1..e8891681 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -345,7 +345,8 @@ no tracking for reads. `ServicesDataContext` sets that behavior globally; shared it explicitly for a service outside that decision. A command loads its aggregate with `GetByIdAsync`, applies the domain behaviour, then stages persistence with `UpdateAsync`; the port does not expose EF tracking. An EF adapter makes any required state changes explicitly inside that operation. Paged reads -take a `PageRequest` and return a `PagedResult` through `ListPagedAsync` ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). The tenant +take a `PageRequest` — or the filter of the list, a record that extends it with the list's plain filters — and return a +`PagedResult` through `ListPagedAsync` ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). The tenant and soft-delete filters come from the `DbContext`: a repository never writes a `TenantId` or `DeletedAt` predicate. A query cannot reach `.Value` through a converter: compare whole value objects or order by the property. Matching or sorting a value-object column as text — `EF.Property` over it throws — goes through a @@ -391,7 +392,8 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult - The wire is camelCase English; calendar dates are `DateOnly` (`yyyy-MM-dd`); instants are UTC. - A paged list takes `Page`/`PageSize` with the `PageRequest` defaults and a validator that writes its three rules (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared - `Paging.*` codes; the handler builds a `PageRequest`, the port takes it and returns `PagedResult`, and the + `Paging.*` codes; the handler builds the list's filter (`ClientListFilter`, a `PageRequest` with the plain filters), the + port takes it and returns `PagedResult`, and the handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). @@ -464,7 +466,7 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `PageRequest` in and a `PagedResult` out, a validator with the `Paging.*` codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryHandler.cs` (page and size as `int`, a tuple back), `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryHandler.cs` (page and size as `int`, a tuple back), `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | | Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index 284ad5f4..19b6abc0 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -1,4 +1,5 @@ using Admin.SharedKernel; +using ServicesService.Application.Clients; namespace ServicesService.Application.Abstractions; @@ -6,12 +7,7 @@ public interface IClientRepository { Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken); - Task> ListAsync( - PageRequest page, - string? text, - string? cpfDigits, - ClientStatus? status, - CancellationToken cancellationToken); + Task> ListAsync(ClientListFilter filter, CancellationToken cancellationToken); Task UpdateAsync(Client client, CancellationToken cancellationToken); diff --git a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs new file mode 100644 index 00000000..ab3c4d26 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs @@ -0,0 +1,53 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients; + +public sealed record ClientListFilter : PageRequest +{ + private ClientListFilter(int page, int pageSize, string? text, string? cpfDigits, ClientStatus? status) + : base(page, pageSize) + { + Text = text; + CpfDigits = cpfDigits; + Status = status; + } + + public string? Text { get; } + + public string? CpfDigits { get; } + + public ClientStatus? Status { get; } + + public static ClientListFilter Create(int page, int pageSize, SearchText? search, ClientStatus? status) + { + var text = TextOf(search); + + return new ClientListFilter(page, pageSize, text, ExtractCpfDigits(text), status); + } + + private static string? TextOf(SearchText? search) + { + if (search is null || search.IsEmpty) + { + return null; + } + + return search.Value; + } + + private static string? ExtractCpfDigits(string? text) + { + if (text is null) + { + return null; + } + + var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return digits; + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 5e6bfd9c..d041ef1b 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -10,43 +10,15 @@ public async Task>> Handle( ListClientsQuery query, CancellationToken cancellationToken) { - var text = TextOf(query.Search); - var cpfDigits = ExtractCpfDigits(text); var status = ToStatusFilter(query.Status); - var page = new PageRequest(query.Page, query.PageSize); + var filter = ClientListFilter.Create(query.Page, query.PageSize, query.Search, status); - var clients = await clientRepository.ListAsync(page, text, cpfDigits, status, cancellationToken); + var clients = await clientRepository.ListAsync(filter, cancellationToken); return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } - private static string? TextOf(SearchText? search) - { - if (search is null || search.IsEmpty) - { - return null; - } - - return search.Value; - } - - private static string? ExtractCpfDigits(string? text) - { - if (text is null) - { - return null; - } - - var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); - if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) - { - return null; - } - - return digits; - } - private static ClientStatus? ToStatusFilter(ClientStatusFilter? status) { if (status == ClientStatusFilter.All) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 516f59e9..fcfc34ed 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -2,6 +2,7 @@ using Admin.SharedKernel.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients; using ServicesService.Infrastructure.Persistence; namespace ServicesService.Infrastructure.Repositories; @@ -21,42 +22,21 @@ public ClientRepository(ServicesDataContext dbContext) .FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); } - public Task> ListAsync( - PageRequest page, - string? text, - string? cpfDigits, - ClientStatus? status, - CancellationToken cancellationToken) + public Task> ListAsync(ClientListFilter filter, CancellationToken cancellationToken) { - return ListPagedAsync( - page, - query => FilterAndOrder(query, text, cpfDigits, status), - cancellationToken); + return ListPagedAsync(filter, query => FilterAndOrder(query, filter), cancellationToken); } - private static IQueryable FilterAndOrder( - IQueryable query, - string? text, - string? cpfDigits, - ClientStatus? status) + private static IQueryable FilterAndOrder(IQueryable query, ClientListFilter filter) { - if (status is { } wantedStatus) + if (filter.Status is { } wantedStatus) { query = query.Where(client => client.Status == wantedStatus); } - if (text is not null && cpfDigits is not null) - { - query = query.Where(client => - EF.Property(client, "NameNormalized").Contains(text) - || EF.Property(client, "EmailNormalized").Contains(text) - || EF.Property(client, "CpfNormalized").Contains(cpfDigits)); - } - else if (text is not null) + if (filter.Text is { } text) { - query = query.Where(client => - EF.Property(client, "NameNormalized").Contains(text) - || EF.Property(client, "EmailNormalized").Contains(text)); + query = Search(query, text, filter.CpfDigits); } return query @@ -64,6 +44,21 @@ private static IQueryable FilterAndOrder( .ThenBy(client => client.Id); } + private static IQueryable Search(IQueryable query, string text, string? cpfDigits) + { + if (cpfDigits is not null) + { + return query.Where(client => + EF.Property(client, "NameNormalized").Contains(text) + || EF.Property(client, "EmailNormalized").Contains(text) + || EF.Property(client, "CpfNormalized").Contains(cpfDigits)); + } + + return query.Where(client => + EF.Property(client, "NameNormalized").Contains(text) + || EF.Property(client, "EmailNormalized").Contains(text)); + } + public async Task UpdateAsync(Client client, CancellationToken cancellationToken) { await SyncContactsAsync(client.Id, client.Guardians, cancellationToken); diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs index edc83432..1af2012c 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients; using ServicesService.Infrastructure.Persistence; using ServicesService.Infrastructure.Repositories; @@ -71,13 +72,13 @@ private static async Task> List( Guid tenantId, int page = 1, int pageSize = 20, - string? text = null, - string? cpfDigits = null, + string? search = null, ClientStatus? status = null) { await using var context = PersistenceContext.Create(databaseName, tenantId); - return await new ClientRepository(context) - .ListAsync(new PageRequest(page, pageSize), text, cpfDigits, status, CancellationToken.None); + var searchText = search is null ? null : SearchText.Create(search).Value; + var filter = ClientListFilter.Create(page, pageSize, searchText, status); + return await new ClientRepository(context).ListAsync(filter, CancellationToken.None); } private static string[] Names(PagedResult page) @@ -112,9 +113,9 @@ public async Task List_NeverMatchesAnotherTenantsClientBySearchByName_ByEmailNor await SaveAll(databaseName, tenantA, NewClient("Ana Lima")); await SaveAll(databaseName, tenantB, NewClient("Maria Souza", MariaCpf, "maria@example.com")); - var byName = await List(databaseName, tenantA, text: "maria"); - var byEmail = await List(databaseName, tenantA, text: "example.com"); - var byCpf = await List(databaseName, tenantA, text: MariaCpf, cpfDigits: MariaCpf); + var byName = await List(databaseName, tenantA, search: "maria"); + var byEmail = await List(databaseName, tenantA, search: "example.com"); + var byCpf = await List(databaseName, tenantA, search: MariaCpf); byName.Items.Should().BeEmpty(); byName.TotalCount.Should().Be(0); @@ -133,7 +134,7 @@ public async Task List_WithNoTenantInContext_ReturnsNothing() await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); var page = await new ClientRepository(tenantlessContext) - .ListAsync(new PageRequest(1, 20), null, null, null, CancellationToken.None); + .ListAsync(ClientListFilter.Create(1, 20, null, null), CancellationToken.None); page.Items.Should().BeEmpty(); page.TotalCount.Should().Be(0); @@ -150,8 +151,8 @@ public async Task List_NeverReturnsNorCountsASoftDeletedClient_EvenWhenItMatches await SoftDelete(databaseName, tenantId, deleted.Id); var all = await List(databaseName, tenantId); - var searched = await List(databaseName, tenantId, text: "ana"); - var byCpf = await List(databaseName, tenantId, text: JoseCpf, cpfDigits: JoseCpf); + var searched = await List(databaseName, tenantId, search: "ana"); + var byCpf = await List(databaseName, tenantId, search: JoseCpf); Names(all).Should().Equal("Ana Lima"); all.TotalCount.Should().Be(1); @@ -254,7 +255,7 @@ await SaveAll( NewClient("Silvana Reis"), NewClient("Ana Lima")); - var page = await List(databaseName, tenantId, text: "silva"); + var page = await List(databaseName, tenantId, search: "silva"); Names(page).Should().Equal("Maria SILVA Souza", "Silvana Reis"); page.TotalCount.Should().Be(2); @@ -272,9 +273,9 @@ await SaveAll( NewClient("Bruno Dias", MariaCpf, "bruno@gmail.com"), NewClient("Carla Souza")); - var byEmail = await List(databaseName, tenantId, text: "example"); - var byProviderOnly = await List(databaseName, tenantId, text: "@gmail"); - var byNameOfAClientWithoutEmail = await List(databaseName, tenantId, text: "carla"); + var byEmail = await List(databaseName, tenantId, search: "example"); + var byProviderOnly = await List(databaseName, tenantId, search: "@gmail"); + var byNameOfAClientWithoutEmail = await List(databaseName, tenantId, search: "carla"); Names(byEmail).Should().Equal("Ana Lima"); Names(byProviderOnly).Should().Equal("Bruno Dias"); @@ -282,7 +283,7 @@ await SaveAll( } [Fact] - public async Task List_SearchesTheCpfDigitsAnywhere_OnlyWhenTheHandlerSentThem() + public async Task List_SearchesTheCpfDigitsAnywhere_WithOrWithoutTheMask() { var databaseName = Guid.NewGuid().ToString(); var tenantId = Guid.NewGuid(); @@ -294,13 +295,11 @@ await SaveAll( NewClient("Paulo Reis", PauloCpf), NewClient("Ana Lima")); - var whole = await List(databaseName, tenantId, text: "529.982.247-25", cpfDigits: MariaCpf); - var partial = await List(databaseName, tenantId, text: "982", cpfDigits: "982"); - var withoutDigits = await List(databaseName, tenantId, text: "982"); + var whole = await List(databaseName, tenantId, search: "529.982.247-25"); + var partial = await List(databaseName, tenantId, search: "982"); Names(whole).Should().Equal("Maria Souza"); Names(partial).Should().Equal("Maria Souza"); - withoutDigits.Items.Should().BeEmpty(); } [Fact] @@ -320,10 +319,10 @@ public async Task List_CombinesTheSearchWithTheSituationAndThePage() await SetStatus(databaseName, tenantId, clients[1].Id, ClientStatus.Inactive); await SetStatus(databaseName, tenantId, clients[4].Id, ClientStatus.Inactive); - var activeSilvaFirstPage = await List(databaseName, tenantId, page: 1, pageSize: 2, text: "silva", status: ClientStatus.Active); - var activeSilvaSecondPage = await List(databaseName, tenantId, page: 2, pageSize: 2, text: "silva", status: ClientStatus.Active); - var inactiveSilva = await List(databaseName, tenantId, text: "silva", status: ClientStatus.Inactive); - var anySilva = await List(databaseName, tenantId, text: "silva", status: null); + var activeSilvaFirstPage = await List(databaseName, tenantId, page: 1, pageSize: 2, search: "silva", status: ClientStatus.Active); + var activeSilvaSecondPage = await List(databaseName, tenantId, page: 2, pageSize: 2, search: "silva", status: ClientStatus.Active); + var inactiveSilva = await List(databaseName, tenantId, search: "silva", status: ClientStatus.Inactive); + var anySilva = await List(databaseName, tenantId, search: "silva", status: null); Names(activeSilvaFirstPage).Should().Equal("Ana Silva", "Caio Silva"); Names(activeSilvaSecondPage).Should().Equal("Dani Silva"); diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs new file mode 100644 index 00000000..5e355484 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs @@ -0,0 +1,126 @@ +using Admin.SharedKernel; +using ServicesService.Application.Clients; + +namespace ServicesService.Tests.Clients; + +public class ClientListFilterTests +{ + private static SearchText SearchTextOf(string raw) + { + return SearchText.Create(raw).Value; + } + + [Fact] + public void Create_KeepsThePageAndExposesItsOffset() + { + var filter = ClientListFilter.Create(3, 50, null, null); + + filter.Page.Should().Be(3); + filter.PageSize.Should().Be(50); + filter.Offset.Should().Be(100); + filter.Should().BeAssignableTo(); + } + + [Theory] + [InlineData(0, 20)] + [InlineData(1, 0)] + [InlineData(21_474_838, 100)] + public void Create_WithAPageNoRepositoryCouldServe_Throws(int page, int pageSize) + { + var act = () => ClientListFilter.Create(page, pageSize, null, null); + + act.Should().Throw(); + } + + [Fact] + public void Create_WithoutSearch_HasNoTextAndNoCpf() + { + var filter = ClientListFilter.Create(1, 20, null, null); + + filter.Text.Should().BeNull(); + filter.CpfDigits.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Create_WithAnEmptySearchText_IsNoSearch(string raw) + { + var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); + + filter.Text.Should().BeNull(); + filter.CpfDigits.Should().BeNull(); + } + + [Theory] + [InlineData(" Maria SILVA ", "maria silva")] + [InlineData("MARIA@Example.COM", "maria@example.com")] + [InlineData("josé", "josé")] + [InlineData("50%_off", "50%_off")] + public void Create_KeepsTheNormalizedTextForTheNameAndTheEmail(string raw, string expected) + { + ClientListFilter.Create(1, 20, SearchTextOf(raw), null).Text.Should().Be(expected); + } + + [Theory] + [InlineData("529.982.247-25", "52998224725")] + [InlineData("52998224725", "52998224725")] + [InlineData(" 529 982 247 25 ", "52998224725")] + [InlineData("123", "123")] + [InlineData("456.789", "456789")] + [InlineData("0", "0")] + public void Create_WithDigitsAndCpfMask_AlsoCarriesTheCpfWithoutTheMask(string raw, string expectedDigits) + { + var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); + + filter.CpfDigits.Should().Be(expectedDigits); + filter.Text.Should().Be(raw.Trim()); + } + + [Theory] + [InlineData("maria")] + [InlineData("maria.silva@example.com")] + [InlineData("ana-lima")] + [InlineData("a1")] + [InlineData("1a")] + [InlineData("529.982.247-2x")] + [InlineData("12/34")] + [InlineData("12,5")] + [InlineData(".")] + [InlineData("-")] + [InlineData("...---")] + public void Create_WithAnythingButDigitsAndTheMask_DoesNotCarryTheCpf(string raw) + { + var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); + + filter.CpfDigits.Should().BeNull(); + filter.Text.Should().Be(raw); + } + + [Fact] + public void Create_OnlyAcceptsAsciiDigitsForTheCpf() + { + ClientListFilter.Create(1, 20, SearchTextOf("١٢٣"), null).CpfDigits.Should().BeNull(); + ClientListFilter.Create(1, 20, SearchTextOf("123"), null).CpfDigits.Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData(ClientStatus.Active)] + [InlineData(ClientStatus.Inactive)] + public void Create_KeepsTheStatusItWasGiven(ClientStatus? status) + { + ClientListFilter.Create(1, 20, null, status).Status.Should().Be(status); + } + + [Fact] + public void TwoFiltersWithTheSameValues_AreEqual() + { + var filter = ClientListFilter.Create(2, 20, SearchTextOf("Maria"), ClientStatus.Active); + + filter.Should().Be(ClientListFilter.Create(2, 20, SearchTextOf(" MARIA "), ClientStatus.Active)); + filter.Should().NotBe(ClientListFilter.Create(3, 20, SearchTextOf("Maria"), ClientStatus.Active)); + filter.Should().NotBe(ClientListFilter.Create(2, 20, SearchTextOf("Maria"), ClientStatus.Inactive)); + filter.Should().NotBe(ClientListFilter.Create(2, 20, SearchTextOf("Mario"), ClientStatus.Active)); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 7c85f385..5d46c182 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Admin.SharedKernel; using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients; using ServicesService.Application.Clients.ListClients; namespace ServicesService.Tests.Clients.ListClients; @@ -18,16 +19,11 @@ public ListClientsQueryHandlerTests() private void Returns(IReadOnlyList clients, int totalCount) { _repository - .ListAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()) + .ListAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => { - var page = callInfo.Arg(); - return Task.FromResult(new PagedResult(clients, totalCount, page.Page, page.PageSize)); + var filter = callInfo.Arg(); + return Task.FromResult(new PagedResult(clients, totalCount, filter.Page, filter.PageSize)); }); } @@ -58,7 +54,18 @@ private static Client MinimalClient(string name) private async Task AssertListed(string? text, string? cpfDigits, ClientStatus? status, int page = 1, int pageSize = 20) { - await _repository.Received(1).ListAsync(new PageRequest(page, pageSize), text, cpfDigits, status, Arg.Any()); + await _repository.Received(1).ListAsync( + Arg.Is(filter => FilterIs(filter, text, cpfDigits, status, page, pageSize)), + Arg.Any()); + } + + private static bool FilterIs(ClientListFilter filter, string? text, string? cpfDigits, ClientStatus? status, int page, int pageSize) + { + return filter.Page == page + && filter.PageSize == pageSize + && filter.Text == text + && filter.CpfDigits == cpfDigits + && filter.Status == status; } [Fact] @@ -184,10 +191,7 @@ public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, str [Theory] [InlineData("529.982.247-25", "529.982.247-25", "52998224725")] - [InlineData("52998224725", "52998224725", "52998224725")] - [InlineData(" 529 982 247 25 ", "529 982 247 25", "52998224725")] [InlineData("123", "123", "123")] - [InlineData("456.789", "456.789", "456789")] public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(string search, string expectedText, string expectedDigits) { await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); @@ -197,12 +201,7 @@ public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(s [Theory] [InlineData("maria")] - [InlineData("maria.silva@example.com")] [InlineData("ana-lima")] - [InlineData("a1")] - [InlineData("529.982.247-2x")] - [InlineData(".")] - [InlineData("-")] public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(string search) { await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); diff --git a/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs index 128e3bcd..6ed8b0ee 100644 --- a/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs @@ -2,6 +2,17 @@ namespace Admin.SharedKernel.Tests; public class PageRequestTests { + private sealed record FilteredPage : PageRequest + { + public FilteredPage(int page, int pageSize, string tag) + : base(page, pageSize) + { + Tag = tag; + } + + public string Tag { get; } + } + [Fact] public void Constructor_WithAValidPage_KeepsThePageAndThePageSize() { @@ -75,6 +86,28 @@ public void TwoRequestsForTheSamePage_AreEqual() new PageRequest(2, 20).Should().NotBe(new PageRequest(2, 10)); } + [Fact] + public void ARecordThatExtendsIt_KeepsTheGuardAndTheOffset() + { + var filtered = new FilteredPage(3, 50, "tag"); + + filtered.Page.Should().Be(3); + filtered.PageSize.Should().Be(50); + filtered.Offset.Should().Be(100); + filtered.Should().BeAssignableTo(); + + var act = () => new FilteredPage(0, 50, "tag"); + act.Should().Throw().WithParameterName("page"); + } + + [Fact] + public void ARecordThatExtendsIt_IsNeverEqualToAPlainRequestOfTheSamePage() + { + new FilteredPage(2, 20, "tag").Should().NotBe(new PageRequest(2, 20)); + new FilteredPage(2, 20, "tag").Should().Be(new FilteredPage(2, 20, "tag")); + new FilteredPage(2, 20, "tag").Should().NotBe(new FilteredPage(2, 20, "other")); + } + [Fact] public void TheLimits_AreTheOnesTheListsPromise() { diff --git a/backend/shared/Admin.SharedKernel/PageRequest.cs b/backend/shared/Admin.SharedKernel/PageRequest.cs index 6a979713..49282667 100644 --- a/backend/shared/Admin.SharedKernel/PageRequest.cs +++ b/backend/shared/Admin.SharedKernel/PageRequest.cs @@ -1,6 +1,6 @@ namespace Admin.SharedKernel; -public sealed record PageRequest +public record PageRequest { public const int FirstPage = 1; public const int DefaultPageSize = 20; diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index b204ff1f..96ab5298 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -66,7 +66,9 @@ it as "contains": that person; `982` finds the CPFs that contain 982 and, as any term, the names and e-mails that contain it. The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is -found by name. The handler derives the two plain values (`text`, from `query.Search`, and `cpfDigits`); the repository port takes them. +found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): its +`Create` derives the two plain values (`Text`, from the `SearchText` of `query.Search`, and `CpfDigits`), and the +repository port takes the filter. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not change. "Normalized" is `lower("FullName")`, like `Service`, `Tag` and `Category`: accents stay, so `jose` does not diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index d4124194..ca3c47e7 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -24,19 +24,25 @@ repository receives, and the repository operation itself. repository. Building one that cannot be served throws `ArgumentOutOfRangeException` (page below 1, size below 1, an offset that does not fit an `int`): a programmer-error guard, because the validator already refused those inputs with a `400`. It exposes `Offset`, the limits (`FirstPage` 1, `DefaultPageSize` 20, `MaxPageSize` 100), the stable -error codes below and `OffsetFits(page, pageSize)`, the one formula the validators and the constructor share. +error codes below and `OffsetFits(page, pageSize)`, the one formula the validators and the constructor share. It is +not `sealed` on purpose: the filter of a list extends it (below), so the guard comes with the page. **`RepositoryBase.ListPagedAsync(PageRequest, filterAndOrder, ct)`** returns `PagedResult`: the items, the real total, and the page and size it was asked for. The filter-and-order function is required: a page without an explicit order is a bug. **`PagedResult.Map(...)`** converts the items and keeps the total, the page and the size, so a handler is -`repository.ListAsync(new PageRequest(query.Page, query.PageSize), , ct)` followed by +`repository.ListAsync(ListFilter.Create(query.Page, query.PageSize, ), ct)` followed by `.Map(ListItemResponse.From)`. -**The port** of a paged list is `ListAsync(PageRequest page, , ct)` returning `PagedResult`. -The filters stay plain values per port: ids, value objects, strings, a status. Nothing generic filters for the -repository (see below). +**The filter of a list extends it.** A paged list has one record, `ClientListFilter : PageRequest`, that carries the +page and the plain filters of that list (`Text`, `CpfDigits`, `Status`): one object from the handler to the +repository, where a new criterion is one more property and not one more parameter. Its `Create` normalizes what comes +from the query (the search text, the digits of the CPF) and the base constructor keeps the guard. The port is +`ListAsync(ClientListFilter filter, ct)` returning `PagedResult`, and the adapter hands the filter to +`ListPagedAsync(filter, filterAndOrder, ct)`, which reads only the page from it. The filter holds plain values: no +expression, no specification, nothing generic (see below). Only the filter of a list extends `PageRequest`; a query or +a command never does, because they carry unvalidated input and have to be buildable with any number. **Validation** stays in each validator ([ADR 0061](0061-validators-write-their-own-rules.md)): the limits come from the constants of `PageRequest`, and the three rules carry codes that every list shares, so a client branches once: @@ -51,7 +57,7 @@ A page whose offset does not fit an `int` is a `400`, not an empty page: the cli holds, and the error says so. **The query stays flat.** `ListClientsQuery` keeps `Page` and `PageSize` as members, with `PageRequest` constants as -defaults, because the wire is `?page=&pageSize=`. The handler builds the `PageRequest`. +defaults, because the wire is `?page=&pageSize=`. The handler builds the filter after the validator ran. **Only clients adopt it for now.** `ServiceRepository` and `ListServicesQueryHandler` keep the older `ListPagedAsync(order, page, pageSize)`, which stays beside the new overload and shares its private slice-reading @@ -69,6 +75,8 @@ step, so services behave exactly as before, including the two wrong answers abov - A repository test cannot live in `Admin.SharedKernel.Tests` (it has no EF). The paged helper is proven through the clients persistence tests (items, total, echoed page and size, a page past the last, no repeat or omission). - The frontend's generated types do not change: `PagedResult` has the same schema. +- A filter that extends `PageRequest` is never equal to a plain `PageRequest` of the same page: record equality includes + the type. A test that wants the filter compares the filter. Verified on the same disposable PostgreSQL 18.3 as [ADR 0063](0063-clients-list-and-search-contract.md), on the final code: the 90 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; @@ -82,10 +90,18 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared - **A shared `PagedQueryValidator` (`Include`) over an `IPagedQuery` interface.** One source for the rules, but it reopens [ADR 0061](0061-validators-write-their-own-rules.md) and adds an interface to the kernel for three one-line rules. -- **A filter object or specification passed to the repository** (`ListAsync(PageRequest, ISpecification)`, or an +- **A generic filter or specification passed to the repository** (`ListAsync(PageRequest, ISpecification)`, or an `Expression>`). It is what "filter any request" suggests, but the architecture keeps ports to plain values ([ARCHITECTURE](../../backend/docs/ARCHITECTURE.md) §5): each list has its own filters, and the repository - stays the seam that knows how to apply them. + stays the seam that knows how to apply them. A record of plain values that belongs to one list is not that. +- **`ListClientsQuery` extending `PageRequest`.** Binding would run the guard on raw input (`?page=0` would throw + instead of answering `Paging.PageInvalid`), and the handler would hand the port a query carrying the wire's `Search` + and `Status`. The filter, built after the validator, can extend it. +- **The filter holding a `PageRequest`** instead of extending it. Equally simple; one flat object was preferred, and + `ListPagedAsync(PageRequest, …)` accepts the filter as it is. +- **The first version of this change**: `ListAsync(PageRequest page, string? text, string? cpfDigits, ClientStatus? + status, ct)`. It grows by one parameter per criterion, and the text and the digits were one concept in two arguments. + Changed after review. - **A base query record with `Page` and `PageSize`.** Binding positional members inherited through `[FromQuery]` and a base type on every query go against "no base handler, no generic CRUD". - **Clamping to an empty page in the helper, and dropping `Paging.PageTooLarge`.** Safe by construction for every From 3c1360e29437ac20b10a58ab71cc4e530cc89b8f Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 11:02:00 -0300 Subject: [PATCH 08/24] refactor(services): tipar o status do item da lista de pessoas com o enum ClientStatus (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientListItemResponse convertia o status à mão, com `client.Status.ToString().ToLowerInvariant()`. O kernel já registra JsonStringEnumConverter(CamelCase) nas opções do MVC e do minimal API, então um enum numa resposta sai como string camelCase sozinho (ADR 0058, ARCHITECTURE §6). - ClientListItemResponse.Status passa a ser ClientStatus e o FromClient o repassa. O JSON não muda: continua "active" e "inactive". - services-api.d.ts regenerado: o status do item sai como 'active' | 'inactive' em vez de string, como já é o ContactPurpose. - A conversão manual só dava certo por coincidência: ela minusculiza tudo, e o conversor usa camelCase, então um membro de duas palavras divergiria. - ARCHITECTURE §2 e use-case.md diziam que a resposta "achata o enum para string camelCase", o que se lê como uma conversão manual. Passam a dizer que o enum continua enum e que o conversor do kernel o nomeia. - ClientResponse (o detalhe) tem a mesma linha e não foi tocado. Co-Authored-By: Claude Sonnet 5.5 --- .claude/skills/agenza-backend-slice/references/use-case.md | 3 ++- .../src/shared/api/generated/services-api.d.ts | 4 +++- backend/docs/ARCHITECTURE.md | 5 +++-- .../Clients/ListClients/ClientListItemResponse.cs | 4 ++-- .../Clients/ListClients/ListClientsQueryHandlerTests.cs | 4 ++-- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 1f847b04..84c84d88 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -110,7 +110,8 @@ No `try/catch`, no base class, no loader or service class between handlers. - `Response`, a `sealed record`, born in the operation folder and moved to the feature root when a second operation returns it. - `public static Response From( entity, …)`: a value object becomes its - `.Value` (`?.Value` when optional), an enum its camelCase wire name, each child its own + `.Value` (`?.Value` when optional), an enum stays the enum (the kernel's converter writes its camelCase name; no + `ToString()`), each child its own `Response.From`, another aggregate an `Summary(Id, Name, …)` built from what the handler read. - The DTO decides nothing. A derived value the interface needs is exposed by the domain and copied diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 604f9617..6cf1c792 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -1943,7 +1943,7 @@ export interface components { phone: null | string; email: null | string; cpf: null | string; - status: string; + status: components['schemas']['ClientStatus']; }; ClientResponse: { /** Format: uuid */ @@ -1960,6 +1960,8 @@ export interface components { referenceContacts: components['schemas']['ReferenceContactResponse'][]; }; /** @enum {unknown} */ + ClientStatus: 'active' | 'inactive'; + /** @enum {unknown} */ ClientStatusFilter: 'active' | 'inactive' | 'all' | null; /** @enum {unknown} */ ContactPurpose: 'emergency' | 'operationalSupport' | 'dailyCommunication'; diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index e8891681..69f997f8 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -145,8 +145,9 @@ nested `Input` records: primitives, strings, `DateOnly`, ids and the shar no tenant. The output is `Response` with a static `From(entity, …)`, built from the aggregate plus what the handler read explicitly for it; another aggregate appears as a small `Summary`. In a -response, value objects flatten to their primitive and enums to camelCase strings. No mapping library, no DTO in the -domain, no entity past the handler. +response, a value object flattens to its primitive, and an enum stays the enum: the kernel's converter writes it as a +camelCase string (§6), so the mapping never calls `ToString()` on it. No mapping library, no DTO in the domain, no +entity past the handler. ## 3. Domain diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs index 3e2a3136..86f41671 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs @@ -7,7 +7,7 @@ public sealed record ClientListItemResponse( string? Phone, string? Email, string? Cpf, - string Status) + ClientStatus Status) { public static ClientListItemResponse FromClient(Client client) { @@ -18,6 +18,6 @@ public static ClientListItemResponse FromClient(Client client) client.Phone?.Value, client.Email?.Value, client.Cpf?.Value, - client.Status.ToString().ToLowerInvariant()); + client.Status); } } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 5d46c182..185d1dec 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -84,7 +84,7 @@ public async Task Handle_ReturnsEveryListFieldOfTheClientWithItsWholeCpf() item.Phone.Should().Be("(11) 99999-0000"); item.Email.Should().Be("paula@example.com"); item.Cpf.Should().Be("52998224725"); - item.Status.Should().Be("active"); + item.Status.Should().Be(ClientStatus.Active); } [Fact] @@ -110,7 +110,7 @@ public async Task Handle_ForAnInactiveClient_ReturnsTheInactiveSituation() var result = await _handler.Handle(new ListClientsQuery(Status: ClientStatusFilter.All), CancellationToken.None); - result.Value.Items.Should().ContainSingle().Which.Status.Should().Be("inactive"); + result.Value.Items.Should().ContainSingle().Which.Status.Should().Be(ClientStatus.Inactive); } [Fact] From e1fa5ced1d29385942c1b0e2056d3c1455e1c91a Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 11:23:58 -0300 Subject: [PATCH 09/24] refactor(services): estender PagedQuery na ListClientsQuery para reaproveitar Page e PageSize (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ListClientsQuery declarava `Page` e `PageSize` com os padrões, e cada lista nova repetiria os dois membros. Passam a vir de uma base de entrada. - PagedQuery (Admin.SharedKernel): record abstrato com Page e PageSize como propriedades `init` e os padrões de PageRequest. Sem guarda, porque guarda entrada crua: o validator continua sendo o portão e PageRequest continua sendo a página que o repositório consegue servir. - ListClientsQuery estende PagedQuery e declara só Search e Status. O fio continua plano (?page=&pageSize=&search=&status=) e a página é definida por initializer. - PagedQueryTests prova com o ParameterBinder do MVC que Page e PageSize herdados são ligados pela query string em qualquer caixa, que os padrões valem quando ausentes e que um valor inválido é relatado sob a chave da página. - Ao vivo: 94 verificações de pessoas, 10 de edição e 11 de serviços passam; o OpenAPI publica os mesmos quatro parâmetros, só em outra ordem, e o services-api.d.ts é regenerado. - ADR 0064, ARCHITECTURE §1 e §6 e use-case.md ajustados: a base de query, antes rejeitada, passa a ser adotada, com o motivo. Co-Authored-By: Claude Sonnet 5.5 --- .../references/use-case.md | 6 +- .../shared/api/generated/services-api.d.ts | 4 +- backend/docs/ARCHITECTURE.md | 5 +- .../Clients/ListClients/ListClientsQuery.cs | 4 +- .../ListClientsQueryHandlerTests.cs | 6 +- .../ListClientsQueryValidatorTests.cs | 24 ++-- .../PagedQueryTests.cs | 119 ++++++++++++++++++ .../shared/Admin.SharedKernel/PagedQuery.cs | 8 ++ ...ged-reads-page-request-and-paged-result.md | 21 +++- 9 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs create mode 100644 backend/shared/Admin.SharedKernel/PagedQuery.cs diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 84c84d88..248a3b4c 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -16,9 +16,9 @@ messages", plus the earlier slices for reads, edits and paging. This file is how nothing to register and no `Names` class. A list of enums gets an `IsInEnum` rule per item with the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. -- A list query carries its filters as optional members and `Page`/`PageSize` with the `PageRequest` defaults; the - handler builds the `ListFilter` (a `PageRequest` with the list's filters) and the validator writes the three - `Paging.*` rules (ADR 0064). +- A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as + optional members; the handler builds the `ListFilter` (a `PageRequest` with the list's filters) and the + validator writes the three `Paging.*` rules (ADR 0064). ## 2. The validator — simple and clean diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 6cf1c792..3b554094 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -462,10 +462,10 @@ export interface paths { get: { parameters: { query?: { - Page?: number | string; - PageSize?: number | string; Search?: string; Status?: components['schemas']['ClientStatusFilter']; + Page?: number | string; + PageSize?: number | string; }; header?: never; path: { diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 69f997f8..30c8a269 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -71,7 +71,7 @@ Shared projects in `backend/shared/` hold infrastructure, never business rules: | Project | Holds | Referenced by | | --- | --- | --- | -| `Admin.SharedKernel` | `Result`, `Error`/`ErrorType`/`FieldError`, CQRS contracts, `IDispatcher`, `PageRequest` and `PagedResult`, the JSON converters of the wire (value objects, enum names) | Application | +| `Admin.SharedKernel` | `Result`, `Error`/`ErrorType`/`FieldError`, CQRS contracts, `IDispatcher`, `PagedQuery`, `PageRequest` and `PagedResult`, the JSON converters of the wire (value objects, enum names) | Application | | `Admin.SharedKernel.AspNetCore` | `ToActionResult`, `ApiResponse`, `ApiProblemDetails`, `AgenzaControllerBase`, `GenericExceptionHandler`, `AddWireJson()` | Api | | `Admin.SharedKernel.EntityFrameworkCore` | `RepositoryBase` (with the paged read over `PageRequest`), `ApplyAuditableConventions` (soft-delete and tenant filters), the column conversion of the shared value objects | Infrastructure | | `Admin.SharedKernel.ValueObjects` | `IStringValueObject` and the string value objects every service shares (`CpfNumber`); no reference at all | Domain | @@ -391,7 +391,8 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult translation class. Each item of a list of enums has an `IsInEnum` rule with the domain's code ([0058](../../docs/adr/0058-closed-set-members-are-plain-enums.md)). - The wire is camelCase English; calendar dates are `DateOnly` (`yyyy-MM-dd`); instants are UTC. -- A paged list takes `Page`/`PageSize` with the `PageRequest` defaults and a validator that writes its three rules +- The query of a paged list extends `PagedQuery` (`Page` and `PageSize`, with the `PageRequest` defaults, unguarded) and + its validator writes the three rules (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared `Paging.*` codes; the handler builds the list's filter (`ClientListFilter`, a `PageRequest` with the plain filters), the port takes it and returns `PagedResult`, and the diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 7ad3834b..dc0b30a3 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -3,7 +3,5 @@ namespace ServicesService.Application.Clients.ListClients; public sealed record ListClientsQuery( - int Page = PageRequest.FirstPage, - int PageSize = PageRequest.DefaultPageSize, SearchText? Search = null, - ClientStatusFilter? Status = ClientStatusFilter.Active) : IQuery>; + ClientStatusFilter? Status = ClientStatusFilter.Active) : PagedQuery, IQuery>; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 185d1dec..a75625f3 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -128,7 +128,7 @@ public async Task Handle_EchoesThePageTheTotalAndThePageSize() { Returns([MinimalClient("Ana"), MinimalClient("Bia")], 7); - var result = await _handler.Handle(new ListClientsQuery(Page: 2, PageSize: 2), CancellationToken.None); + var result = await _handler.Handle(new ListClientsQuery { Page = 2, PageSize = 2 }, CancellationToken.None); result.Value.Page.Should().Be(2); result.Value.PageSize.Should().Be(2); @@ -142,7 +142,7 @@ public async Task Handle_WithAPagePastTheLast_ReturnsAnEmptyPageAndTheTotal() { Returns([], 3); - var result = await _handler.Handle(new ListClientsQuery(Page: 9, PageSize: 20), CancellationToken.None); + var result = await _handler.Handle(new ListClientsQuery { Page = 9, PageSize = 20 }, CancellationToken.None); result.IsSuccess.Should().BeTrue(); result.Value.Items.Should().BeEmpty(); @@ -220,7 +220,7 @@ public async Task Handle_WithAnEmptySearchText_DoesNotSearch() [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { - await _handler.Handle(new ListClientsQuery(Page: 3, PageSize: 50, Search: SearchTextOf("Silva"), Status: ClientStatusFilter.Inactive), CancellationToken.None); + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf("Silva"), Status: ClientStatusFilter.Inactive) { Page = 3, PageSize = 50 }, CancellationToken.None); await AssertListed("silva", null, ClientStatus.Inactive, page: 3, pageSize: 50); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs index 14b6c40b..737fde8f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -10,12 +10,12 @@ public static TheoryData EveryRule() { return new TheoryData { - { "page below one", new ListClientsQuery(Page: 0), "Page", "Paging.PageInvalid" }, - { "negative page", new ListClientsQuery(Page: -3), "Page", "Paging.PageInvalid" }, - { "page whose offset overflows", new ListClientsQuery(Page: int.MaxValue), "Page", "Paging.PageTooLarge" }, - { "page just past the offset limit", new ListClientsQuery(Page: 21_474_838, PageSize: 100), "Page", "Paging.PageTooLarge" }, - { "page size below one", new ListClientsQuery(PageSize: 0), "PageSize", "Paging.PageSizeInvalid" }, - { "page size above the maximum", new ListClientsQuery(PageSize: 101), "PageSize", "Paging.PageSizeInvalid" }, + { "page below one", new ListClientsQuery { Page = 0 }, "Page", "Paging.PageInvalid" }, + { "negative page", new ListClientsQuery { Page = -3 }, "Page", "Paging.PageInvalid" }, + { "page whose offset overflows", new ListClientsQuery { Page = int.MaxValue }, "Page", "Paging.PageTooLarge" }, + { "page just past the offset limit", new ListClientsQuery { Page = 21_474_838, PageSize = 100 }, "Page", "Paging.PageTooLarge" }, + { "page size below one", new ListClientsQuery { PageSize = 0 }, "PageSize", "Paging.PageSizeInvalid" }, + { "page size above the maximum", new ListClientsQuery { PageSize = 101 }, "PageSize", "Paging.PageSizeInvalid" }, { "a status past the last member", new ListClientsQuery(Status: (ClientStatusFilter)7), "Status", "Client.StatusFilterInvalid" }, { "a negative status", new ListClientsQuery(Status: (ClientStatusFilter)(-1)), "Status", "Client.StatusFilterInvalid" }, { "zero, which is no member", new ListClientsQuery(Status: default(ClientStatusFilter)), "Status", "Client.StatusFilterInvalid" }, @@ -50,9 +50,9 @@ public void Validate_WithTheDefaultQuery_Passes() [Fact] public void Validate_WithEveryLimitAtItsBoundary_Passes() { - _validator.Validate(new ListClientsQuery(Page: 1, PageSize: 1)).IsValid.Should().BeTrue(); - _validator.Validate(new ListClientsQuery(Page: 21_474_837, PageSize: 100)).IsValid.Should().BeTrue(); - _validator.Validate(new ListClientsQuery(PageSize: 100)).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery { Page = 1, PageSize = 1 }).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery { Page = 21_474_837, PageSize = 100 }).IsValid.Should().BeTrue(); + _validator.Validate(new ListClientsQuery { PageSize = 100 }).IsValid.Should().BeTrue(); } [Theory] @@ -70,7 +70,7 @@ public void Validate_WithAMemberOfTheEnumOrNoStatus_Passes(ClientStatusFilter? s [Fact] public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 100)); + var result = _validator.Validate(new ListClientsQuery { Page = 0, PageSize = 100 }); result.Errors.Select(error => error.ErrorCode).Should().Equal("Paging.PageInvalid"); } @@ -78,7 +78,7 @@ public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() [Fact] public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: (ClientStatusFilter)9)); + var result = _validator.Validate(new ListClientsQuery(Status: (ClientStatusFilter)9) { Page = 0, PageSize = 0 }); result.Errors.Select(error => (error.PropertyName, error.ErrorCode)).Should().BeEquivalentTo( [ @@ -91,7 +91,7 @@ public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() [Fact] public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() { - var result = _validator.Validate(new ListClientsQuery(Page: 0, PageSize: 0, Status: (ClientStatusFilter)9)); + var result = _validator.Validate(new ListClientsQuery(Status: (ClientStatusFilter)9) { Page = 0, PageSize = 0 }); result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( [ diff --git a/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs b/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs new file mode 100644 index 00000000..61b41339 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs @@ -0,0 +1,119 @@ +using Admin.SharedKernel.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using System.Globalization; + +namespace Admin.SharedKernel.Tests; + +public class PagedQueryTests +{ + public sealed record ThingsQuery(string? Term = null, bool Archived = false) : PagedQuery; + + private static async Task<(ThingsQuery? Model, ModelStateDictionary State)> Bind(Dictionary values) + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddLogging(); + services.AddControllers().AddWireJson(); + var provider = services.BuildServiceProvider(); + + var metadata = provider.GetRequiredService().GetMetadataForType(typeof(ThingsQuery)); + var parameter = new ParameterDescriptor + { + Name = "query", + ParameterType = typeof(ThingsQuery), + BindingInfo = new BindingInfo { BindingSource = BindingSource.Query }, + }; + var binder = provider.GetRequiredService().CreateBinder( + new ModelBinderFactoryContext { Metadata = metadata, BindingInfo = parameter.BindingInfo, CacheToken = parameter }); + var actionContext = new ActionContext( + new DefaultHttpContext { RequestServices = provider }, + new RouteData(), + new ActionDescriptor()); + var valueProvider = new QueryStringValueProvider(BindingSource.Query, new QueryCollection(new Dictionary(values, StringComparer.OrdinalIgnoreCase)), CultureInfo.InvariantCulture); + + var result = await provider.GetRequiredService() + .BindModelAsync(actionContext, binder, valueProvider, parameter, metadata, value: null); + + return (result.Model as ThingsQuery, actionContext.ModelState); + } + + [Fact] + public void ANewQuery_AsksForTheFirstPageWithTheDefaultSize() + { + var query = new ThingsQuery(); + + query.Page.Should().Be(PageRequest.FirstPage); + query.PageSize.Should().Be(PageRequest.DefaultPageSize); + } + + [Fact] + public void ThePageAndTheSize_AreSetWithAnInitializerAndAreNotGuarded() + { + var query = new ThingsQuery("x") { Page = 0, PageSize = -5 }; + + query.Page.Should().Be(0); + query.PageSize.Should().Be(-5); + query.Term.Should().Be("x"); + } + + [Fact] + public void TwoQueriesWithTheSamePageAndMembers_AreEqual() + { + new ThingsQuery("x") { Page = 2 }.Should().Be(new ThingsQuery("x") { Page = 2 }); + new ThingsQuery("x") { Page = 2 }.Should().NotBe(new ThingsQuery("x") { Page = 3 }); + new ThingsQuery("x") { Page = 2 }.Should().NotBe(new ThingsQuery("y") { Page = 2 }); + } + + [Fact] + public async Task TheMvcBinder_ReadsTheInheritedPageAndSizeAndTheOwnMembersFromTheQueryString() + { + var (model, state) = await Bind(new Dictionary + { + ["page"] = "3", + ["pageSize"] = "50", + ["term"] = "maria", + ["archived"] = "true", + }); + + state.ErrorCount.Should().Be(0); + model.Should().NotBeNull(); + model!.Page.Should().Be(3); + model.PageSize.Should().Be(50); + model.Term.Should().Be("maria"); + model.Archived.Should().BeTrue(); + } + + [Fact] + public async Task TheMvcBinder_KeepsTheDefaultsWhenThePageIsNotInTheQueryString() + { + var (model, state) = await Bind(new Dictionary { ["term"] = "maria" }); + + state.ErrorCount.Should().Be(0); + model!.Page.Should().Be(PageRequest.FirstPage); + model.PageSize.Should().Be(PageRequest.DefaultPageSize); + model.Term.Should().Be("maria"); + } + + [Fact] + public async Task TheMvcBinder_ReadsTheNamesWhateverTheirCase() + { + var (model, _) = await Bind(new Dictionary { ["PAGE"] = "4", ["Pagesize"] = "10" }); + + model!.Page.Should().Be(4); + model.PageSize.Should().Be(10); + } + + [Fact] + public async Task TheMvcBinder_ReportsAPageThatIsNotANumberUnderThePageKey() + { + var (_, state) = await Bind(new Dictionary { ["page"] = "abc" }); + + state.Keys.Should().Contain(key => key.EndsWith("Page", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/backend/shared/Admin.SharedKernel/PagedQuery.cs b/backend/shared/Admin.SharedKernel/PagedQuery.cs new file mode 100644 index 00000000..a22cc7f3 --- /dev/null +++ b/backend/shared/Admin.SharedKernel/PagedQuery.cs @@ -0,0 +1,8 @@ +namespace Admin.SharedKernel; + +public abstract record PagedQuery +{ + public int Page { get; init; } = PageRequest.FirstPage; + + public int PageSize { get; init; } = PageRequest.DefaultPageSize; +} diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index ca3c47e7..22a1b5a7 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -56,8 +56,13 @@ the constants of `PageRequest`, and the three rules carry codes that every list A page whose offset does not fit an `int` is a `400`, not an empty page: the client asked for something no table holds, and the error says so. -**The query stays flat.** `ListClientsQuery` keeps `Page` and `PageSize` as members, with `PageRequest` constants as -defaults, because the wire is `?page=&pageSize=`. The handler builds the filter after the validator ran. +**A paged query extends `PagedQuery`.** `PagedQuery`, in `Admin.SharedKernel`, is an abstract record with `Page` and +`PageSize` as `init` properties that default to the `PageRequest` constants, and no guard: it holds the raw input, and +the validator is the gate. `ListClientsQuery` extends it and declares only its own members (`Search`, `Status`), so the +wire stays flat (`?page=&pageSize=&search=&status=`), a new list does not redeclare the two members or their defaults, +and a paging value is set with an initializer (`new ListClientsQuery { Page = 2 }`). The handler builds the filter after +the validator ran. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other +is a page a repository can serve. The query is never the filter, and the filter is never bound from the wire. **Only clients adopt it for now.** `ServiceRepository` and `ListServicesQueryHandler` keep the older `ListPagedAsync(order, page, pageSize)`, which stays beside the new overload and shares its private slice-reading @@ -74,7 +79,8 @@ step, so services behave exactly as before, including the two wrong answers abov - Two ways to page exist in `RepositoryBase` until services is converted. The older one is the only unguarded one. - A repository test cannot live in `Admin.SharedKernel.Tests` (it has no EF). The paged helper is proven through the clients persistence tests (items, total, echoed page and size, a page past the last, no repeat or omission). -- The frontend's generated types do not change: `PagedResult` has the same schema. +- The frontend's generated types keep the same query parameters; `Page` and `PageSize` come after the list's own + members in the generated `query` object, because the properties are inherited. - A filter that extends `PageRequest` is never equal to a plain `PageRequest` of the same page: record equality includes the type. A test that wants the filter compares the filter. @@ -102,8 +108,13 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared - **The first version of this change**: `ListAsync(PageRequest page, string? text, string? cpfDigits, ClientStatus? status, ct)`. It grows by one parameter per criterion, and the text and the digits were one concept in two arguments. Changed after review. -- **A base query record with `Page` and `PageSize`.** Binding positional members inherited through `[FromQuery]` - and a base type on every query go against "no base handler, no generic CRUD". +- **A base query record with `Page` and `PageSize`** (rejected in the first version, adopted after review). The fears + were that MVC would not bind inherited members and that a base type goes against "no base handler, no generic CRUD". + A test of the MVC binder (`PagedQueryTests`) and the live service show that the inherited `init` properties are + bound from the query string in any case, and that the OpenAPI document keeps the same four parameters (only their + order changes). The rule is about handlers and CRUD, and a record with two properties is neither. +- **A positional base (`PagedQuery(int Page, int PageSize)`).** A derived positional record has to repeat both + parameters to bind through its constructor, which is the duplication this removes. - **Clamping to an empty page in the helper, and dropping `Paging.PageTooLarge`.** Safe by construction for every future list; the owner preferred the explicit `400`. - **Codes per feature** (`Client.PageInvalid`, `Service.PageInvalid`). The same rule would have a code per list. From 58596fd42235326a2b13b12ec2ce3e428e5d398a Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 11:56:42 -0300 Subject: [PATCH 10/24] =?UTF-8?q?refactor(services):=20converter=20a=20lis?= =?UTF-8?q?ta=20de=20servi=C3=A7os=20ao=20PageRequest,=20ao=20PagedQuery?= =?UTF-8?q?=20e=20ao=20PagedResult=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lista de serviços era a única que ainda usava o helper antigo ListPagedAsync(order, page, pageSize), que calcula o offset em int: `GET /api/v1/services?page=2147483647&pageSize=100` respondia 500 e `page=43000000` calculava um offset errado em silêncio. - ServiceListFilter : PageRequest (Application/Services) leva Search, CategoryId e TagId; Create apara a busca e trata vazio como sem busca. - ListServicesQuery estende PagedQuery e declara só Search, CategoryId e TagId. - IServiceRepository.ListAsync(ServiceListFilter, ct) devolve PagedResult; o repositório separa filtro e ordem (FilterAndOrder) e ordena por Name e depois por Id, para um empate não repetir nem pular registro. O SQL de busca, categoria e tag não muda, e o ILike continua sem escape, como antes. - O handler monta o filtro e faz Map; continua lendo os nomes das categorias da página em uma só consulta. Perde a tupla e o PagedResult montado à mão. - O validator escreve as três regras com os códigos Paging.PageInvalid, Paging.PageSizeInvalid e Paging.PageTooLarge. Uma página enorme agora é 400, não 500 nem offset errado. - O helper antigo saiu do RepositoryBase: o ListPagedAsync passou a ser um só. - Testes: ServiceListFilterTests, validator por código, handler pelo filtro e ServiceListPersistenceTests (tenant, exclusão lógica, ordem e desempate, paginação sem repetir nem omitir, categoria, tag e os três juntos). - Ao vivo, 40 verificações da lista de serviços e a regressão de pessoas passam; services-api.d.ts regenerado (só a ordem dos parâmetros muda). - ADR 0064, ADR 0063, ARCHITECTURE §10 e API.md ajustados. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/api/generated/services-api.d.ts | 4 +- backend/docs/ARCHITECTURE.md | 2 +- .../Abstractions/IServiceRepository.cs | 11 +- .../ListServices/ListServicesQuery.cs | 4 +- .../ListServices/ListServicesQueryHandler.cs | 57 ++-- .../ListServicesQueryValidator.cs | 13 +- .../Services/ServiceListFilter.cs | 35 +++ .../Repositories/ServiceRepository.cs | 47 ++-- .../ServiceListPersistenceTests.cs | 254 ++++++++++++++++++ .../ListServicesQueryHandlerTests.cs | 132 ++++++--- .../ListServicesQueryValidatorTests.cs | 59 ++-- .../Services/ServiceListFilterTests.cs | 71 +++++ .../RepositoryBase.cs | 29 +- docs/API.md | 4 +- .../0063-clients-list-and-search-contract.md | 12 +- ...ged-reads-page-request-and-paged-result.md | 30 ++- 16 files changed, 607 insertions(+), 157 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs create mode 100644 backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 3b554094..900a1b6b 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -1037,11 +1037,11 @@ export interface paths { get: { parameters: { query?: { - Page?: number | string; - PageSize?: number | string; Search?: string; CategoryId?: string; TagId?: string; + Page?: number | string; + PageSize?: number | string; }; header?: never; path: { diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 30c8a269..f3fa6d20 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -468,7 +468,7 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a handler that normalizes the term, a repository that filters and orders over generated string columns | `ListServicesQueryHandler.cs` (page and size as `int`, a tuple back), `ListServicesQueryValidator.cs` without codes; `EF.Functions.ILike` with an unescaped pattern | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a filter whose `Create` normalizes the search term, a repository that filters and orders over generated string columns | `EF.Functions.ILike` with an unescaped pattern (`ServiceRepository`, `TagRepository`, `CategoryRepository`) | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | | Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IServiceRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IServiceRepository.cs index cb1552fc..6d2e6f9d 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IServiceRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IServiceRepository.cs @@ -1,14 +1,11 @@ +using Admin.SharedKernel; +using ServicesService.Application.Services; + namespace ServicesService.Application.Abstractions; public interface IServiceRepository { - Task<(IReadOnlyList Items, int TotalCount)> ListAsync( - int page, - int pageSize, - string? search, - Guid? categoryId, - Guid? tagId, - CancellationToken cancellationToken); + Task> ListAsync(ServiceListFilter filter, CancellationToken cancellationToken); Task GetByIdAsync(Guid serviceId, CancellationToken cancellationToken); diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs index 13a6647a..c8820e4a 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs @@ -3,8 +3,6 @@ namespace ServicesService.Application.Services.ListServices; public sealed record ListServicesQuery( - int Page = 1, - int PageSize = 20, string? Search = null, Guid? CategoryId = null, - Guid? TagId = null) : IQuery>; + Guid? TagId = null) : PagedQuery, IQuery>; diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs index 037b6e24..1ee303cd 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs @@ -18,36 +18,41 @@ public async Task>> Handle( ListServicesQuery query, CancellationToken cancellationToken) { - var (services, totalCount) = await _serviceRepository.ListAsync( - query.Page, - query.PageSize, - query.Search, - query.CategoryId, - query.TagId, - cancellationToken); - - // Only the categories this page's services actually reference (at most - // pageSize distinct ids), not the tenant's entire Category catalog on - // every page (docs/adr/0012). + var filter = ServiceListFilter.Create(query.Page, query.PageSize, query.Search, query.CategoryId, query.TagId); + + var services = await _serviceRepository.ListAsync(filter, cancellationToken); + + var categoryNamesById = await CategoryNamesAsync(services.Items, cancellationToken); + + return Result.Success(services.Map(service => ServiceResponse.FromService(service, CategoryNameOf(service, categoryNamesById)))); + } + + private async Task> CategoryNamesAsync( + IReadOnlyList services, + CancellationToken cancellationToken) + { var categoryIds = services .Select(service => service.CategoryId) - .Where(categoryId => categoryId is not null) - .Select(categoryId => categoryId!.Value) + .OfType() .Distinct() .ToList(); - var categoryNamesById = categoryIds.Count == 0 - ? new Dictionary() - : (await _categoryRepository.GetByIdsAsync(categoryIds, cancellationToken)) - .ToDictionary(category => category.Id, category => category.Name); - - var items = services - .Select(service => ServiceResponse.FromService( - service, - service.CategoryId is { } categoryId && categoryNamesById.TryGetValue(categoryId, out var name) - ? name - : null)) - .ToList(); + if (categoryIds.Count == 0) + { + return []; + } + + var categories = await _categoryRepository.GetByIdsAsync(categoryIds, cancellationToken); + + return categories.ToDictionary(category => category.Id, category => category.Name); + } + + private static string? CategoryNameOf(Service service, Dictionary categoryNamesById) + { + if (service.CategoryId is not { } categoryId) + { + return null; + } - return Result.Success(new PagedResult(items, totalCount, query.Page, query.PageSize)); + return categoryNamesById.GetValueOrDefault(categoryId); } } diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryValidator.cs index d0463619..b2ba6b96 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryValidator.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryValidator.cs @@ -1,3 +1,4 @@ +using Admin.SharedKernel; using FluentValidation; namespace ServicesService.Application.Services.ListServices; @@ -7,9 +8,17 @@ public sealed class ListServicesQueryValidator : AbstractValidator query.Page) - .GreaterThanOrEqualTo(1).WithMessage("A página deve ser maior ou igual a 1."); + .Cascade(CascadeMode.Stop) + .GreaterThanOrEqualTo(PageRequest.FirstPage) + .WithErrorCode(PageRequest.PageInvalidCode) + .WithMessage($"A página deve ser maior ou igual a {PageRequest.FirstPage}.") + .Must((query, page) => PageRequest.OffsetFits(page, query.PageSize)) + .WithErrorCode(PageRequest.PageTooLargeCode) + .WithMessage("A página informada está além do limite permitido."); RuleFor(query => query.PageSize) - .InclusiveBetween(1, 100).WithMessage("O tamanho da página deve ser entre 1 e 100."); + .InclusiveBetween(1, PageRequest.MaxPageSize) + .WithErrorCode(PageRequest.PageSizeInvalidCode) + .WithMessage($"O tamanho da página deve ser entre 1 e {PageRequest.MaxPageSize}."); } } diff --git a/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs new file mode 100644 index 00000000..7d368c8a --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs @@ -0,0 +1,35 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Services; + +public sealed record ServiceListFilter : PageRequest +{ + private ServiceListFilter(int page, int pageSize, string? search, Guid? categoryId, Guid? tagId) + : base(page, pageSize) + { + Search = search; + CategoryId = categoryId; + TagId = tagId; + } + + public string? Search { get; } + + public Guid? CategoryId { get; } + + public Guid? TagId { get; } + + public static ServiceListFilter Create(int page, int pageSize, string? search, Guid? categoryId, Guid? tagId) + { + return new ServiceListFilter(page, pageSize, SearchOf(search), categoryId, tagId); + } + + private static string? SearchOf(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return null; + } + + return search.Trim(); + } +} diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs index 5a9589b6..9317dac5 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs @@ -1,6 +1,8 @@ +using Admin.SharedKernel; using Admin.SharedKernel.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ServicesService.Application.Abstractions; +using ServicesService.Application.Services; using ServicesService.Infrastructure.Persistence; namespace ServicesService.Infrastructure.Repositories; @@ -12,23 +14,34 @@ public ServiceRepository(ServicesDataContext dbContext) { } - public Task<(IReadOnlyList Items, int TotalCount)> ListAsync( - int page, - int pageSize, - string? search, - Guid? categoryId, - Guid? tagId, - CancellationToken cancellationToken) => - ListPagedAsync( - query => query - .Include(s => s.Tags) - .Where(s => string.IsNullOrWhiteSpace(search) || EF.Functions.ILike(s.Name, $"%{search.Trim()}%")) - .Where(s => categoryId == null || s.CategoryId == categoryId) - .Where(s => tagId == null || s.Tags.Any(t => t.Id == tagId)) - .OrderBy(s => s.Name), - page, - pageSize, - cancellationToken); + public Task> ListAsync(ServiceListFilter filter, CancellationToken cancellationToken) + { + return ListPagedAsync(filter, query => FilterAndOrder(query, filter), cancellationToken); + } + + private static IQueryable FilterAndOrder(IQueryable query, ServiceListFilter filter) + { + query = query.Include(service => service.Tags); + + if (filter.Search is { } search) + { + query = query.Where(service => EF.Functions.ILike(service.Name, $"%{search}%")); + } + + if (filter.CategoryId is { } categoryId) + { + query = query.Where(service => service.CategoryId == categoryId); + } + + if (filter.TagId is { } tagId) + { + query = query.Where(service => service.Tags.Any(tag => tag.Id == tagId)); + } + + return query + .OrderBy(service => service.Name) + .ThenBy(service => service.Id); + } public Task GetByIdAsync(Guid serviceId, CancellationToken cancellationToken) => Set.Include(s => s.Tags).FirstOrDefaultAsync(s => s.Id == serviceId, cancellationToken); diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs new file mode 100644 index 00000000..bcd0a4f0 --- /dev/null +++ b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs @@ -0,0 +1,254 @@ +using Admin.SharedKernel; +using Microsoft.EntityFrameworkCore; +using ServicesService.Application.Services; +using ServicesService.Infrastructure.Persistence; +using ServicesService.Infrastructure.Repositories; + +namespace ServicesService.PersistenceTests; + +public class ServiceListPersistenceTests +{ + private static int _code; + + private static Service NewService(string name, Guid? categoryId = null) + { + return Service.Create( + Guid.NewGuid(), + name, + null, + DurationRange.Create(15, 30, 60).Value, + 45.50m, + 10m, + categoryId, + Interlocked.Increment(ref _code)).Value; + } + + private static Tag NewTag(string name) + { + return Tag.Create(Guid.NewGuid(), name, TagColor.Create("#0d9488").Value, null).Value; + } + + private static async Task Save(string databaseName, Guid tenantId, params Service[] services) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var serviceRepository = new ServiceRepository(context); + foreach (var service in services) + { + serviceRepository.Add(service); + } + + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + private static async Task SaveTagged( + string databaseName, + Guid tenantId, + IReadOnlyCollection tags, + params (Service Service, Tag[] Tags)[] entries) + { + await using (var context = PersistenceContext.Create(databaseName, tenantId)) + { + var tagRepository = new TagRepository(context); + foreach (var tag in tags) + { + tagRepository.Add(tag); + } + + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + foreach (var (service, serviceTags) in entries) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var ids = serviceTags.Select(tag => tag.Id).ToList(); + service.SetTags(await new TagRepository(context).GetByIdsAsync(ids, CancellationToken.None)); + new ServiceRepository(context).Add(service); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + } + + private static async Task SoftDelete(string databaseName, Guid tenantId, Guid serviceId) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var tracked = await context.Services.AsTracking().SingleAsync(s => s.Id == serviceId); + context.Services.Remove(tracked); + await context.SaveChangesAsync(); + } + + private static async Task> List( + string databaseName, + Guid tenantId, + int page = 1, + int pageSize = 20, + Guid? categoryId = null, + Guid? tagId = null) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + var filter = ServiceListFilter.Create(page, pageSize, null, categoryId, tagId); + return await new ServiceRepository(context).ListAsync(filter, CancellationToken.None); + } + + private static string[] Names(PagedResult page) + { + return page.Items.Select(service => service.Name).ToArray(); + } + + [Fact] + public async Task List_NeverReturnsNorCountsAnotherTenantsServices() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantA = Guid.NewGuid(); + var tenantB = Guid.NewGuid(); + await Save(databaseName, tenantA, NewService("Corte"), NewService("Barba")); + await Save(databaseName, tenantB, NewService("Massagem")); + + var asA = await List(databaseName, tenantA); + var asB = await List(databaseName, tenantB); + + Names(asA).Should().Equal("Barba", "Corte"); + asA.TotalCount.Should().Be(2); + Names(asB).Should().Equal("Massagem"); + asB.TotalCount.Should().Be(1); + } + + [Fact] + public async Task List_NeverReturnsNorCountsASoftDeletedService() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var kept = NewService("Corte"); + var deleted = NewService("Barba"); + await Save(databaseName, tenantId, kept, deleted); + await SoftDelete(databaseName, tenantId, deleted.Id); + + var page = await List(databaseName, tenantId); + + Names(page).Should().Equal("Corte"); + page.TotalCount.Should().Be(1); + } + + [Fact] + public async Task List_OrdersByNameAndBreaksATieById() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var twins = Enumerable.Range(0, 4).Select(_ => NewService("Corte")).ToArray(); + await Save(databaseName, tenantId, [.. twins, NewService("Barba"), NewService("Unha")]); + + var page = await List(databaseName, tenantId); + + Names(page).Should().Equal("Barba", "Corte", "Corte", "Corte", "Corte", "Unha"); + page.Items.Where(service => service.Name == "Corte").Select(service => service.Id) + .Should().Equal(twins.Select(service => service.Id).Order()); + } + + [Fact] + public async Task List_PagesWithoutRepeatingOrOmittingARecordAndEchoesThePage() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var services = Enumerable.Range(0, 7).Select(index => NewService($"Servico {index}")).ToArray(); + await Save(databaseName, tenantId, services); + + var first = await List(databaseName, tenantId, page: 1, pageSize: 3); + var second = await List(databaseName, tenantId, page: 2, pageSize: 3); + var third = await List(databaseName, tenantId, page: 3, pageSize: 3); + var past = await List(databaseName, tenantId, page: 4, pageSize: 3); + + first.Items.Should().HaveCount(3); + second.Items.Should().HaveCount(3); + third.Items.Should().HaveCount(1); + past.Items.Should().BeEmpty(); + new[] { first, second, third, past }.Select(page => page.TotalCount).Should().OnlyContain(total => total == 7); + new[] { first, second, third, past }.Select(page => (page.Page, page.PageSize)).Should().Equal((1, 3), (2, 3), (3, 3), (4, 3)); + var seen = first.Items.Concat(second.Items).Concat(third.Items).Select(service => service.Id).ToArray(); + seen.Should().OnlyHaveUniqueItems(); + seen.Should().BeEquivalentTo(services.Select(service => service.Id)); + } + + [Fact] + public async Task List_FiltersByCategory() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var hair = Guid.NewGuid(); + var nails = Guid.NewGuid(); + await Save( + databaseName, + tenantId, + NewService("Corte", hair), + NewService("Barba", hair), + NewService("Unha", nails), + NewService("Massagem")); + + var page = await List(databaseName, tenantId, categoryId: hair); + + Names(page).Should().Equal("Barba", "Corte"); + page.TotalCount.Should().Be(2); + } + + [Fact] + public async Task List_FiltersByTagAndLoadsTheTagsOfEachService() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var vip = NewTag("VIP"); + var promo = NewTag("Promo"); + await SaveTagged( + databaseName, + tenantId, + [vip, promo], + (NewService("Corte"), [vip, promo]), + (NewService("Barba"), [promo]), + (NewService("Unha"), [])); + + var byVip = await List(databaseName, tenantId, tagId: vip.Id); + var byPromo = await List(databaseName, tenantId, tagId: promo.Id); + + Names(byVip).Should().Equal("Corte"); + byVip.Items.Single().Tags.Select(tag => tag.Name).Order().Should().Equal("Promo", "VIP"); + Names(byPromo).Should().Equal("Barba", "Corte"); + byPromo.TotalCount.Should().Be(2); + } + + [Fact] + public async Task List_CombinesCategoryTagAndPaging() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var hair = Guid.NewGuid(); + var vip = NewTag("VIP"); + await SaveTagged( + databaseName, + tenantId, + [vip], + (NewService("A", hair), [vip]), + (NewService("B", hair), [vip]), + (NewService("C", hair), [vip]), + (NewService("D"), [vip])); + + var first = await List(databaseName, tenantId, page: 1, pageSize: 2, categoryId: hair, tagId: vip.Id); + var second = await List(databaseName, tenantId, page: 2, pageSize: 2, categoryId: hair, tagId: vip.Id); + + Names(first).Should().Equal("A", "B"); + Names(second).Should().Equal("C"); + second.TotalCount.Should().Be(3); + } + + [Fact] + public async Task List_WithNoTenantInContext_ReturnsNothing() + { + var databaseName = Guid.NewGuid().ToString(); + await Save(databaseName, Guid.NewGuid(), NewService("Corte")); + var tenantProvider = Substitute.For(); + tenantProvider.TryGetTenantId(out Arg.Any()).Returns(false); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(databaseName).Options; + await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); + + var page = await new ServiceRepository(tenantlessContext) + .ListAsync(ServiceListFilter.Create(1, 20, null, null, null), CancellationToken.None); + + page.Items.Should().BeEmpty(); + page.TotalCount.Should().Be(0); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs index 1327eac7..cbcffe60 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs @@ -1,4 +1,6 @@ +using Admin.SharedKernel; using ServicesService.Application.Abstractions; +using ServicesService.Application.Services; using ServicesService.Application.Services.ListServices; namespace ServicesService.Tests.Services.ListServices; @@ -12,17 +14,31 @@ private static ListServicesQueryHandler CreateHandler( categoryRepository = Substitute.For(); categoryRepository.GetByIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new List()); + Returns(serviceRepository, [], 0); return new ListServicesQueryHandler(serviceRepository, categoryRepository); } + private static void Returns(IServiceRepository serviceRepository, IReadOnlyList services, int totalCount) + { + serviceRepository + .ListAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var filter = callInfo.Arg(); + return Task.FromResult(new PagedResult(services, totalCount, filter.Page, filter.PageSize)); + }); + } + + private static Service NewService(string name, Guid? categoryId, int code) + { + return Service.Create(Guid.NewGuid(), name, null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, categoryId, code).Value; + } + [Fact] public async Task Handle_ReturnsServicesFromTheRepository() { var handler = CreateHandler(out var serviceRepository, out _); - var service = Service.Create(Guid.NewGuid(), "Haircut", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, null, 1).Value; - serviceRepository.ListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((new List { service }, 1)); + Returns(serviceRepository, [NewService("Haircut", null, 1)], 1); var result = await handler.Handle(new ListServicesQuery(), CancellationToken.None); @@ -34,10 +50,7 @@ public async Task Handle_ReturnsServicesFromTheRepository() [Fact] public async Task Handle_WithNoServices_ReturnsEmptyListAndDoesNotQueryCategories() { - var handler = CreateHandler(out var serviceRepository, out var categoryRepository); - serviceRepository.ListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((new List(), 0)); + var handler = CreateHandler(out _, out var categoryRepository); var result = await handler.Handle(new ListServicesQuery(), CancellationToken.None); @@ -53,10 +66,7 @@ public async Task Handle_WithACategorizedService_ResolvesTheCategoryName() { var handler = CreateHandler(out var serviceRepository, out var categoryRepository); var category = Category.Create(Guid.NewGuid(), "Hair").Value!; - var service = Service.Create(Guid.NewGuid(), "Haircut", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, category.Id, 1).Value; - serviceRepository.ListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((new List { service }, 1)); + Returns(serviceRepository, [NewService("Haircut", category.Id, 1)], 1); categoryRepository.GetByIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new[] { category }); @@ -65,27 +75,35 @@ public async Task Handle_WithACategorizedService_ResolvesTheCategoryName() result.Value.Items.Should().ContainSingle().Which.CategoryName.Should().Be("Hair"); } + [Fact] + public async Task Handle_WithAServiceWithoutACategory_ReturnsNoCategoryName() + { + var handler = CreateHandler(out var serviceRepository, out _); + Returns(serviceRepository, [NewService("Haircut", null, 1)], 1); + + var result = await handler.Handle(new ListServicesQuery(), CancellationToken.None); + + result.Value.Items.Should().ContainSingle().Which.CategoryName.Should().BeNull(); + } + [Fact] public async Task Handle_OnlyQueriesTheDistinctCategoriesReferencedByThisPage_NotTheWholeCatalog() { var handler = CreateHandler(out var serviceRepository, out var categoryRepository); var category = Category.Create(Guid.NewGuid(), "Hair").Value!; - var services = new List - { - Service.Create(Guid.NewGuid(), "Haircut", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, category.Id, 1).Value, - Service.Create(Guid.NewGuid(), "Trim", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, category.Id, 2).Value, - Service.Create(Guid.NewGuid(), "Manicure", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, null, 3).Value, - }; - serviceRepository.ListAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((services, 3)); + Returns( + serviceRepository, + [ + NewService("Haircut", category.Id, 1), + NewService("Trim", category.Id, 2), + NewService("Manicure", null, 3), + ], + 3); categoryRepository.GetByIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new[] { category }); await handler.Handle(new ListServicesQuery(), CancellationToken.None); - // Exactly one call, with only the single distinct category id actually - // referenced by this page - not every category the tenant owns. await categoryRepository.Received(1).GetByIdsAsync( Arg.Is>( ids => ids != null && ids.Count == 1 && ids.Contains(category.Id)), @@ -96,15 +114,9 @@ await categoryRepository.Received(1).GetByIdsAsync( public async Task Handle_WithPageSizeSmallerThanTotal_ReturnsRequestedPageAndTotalCount() { var handler = CreateHandler(out var serviceRepository, out _); - var services = new List - { - Service.Create(Guid.NewGuid(), "Haircut", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, null, 1).Value, - Service.Create(Guid.NewGuid(), "Manicure", null, DurationRange.Create(15, 30, 60).Value, 45.50m, 10m, null, 2).Value, - }; - serviceRepository.ListAsync(1, 2, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((services, 3)); + Returns(serviceRepository, [NewService("Haircut", null, 1), NewService("Manicure", null, 2)], 3); - var result = await handler.Handle(new ListServicesQuery(1, 2), CancellationToken.None); + var result = await handler.Handle(new ListServicesQuery { Page = 1, PageSize = 2 }, CancellationToken.None); result.IsSuccess.Should().BeTrue(); result.Value.Items.Should().HaveCount(2); @@ -114,19 +126,67 @@ public async Task Handle_WithPageSizeSmallerThanTotal_ReturnsRequestedPageAndTot } [Fact] - public async Task Handle_PassesSearchAndFilterIdsToTheRepository() + public async Task Handle_WithAPagePastTheLast_ReturnsAnEmptyPageAndTheTotal() + { + var handler = CreateHandler(out var serviceRepository, out _); + Returns(serviceRepository, [], 3); + + var result = await handler.Handle(new ListServicesQuery { Page = 9, PageSize = 20 }, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().BeEmpty(); + result.Value.TotalCount.Should().Be(3); + result.Value.Page.Should().Be(9); + } + + [Fact] + public async Task Handle_PassesThePageAndTheFiltersToTheRepositoryAsOneFilter() { var handler = CreateHandler(out var serviceRepository, out _); var categoryId = Guid.NewGuid(); var tagId = Guid.NewGuid(); - serviceRepository.ListAsync(1, 20, "cut", categoryId, tagId, Arg.Any()) - .Returns((new List(), 0)); var result = await handler.Handle( - new ListServicesQuery(Search: "cut", CategoryId: categoryId, TagId: tagId), + new ListServicesQuery(Search: " cut ", CategoryId: categoryId, TagId: tagId) { Page = 2, PageSize = 5 }, CancellationToken.None); result.IsSuccess.Should().BeTrue(); - await serviceRepository.Received(1).ListAsync(1, 20, "cut", categoryId, tagId, Arg.Any()); + await serviceRepository.Received(1).ListAsync( + Arg.Is(filter => + filter.Page == 2 + && filter.PageSize == 5 + && filter.Search == "cut" + && filter.CategoryId == categoryId + && filter.TagId == tagId), + Arg.Any()); + } + + [Fact] + public async Task Handle_WithoutFilters_AsksForTheFirstPageWithNoFilter() + { + var handler = CreateHandler(out var serviceRepository, out _); + + await handler.Handle(new ListServicesQuery(), CancellationToken.None); + + await serviceRepository.Received(1).ListAsync( + Arg.Is(filter => + filter.Page == 1 + && filter.PageSize == 20 + && filter.Search == null + && filter.CategoryId == null + && filter.TagId == null), + Arg.Any()); + } + + [Fact] + public async Task Handle_OnlyReads() + { + var handler = CreateHandler(out var serviceRepository, out _); + + await handler.Handle(new ListServicesQuery(), CancellationToken.None); + + await serviceRepository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + serviceRepository.DidNotReceive().Add(Arg.Any()); + serviceRepository.DidNotReceive().Remove(Arg.Any()); } } diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryValidatorTests.cs index fe328756..70adeb4f 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryValidatorTests.cs @@ -6,42 +6,67 @@ public class ListServicesQueryValidatorTests { private readonly ListServicesQueryValidator _validator = new(); - [Fact] - public void Validate_WithDefaultQuery_Passes() + public static TheoryData EveryRule() { - var result = _validator.Validate(new ListServicesQuery()); + return new TheoryData + { + { "page below one", new ListServicesQuery { Page = 0 }, "Page", "Paging.PageInvalid" }, + { "negative page", new ListServicesQuery { Page = -3 }, "Page", "Paging.PageInvalid" }, + { "page whose offset overflows", new ListServicesQuery { Page = int.MaxValue }, "Page", "Paging.PageTooLarge" }, + { "page just past the offset limit", new ListServicesQuery { Page = 21_474_838, PageSize = 100 }, "Page", "Paging.PageTooLarge" }, + { "page size below one", new ListServicesQuery { PageSize = 0 }, "PageSize", "Paging.PageSizeInvalid" }, + { "page size above the maximum", new ListServicesQuery { PageSize = 101 }, "PageSize", "Paging.PageSizeInvalid" }, + }; + } - result.IsValid.Should().BeTrue(); + [Theory] + [MemberData(nameof(EveryRule))] + public void Validate_WithAnInvalidRequest_RejectsItWithTheRulesCodeOnItsProperty( + string caseName, + ListServicesQuery query, + string property, + string code) + { + var result = _validator.Validate(query); + + var error = result.Errors.Should().ContainSingle(caseName).Subject; + error.PropertyName.Should().Be(property); + error.ErrorCode.Should().Be(code); } [Fact] - public void Validate_WithPageLessThanOne_Fails() + public void Validate_WithDefaultQuery_Passes() { - var result = _validator.Validate(new ListServicesQuery(Page: 0)); + var result = _validator.Validate(new ListServicesQuery()); - result.IsValid.Should().BeFalse(); + result.IsValid.Should().BeTrue(); } [Fact] - public void Validate_WithPageSizeBelowOne_Fails() + public void Validate_WithEveryLimitAtItsBoundary_Passes() { - var result = _validator.Validate(new ListServicesQuery(PageSize: 0)); - - result.IsValid.Should().BeFalse(); + _validator.Validate(new ListServicesQuery { Page = 1, PageSize = 1 }).IsValid.Should().BeTrue(); + _validator.Validate(new ListServicesQuery { PageSize = 100 }).IsValid.Should().BeTrue(); + _validator.Validate(new ListServicesQuery { Page = 21_474_837, PageSize = 100 }).IsValid.Should().BeTrue(); } [Fact] - public void Validate_WithPageSizeAboveOneHundred_Fails() + public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() { - var result = _validator.Validate(new ListServicesQuery(PageSize: 101)); + var result = _validator.Validate(new ListServicesQuery { Page = 0, PageSize = 100 }); - result.IsValid.Should().BeFalse(); + result.Errors.Select(error => error.ErrorCode).Should().Equal("Paging.PageInvalid"); } [Fact] - public void Validate_WithPageSizeAtBounds_Passes() + public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() { - _validator.Validate(new ListServicesQuery(PageSize: 1)).IsValid.Should().BeTrue(); - _validator.Validate(new ListServicesQuery(PageSize: 100)).IsValid.Should().BeTrue(); + var result = _validator.Validate(new ListServicesQuery { Page = 0, PageSize = 0 }); + + result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( + [ + "A página deve ser maior ou igual a 1.", + "O tamanho da página deve ser entre 1 e 100.", + ]); } } diff --git a/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs new file mode 100644 index 00000000..edd62bd5 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs @@ -0,0 +1,71 @@ +using Admin.SharedKernel; +using ServicesService.Application.Services; + +namespace ServicesService.Tests.Services; + +public class ServiceListFilterTests +{ + [Fact] + public void Create_KeepsThePageAndExposesItsOffset() + { + var filter = ServiceListFilter.Create(3, 50, null, null, null); + + filter.Page.Should().Be(3); + filter.PageSize.Should().Be(50); + filter.Offset.Should().Be(100); + filter.Should().BeAssignableTo(); + } + + [Theory] + [InlineData(0, 20)] + [InlineData(1, 0)] + [InlineData(21_474_838, 100)] + public void Create_WithAPageNoRepositoryCouldServe_Throws(int page, int pageSize) + { + var act = () => ServiceListFilter.Create(page, pageSize, null, null, null); + + act.Should().Throw(); + } + + [Theory] + [InlineData(" cut ", "cut")] + [InlineData("Hair Cut", "Hair Cut")] + [InlineData("100%", "100%")] + public void Create_TrimsTheSearchAndKeepsItsCase(string raw, string expected) + { + ServiceListFilter.Create(1, 20, raw, null, null).Search.Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_WithABlankSearch_IsNoSearch(string? raw) + { + ServiceListFilter.Create(1, 20, raw, null, null).Search.Should().BeNull(); + } + + [Fact] + public void Create_KeepsTheCategoryAndTheTag() + { + var categoryId = Guid.NewGuid(); + var tagId = Guid.NewGuid(); + + var filter = ServiceListFilter.Create(1, 20, null, categoryId, tagId); + + filter.CategoryId.Should().Be(categoryId); + filter.TagId.Should().Be(tagId); + } + + [Fact] + public void TwoFiltersWithTheSameValues_AreEqual() + { + var categoryId = Guid.NewGuid(); + var filter = ServiceListFilter.Create(2, 20, "cut", categoryId, null); + + filter.Should().Be(ServiceListFilter.Create(2, 20, " cut ", categoryId, null)); + filter.Should().NotBe(ServiceListFilter.Create(3, 20, "cut", categoryId, null)); + filter.Should().NotBe(ServiceListFilter.Create(2, 20, "cuts", categoryId, null)); + filter.Should().NotBe(ServiceListFilter.Create(2, 20, "cut", null, null)); + } +} diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs index 924a9cf7..6b0fe0ca 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs @@ -55,37 +55,12 @@ protected async Task> ListPagedAsync( { var query = filterAndOrder(Set.AsNoTracking()); - var (items, totalCount) = await ReadPageAsync(query, page.Offset, page.PageSize, cancellationToken); + var totalCount = await query.CountAsync(cancellationToken); + var items = await query.Skip(page.Offset).Take(page.PageSize).ToListAsync(cancellationToken); return new PagedResult(items, totalCount, page.Page, page.PageSize); } - protected async Task<(IReadOnlyList Items, int TotalCount)> ListPagedAsync( - Func, IQueryable>? order, - int page, - int pageSize, - CancellationToken cancellationToken) - { - IQueryable query = Set.AsNoTracking(); - if (order is not null) - { - query = order(query); - } - - return await ReadPageAsync(query, (page - 1) * pageSize, pageSize, cancellationToken); - } - - private static async Task<(IReadOnlyList Items, int TotalCount)> ReadPageAsync( - IQueryable query, - int skip, - int take, - CancellationToken cancellationToken) - { - var totalCount = await query.CountAsync(cancellationToken); - var items = await query.Skip(skip).Take(take).ToListAsync(cancellationToken); - return (items, totalCount); - } - protected Task AnyAsync(Expression> predicate, CancellationToken cancellationToken) => Set.AnyAsync(predicate, cancellationToken); diff --git a/docs/API.md b/docs/API.md index 232fdb92..597e8e3a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -267,8 +267,8 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Recurso em uso (regra cruzando entidades) | `DELETE /api/v1/tags/{id}` (tag usada por um `service`) | 409 | `Tag.InUse` | | Categoria não encontrada | `GET /api/v1/categories/{id}` (id não existe) | 404 | `Category.NotFound` | | Id vazio ≠ id inexistente | `DELETE /api/v1/tags/00000000-0000-0000-0000-000000000000` | 400 (não 404!) | `Validation.Failed` (`tagId`: `NotEmptyValidator`) | -| Paginação fora do intervalo | `GET /api/v1/services?page=0` | 400 | `Validation.Failed` (`page`: `GreaterThanOrEqualValidator`) | -| Paginação fora do intervalo | `GET /api/v1/services?pageSize=1000` | 400 | `Validation.Failed` (`pageSize`: `InclusiveBetweenValidator`) | +| Paginação fora do intervalo | `GET /api/v1/services?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`) | +| Paginação fora do intervalo | `GET /api/v1/services?pageSize=1000` | 400 | `Validation.Failed` (`pageSize`: `Paging.PageSizeInvalid`) | | CPF já cadastrado (pessoa ativa ou inativa) | `POST /api/v1/clients` | 409 | `Client.DuplicateCpf` (`errors.cpf[0].meta.clientId`) | | E-mail de pessoa ativa repetido (qualquer caixa) | `POST /api/v1/clients` | 409 | `Client.DuplicateEmail` (`errors.email[0].meta.clientId`) | | Menor sem responsável | `POST /api/v1/clients` (`birthDate` de menor, `guardians: []`) | 400 | `Validation.Failed` (`guardians`: `Client.GuardianRequired`) | diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 96ab5298..564e2ae1 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -105,10 +105,10 @@ client load now reads three more short strings, because EF loads every mapped co binding (gate 1), as `400 Validation.Failed` under `search` with its own pt-BR message. The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). -`Paging.PageTooLarge` exists because `RepositoryBase.ListPagedAsync(order, page, pageSize)` computes the offset in `int`: the older list -`GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error`, and `page=43000000` silently -computes an offset of 5,032,604 instead of 4,299,999,900, so a large enough table would answer a wrong page instead of -an empty one. A framework binding failure (`page=abc`) +`Paging.PageTooLarge` exists because the offset `(page - 1) * pageSize` has to fit an `int`: before the services list was +converted ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)), `GET /api/v1/services?page=2147483647&pageSize=100` +answered `500 Unexpected.Error`, and `page=43000000` silently computed an offset of 5,032,604 instead of 4,299,999,900, +so a large enough table would have answered a wrong page instead of an empty one. A framework binding failure (`page=abc`) stays gate 1: `400 Validation.Failed` with the framework's English text. **A page past the last** answers `200` with empty `items`, the real `totalCount` and the requested `page`. @@ -166,5 +166,5 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, in a validator rule instead of in a type; changed after review (ADR 0065). - **`EF.Functions.ILike` with an escape character.** It cannot reach a value-object column. - **A fixed cap on the page** instead of the offset rule. Any number is arbitrary. -- **Fixing the offset of the older `RepositoryBase.ListPagedAsync(order, page, pageSize)` in this change.** It changes - the services list; converting it to `PageRequest` is a change of its own (ADR 0064). +- **Fixing the offset of the older `RepositoryBase.ListPagedAsync(order, page, pageSize)` on its own.** The conversion of + the services list to `PageRequest` removed that helper (ADR 0064). diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index 22a1b5a7..7c360567 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -9,8 +9,8 @@ Two lists are paged: services and, with #155, clients. Each one repeated the sam - the port took `int page, int pageSize` and returned a tuple `(Items, TotalCount)`; - `RepositoryBase.ListPagedAsync(order, page, pageSize)` computed the offset in `int`, so the older list - `GET /api/v1/services?page=2147483647&pageSize=100` answers `500 Unexpected.Error` and `page=43000000` silently - computes an offset of 5,032,604 instead of 4,299,999,900; + `GET /api/v1/services?page=2147483647&pageSize=100` answered `500 Unexpected.Error` and `page=43000000` silently + computed an offset of 5,032,604 instead of 4,299,999,900; - the handler rebuilt the `PagedResult` envelope by hand from the tuple and the query; - each validator wrote the limits as literals (`1`, `100`) and, for services, with the validator library's internal codes. @@ -64,10 +64,14 @@ and a paging value is set with an initializer (`new ListClientsQuery { Page = 2 the validator ran. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other is a page a repository can serve. The query is never the filter, and the filter is never bound from the wire. -**Only clients adopt it for now.** `ServiceRepository` and `ListServicesQueryHandler` keep the older -`ListPagedAsync(order, page, pageSize)`, which stays beside the new overload and shares its private slice-reading -step, so services behave exactly as before, including the two wrong answers above. Converting services means a -`PageRequest` in its port, a `Map` in its handler and the three rules with the shared codes in its validator. +**Services follows.** The services list was converted after review, in the same pull request, the way clients was: +`ServiceListFilter : PageRequest` (`Search`, `CategoryId`, `TagId`), `ListServicesQuery : PagedQuery`, the port +`ListAsync(ServiceListFilter, ct)`, a `Map` in the handler (which still reads the names of the page's categories in one +query) and the three `Paging.*` rules in its validator. The older `ListPagedAsync(order, page, pageSize)` was removed, +because nothing else called it. `GET /api/v1/services?page=2147483647&pageSize=100` now answers `400 Paging.PageTooLarge` +instead of `500`, and `page=43000000` answers the same `400` instead of a wrong offset. The services order also ends in +the id, so a tie cannot repeat or skip a record. The `Search` of services stays a plain `string?` and its `ILike` pattern +stays unescaped (`%` and `_` are wildcards in it), as before: neither belongs to this change. ## Consequences @@ -76,7 +80,9 @@ step, so services behave exactly as before, including the two wrong answers abov through its Application project. - Clients' error codes for the page rules are `Paging.*`, not `Client.Page*`. The frontend does not branch on the services codes, so nothing there changes; the codes only existed on this unmerged branch for clients. -- Two ways to page exist in `RepositoryBase` until services is converted. The older one is the only unguarded one. +- The `GET /services` contract changes in two ways: its page errors carry the `Paging.*` codes instead of the validator + library's internal names (the frontend does not branch on them), and a huge page is a `400` instead of a `500` or a + wrong page. `RepositoryBase` has one paged read. - A repository test cannot live in `Admin.SharedKernel.Tests` (it has no EF). The paged helper is proven through the clients persistence tests (items, total, echoed page and size, a page past the last, no repeat or omission). - The frontend's generated types keep the same query parameters; `Page` and `PageSize` come after the list's own @@ -86,8 +92,10 @@ step, so services behave exactly as before, including the two wrong answers abov Verified on the same disposable PostgreSQL 18.3 as [ADR 0063](0063-clients-list-and-search-contract.md), on the final code: the 90 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; -eleven checks that the services list did not change (five services created, paging with no repeat or omission, search, -a page past the last, `page=0` still `400`, and the `500` of `page=2147483647` still there); the volume run again with +forty checks of the converted services list (paging with no repeat or omission and the order of the database, a +case-insensitive and trimmed search, the category and tag filters alone and together with paging, a page past the last, +every `Paging.*` code, `page=2147483647` and `page=43000000` as `400`, no leak of another tenant or of a deleted +service, nothing written by a listing, and an edit followed by the list); the volume run of the clients list with 50,000 people (30 to 45 ms at the median, 133 ms at page 2000); and the generated frontend types equal to a fresh generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.SharedKernel.Tests`. @@ -118,5 +126,5 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared - **Clamping to an empty page in the helper, and dropping `Paging.PageTooLarge`.** Safe by construction for every future list; the owner preferred the explicit `400`. - **Codes per feature** (`Client.PageInvalid`, `Service.PageInvalid`). The same rule would have a code per list. -- **Converting services in the same change.** It touches an older slice and the `GET /services` contract inside the - #155 change; it is a change of its own. +- **Converting services in a change of its own** (the first plan). It touches an older slice and the `GET /services` + contract; the owner asked for it in the same pull request, after the clients list was reviewed. From 534c46fe2941f7622286e1102a257a4834527cd3 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 12:41:01 -0300 Subject: [PATCH 11/24] =?UTF-8?q?refactor(services):=20a=20query=20de=20li?= =?UTF-8?q?sta=20vira=20o=20pr=C3=B3prio=20filtro=20com=20ToFilter()=20(#1?= =?UTF-8?q?55,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O handler de cada lista montava o filtro com `ClientListFilter.Create(query.Page, query.PageSize, query.Search, status)` e carregava o mapeamento do enum do fio (ClientStatusFilter) para o ClientStatus? do domínio. O ARCHITECTURE §2 já põe esse tipo de conversão num método de extensão do tipo de entrada (ToModel, ToData), para o handler continuar sendo orquestração. - ListClientsQueryExtensions.ToFilter() e ListServicesQueryExtensions.ToFilter() convertem a query no seu filtro; o mapeamento do status sai do handler de pessoas e fica na extensão. - ListClientsQueryHandler fica com duas linhas: `query.ToFilter()` na porta e `Map` na resposta. O de serviços perde só a montagem do filtro e continua resolvendo os nomes das categorias da página. - ListClientsQueryExtensionsTests recebe o mapeamento do status que estava nos testes do handler; ListServicesQueryExtensionsTests cobre a conversão de serviços. Os testes do handler continuam provando que a porta recebe o filtro certo. - ADR 0064, ARCHITECTURE §2 e §6 e use-case.md ajustados. Refatoração pura: o repositório e o SQL não mudam, então a verificação ao vivo não foi repetida; o que chega à porta é o mesmo filtro, e os testes do handler e das extensões o comparam por igualdade. Co-Authored-By: Claude Sonnet 5.5 --- .../references/use-case.md | 4 +- backend/docs/ARCHITECTURE.md | 8 +-- .../ListClients/ListClientsQueryExtensions.cs | 24 ++++++++ .../ListClients/ListClientsQueryHandler.cs | 21 +------ .../ListServicesQueryExtensions.cs | 9 +++ .../ListServices/ListServicesQueryHandler.cs | 4 +- .../ListClientsQueryExtensionsTests.cs | 60 +++++++++++++++++++ .../ListClientsQueryHandlerTests.cs | 19 ------ .../ListServicesQueryExtensionsTests.cs | 34 +++++++++++ ...ged-reads-page-request-and-paged-result.md | 11 ++-- 10 files changed, 142 insertions(+), 52 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs create mode 100644 backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 248a3b4c..b3d56737 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -17,8 +17,8 @@ messages", plus the earlier slices for reads, edits and paging. This file is how the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. - A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as - optional members; the handler builds the `ListFilter` (a `PageRequest` with the list's filters) and the - validator writes the three `Paging.*` rules (ADR 0064). + optional members; its `ToFilter()` extension builds the `ListFilter` (a `PageRequest` with the list's + filters), and the validator writes the three `Paging.*` rules (ADR 0064). ## 2. The validator — simple and clean diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index f3fa6d20..e354eb7f 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -109,7 +109,8 @@ Vertical slices organise the Application layer; they do not replace the layers Command.cs | Query.cs a record; nested input records live in the same file CommandHandler.cs | QueryHandler.cs CommandValidator.cs | QueryValidator.cs whenever the request carries input - CommandExtensions.cs extension methods ToModel(…) / ApplyTo(entity) / ToData(): command → domain input + CommandExtensions.cs extension methods ToModel(…) / ApplyTo(entity) / ToData(): command → domain input; + QueryExtensions.cs: ToFilter(), the list query → its ListFilter .Infrastructure/ Persistence/Configurations/Configuration.cs Persistence/Migrations/ @@ -394,9 +395,8 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult - The query of a paged list extends `PagedQuery` (`Page` and `PageSize`, with the `PageRequest` defaults, unguarded) and its validator writes the three rules (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared - `Paging.*` codes; the handler builds the list's filter (`ClientListFilter`, a `PageRequest` with the plain filters), the - port takes it and returns `PagedResult`, and the - handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` + `Paging.*` codes; the query's `ToFilter()` extension builds the list's filter (`ClientListFilter`, a `PageRequest` with the + plain filters), the port takes it and returns `PagedResult`, and the handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). A change to an endpoint's contract regenerates the frontend's types in the same change — skill diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs new file mode 100644 index 00000000..327ca4d8 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs @@ -0,0 +1,24 @@ +namespace ServicesService.Application.Clients.ListClients; + +public static class ListClientsQueryExtensions +{ + public static ClientListFilter ToFilter(this ListClientsQuery query) + { + return ClientListFilter.Create(query.Page, query.PageSize, query.Search, ToClientStatus(query.Status)); + } + + private static ClientStatus? ToClientStatus(ClientStatusFilter? status) + { + if (status == ClientStatusFilter.All) + { + return null; + } + + if (status == ClientStatusFilter.Inactive) + { + return ClientStatus.Inactive; + } + + return ClientStatus.Active; + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index d041ef1b..195167c2 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -10,27 +10,8 @@ public async Task>> Handle( ListClientsQuery query, CancellationToken cancellationToken) { - var status = ToStatusFilter(query.Status); - - var filter = ClientListFilter.Create(query.Page, query.PageSize, query.Search, status); - - var clients = await clientRepository.ListAsync(filter, cancellationToken); + var clients = await clientRepository.ListAsync(query.ToFilter(), cancellationToken); return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } - - private static ClientStatus? ToStatusFilter(ClientStatusFilter? status) - { - if (status == ClientStatusFilter.All) - { - return null; - } - - if (status == ClientStatusFilter.Inactive) - { - return ClientStatus.Inactive; - } - - return ClientStatus.Active; - } } diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs new file mode 100644 index 00000000..03424af2 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs @@ -0,0 +1,9 @@ +namespace ServicesService.Application.Services.ListServices; + +public static class ListServicesQueryExtensions +{ + public static ServiceListFilter ToFilter(this ListServicesQuery query) + { + return ServiceListFilter.Create(query.Page, query.PageSize, query.Search, query.CategoryId, query.TagId); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs index 1ee303cd..680ade0e 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryHandler.cs @@ -18,9 +18,7 @@ public async Task>> Handle( ListServicesQuery query, CancellationToken cancellationToken) { - var filter = ServiceListFilter.Create(query.Page, query.PageSize, query.Search, query.CategoryId, query.TagId); - - var services = await _serviceRepository.ListAsync(filter, cancellationToken); + var services = await _serviceRepository.ListAsync(query.ToFilter(), cancellationToken); var categoryNamesById = await CategoryNamesAsync(services.Items, cancellationToken); diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs new file mode 100644 index 00000000..5b837c62 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs @@ -0,0 +1,60 @@ +using ServicesService.Application.Clients; +using ServicesService.Application.Clients.ListClients; + +namespace ServicesService.Tests.Clients.ListClients; + +public class ListClientsQueryExtensionsTests +{ + private static SearchText SearchTextOf(string raw) + { + return SearchText.Create(raw).Value; + } + + [Theory] + [InlineData(ClientStatusFilter.Active, ClientStatus.Active)] + [InlineData(ClientStatusFilter.Inactive, ClientStatus.Inactive)] + [InlineData(null, ClientStatus.Active)] + public void ToFilter_MapsTheStatusFilterToTheClientStatus(ClientStatusFilter? status, ClientStatus expected) + { + new ListClientsQuery(Status: status).ToFilter().Status.Should().Be(expected); + } + + [Fact] + public void ToFilter_WithTheAllFilter_HasNoStatusRestriction() + { + new ListClientsQuery(Status: ClientStatusFilter.All).ToFilter().Status.Should().BeNull(); + } + + [Fact] + public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageOfTheActiveOnesWithoutSearch() + { + var filter = new ListClientsQuery().ToFilter(); + + filter.Should().Be(ClientListFilter.Create(1, 20, null, ClientStatus.Active)); + } + + [Fact] + public void ToFilter_CarriesThePageTheSizeAndTheSearchOfTheQuery() + { + var query = new ListClientsQuery(Search: SearchTextOf(" Maria SILVA "), Status: ClientStatusFilter.Inactive) + { + Page = 3, + PageSize = 50, + }; + + var filter = query.ToFilter(); + + filter.Should().Be(ClientListFilter.Create(3, 50, SearchTextOf("maria silva"), ClientStatus.Inactive)); + filter.Page.Should().Be(3); + filter.PageSize.Should().Be(50); + filter.Text.Should().Be("maria silva"); + } + + [Fact] + public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() + { + var act = () => new ListClientsQuery { Page = 0 }.ToFilter(); + + act.Should().Throw(); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index a75625f3..9c96fd5f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -158,25 +158,6 @@ public async Task Handle_WithoutFilters_AsksForTheActiveOnesWithoutSearch() await AssertListed(null, null, ClientStatus.Active); } - [Theory] - [InlineData(ClientStatusFilter.Active, ClientStatus.Active)] - [InlineData(ClientStatusFilter.Inactive, ClientStatus.Inactive)] - [InlineData(null, ClientStatus.Active)] - public async Task Handle_MapsTheStatusFilterToTheClientStatus(ClientStatusFilter? status, ClientStatus expected) - { - await _handler.Handle(new ListClientsQuery(Status: status), CancellationToken.None); - - await AssertListed(null, null, expected); - } - - [Fact] - public async Task Handle_WithTheAllFilter_AsksForNoStatusRestriction() - { - await _handler.Handle(new ListClientsQuery(Status: ClientStatusFilter.All), CancellationToken.None); - - await AssertListed(null, null, null); - } - [Theory] [InlineData(" Maria SILVA ", "maria silva")] [InlineData("MARIA@Example.COM", "maria@example.com")] diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs new file mode 100644 index 00000000..a2642949 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs @@ -0,0 +1,34 @@ +using ServicesService.Application.Services; +using ServicesService.Application.Services.ListServices; + +namespace ServicesService.Tests.Services.ListServices; + +public class ListServicesQueryExtensionsTests +{ + [Fact] + public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageWithNoFilter() + { + new ListServicesQuery().ToFilter().Should().Be(ServiceListFilter.Create(1, 20, null, null, null)); + } + + [Fact] + public void ToFilter_CarriesThePageTheSizeAndEveryFilterOfTheQuery() + { + var categoryId = Guid.NewGuid(); + var tagId = Guid.NewGuid(); + var query = new ListServicesQuery(Search: " cut ", CategoryId: categoryId, TagId: tagId) { Page = 2, PageSize = 5 }; + + var filter = query.ToFilter(); + + filter.Should().Be(ServiceListFilter.Create(2, 5, "cut", categoryId, tagId)); + filter.Search.Should().Be("cut"); + } + + [Fact] + public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() + { + var act = () => new ListServicesQuery { PageSize = 0 }.ToFilter(); + + act.Should().Throw(); + } +} diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index 7c360567..946d4d7f 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -32,8 +32,7 @@ real total, and the page and size it was asked for. The filter-and-order functio explicit order is a bug. **`PagedResult.Map(...)`** converts the items and keeps the total, the page and the size, so a handler is -`repository.ListAsync(ListFilter.Create(query.Page, query.PageSize, ), ct)` followed by -`.Map(ListItemResponse.From)`. +`repository.ListAsync(query.ToFilter(), ct)` followed by `.Map(ListItemResponse.From)`. **The filter of a list extends it.** A paged list has one record, `ClientListFilter : PageRequest`, that carries the page and the plain filters of that list (`Text`, `CpfDigits`, `Status`): one object from the handler to the @@ -60,8 +59,10 @@ holds, and the error says so. `PageSize` as `init` properties that default to the `PageRequest` constants, and no guard: it holds the raw input, and the validator is the gate. `ListClientsQuery` extends it and declares only its own members (`Search`, `Status`), so the wire stays flat (`?page=&pageSize=&search=&status=`), a new list does not redeclare the two members or their defaults, -and a paging value is set with an initializer (`new ListClientsQuery { Page = 2 }`). The handler builds the filter after -the validator ran. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other +and a paging value is set with an initializer (`new ListClientsQuery { Page = 2 }`). The query turns itself into its filter +with a `ToFilter()` extension method, the mapping pattern of [ARCHITECTURE](../../backend/docs/ARCHITECTURE.md) §2 +(`ToModel`, `ToData()`), so the handler stays orchestration and the mapping of the wire enum `ClientStatusFilter` +to the domain's `ClientStatus?` lives with the query. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other is a page a repository can serve. The query is never the filter, and the filter is never bound from the wire. **Services follows.** The services list was converted after review, in the same pull request, the way clients was: @@ -113,6 +114,8 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared and `Status`. The filter, built after the validator, can extend it. - **The filter holding a `PageRequest`** instead of extending it. Equally simple; one flat object was preferred, and `ListPagedAsync(PageRequest, …)` accepts the filter as it is. +- **The handler building the filter** (the version before the extension): `ClientListFilter.Create(query.Page, + query.PageSize, query.Search, status)` and a private status mapping in every list handler. Changed after review. - **The first version of this change**: `ListAsync(PageRequest page, string? text, string? cpfDigits, ClientStatus? status, ct)`. It grows by one parameter per criterion, and the text and the digits were one concept in two arguments. Changed after review. From b5f15acae20f08df6091c0cabe89608b0a4bf274 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 12:49:16 -0300 Subject: [PATCH 12/24] refactor(services): tornar os filtros das listas DTOs simples e normalizar na query (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientListFilter carregava a regra de normalização da busca: TextOf (aparar, minúsculas, busca vazia) e ExtractCpfDigits (dígitos do CPF sem máscara), dentro de um Create estático. Essa regra conhece o formato do fio, então pertence à query que se converte no filtro, e o filtro fica só com dados. - ClientListFilter vira um record posicional simples que estende PageRequest: (Page, PageSize, Text, CpfDigits, Status). A guarda da página continua vindo do construtor de PageRequest. - TextOf e CpfDigitsOf (antes ExtractCpfDigits) passam para ListClientsQueryExtensions, ao lado do mapeamento do status; ToFilter() monta o filtro. - ServiceListFilter segue o mesmo caminho: record posicional, e o trim da busca vai para ListServicesQueryExtensions. - A matriz de normalização (texto, dígitos, máscara, só dígitos ASCII, busca vazia) sai de ClientListFilterTests e vai para ListClientsQueryExtensionsTests; o trim de serviços vai para ListServicesQueryExtensionsTests. Os testes dos filtros ficam com a página, a guarda, os valores guardados como foram dados e a igualdade. - Os testes de persistência de pessoas e de serviços passam a montar o filtro pela própria query (ToFilter()), então provam a conversão real até o repositório. - ADR 0064, ADR 0063 e ARCHITECTURE §10 ajustados. Refatoração pura: as regras só mudaram de lugar, o repositório e o SQL não mudam, e a verificação ao vivo não foi repetida. Co-Authored-By: Claude Sonnet 5.5 --- backend/docs/ARCHITECTURE.md | 2 +- .../Clients/ClientListFilter.cs | 55 +-------- .../ListClients/ListClientsQueryExtensions.cs | 30 ++++- .../ListServicesQueryExtensions.cs | 12 +- .../Services/ServiceListFilter.cs | 37 +----- .../ClientListPersistenceTests.cs | 27 ++++- .../ServiceListPersistenceTests.cs | 7 +- .../Clients/ClientListFilterTests.cs | 105 +++--------------- .../ListClientsQueryExtensionsTests.cs | 82 +++++++++++++- .../ListServicesQueryExtensionsTests.cs | 22 +++- .../Services/ServiceListFilterTests.cs | 41 ++----- .../0063-clients-list-and-search-contract.md | 4 +- ...ged-reads-page-request-and-paged-result.md | 8 +- 13 files changed, 217 insertions(+), 215 deletions(-) diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index e354eb7f..972d0b4e 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -468,7 +468,7 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a filter whose `Create` normalizes the search term, a repository that filters and orders over generated string columns | `EF.Functions.ILike` with an unescaped pattern (`ServiceRepository`, `TagRepository`, `CategoryRepository`) | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a query whose `ToFilter()` normalizes the search term, a repository that filters and orders over generated string columns | `EF.Functions.ILike` with an unescaped pattern (`ServiceRepository`, `TagRepository`, `CategoryRepository`) | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | | Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs index ab3c4d26..f6075194 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs @@ -2,52 +2,9 @@ namespace ServicesService.Application.Clients; -public sealed record ClientListFilter : PageRequest -{ - private ClientListFilter(int page, int pageSize, string? text, string? cpfDigits, ClientStatus? status) - : base(page, pageSize) - { - Text = text; - CpfDigits = cpfDigits; - Status = status; - } - - public string? Text { get; } - - public string? CpfDigits { get; } - - public ClientStatus? Status { get; } - - public static ClientListFilter Create(int page, int pageSize, SearchText? search, ClientStatus? status) - { - var text = TextOf(search); - - return new ClientListFilter(page, pageSize, text, ExtractCpfDigits(text), status); - } - - private static string? TextOf(SearchText? search) - { - if (search is null || search.IsEmpty) - { - return null; - } - - return search.Value; - } - - private static string? ExtractCpfDigits(string? text) - { - if (text is null) - { - return null; - } - - var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); - if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) - { - return null; - } - - return digits; - } -} +public sealed record ClientListFilter( + int Page, + int PageSize, + string? Text, + string? CpfDigits, + ClientStatus? Status) : PageRequest(Page, PageSize); diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs index 327ca4d8..8484f0a7 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs @@ -4,7 +4,35 @@ public static class ListClientsQueryExtensions { public static ClientListFilter ToFilter(this ListClientsQuery query) { - return ClientListFilter.Create(query.Page, query.PageSize, query.Search, ToClientStatus(query.Status)); + var text = TextOf(query.Search); + + return new ClientListFilter(query.Page, query.PageSize, text, CpfDigitsOf(text), ToClientStatus(query.Status)); + } + + private static string? TextOf(SearchText? search) + { + if (search is null || search.IsEmpty) + { + return null; + } + + return search.Value; + } + + private static string? CpfDigitsOf(string? text) + { + if (text is null) + { + return null; + } + + var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return digits; } private static ClientStatus? ToClientStatus(ClientStatusFilter? status) diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs index 03424af2..926e90f0 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs @@ -4,6 +4,16 @@ public static class ListServicesQueryExtensions { public static ServiceListFilter ToFilter(this ListServicesQuery query) { - return ServiceListFilter.Create(query.Page, query.PageSize, query.Search, query.CategoryId, query.TagId); + return new ServiceListFilter(query.Page, query.PageSize, SearchOf(query.Search), query.CategoryId, query.TagId); + } + + private static string? SearchOf(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return null; + } + + return search.Trim(); } } diff --git a/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs index 7d368c8a..b5b85d74 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs @@ -2,34 +2,9 @@ namespace ServicesService.Application.Services; -public sealed record ServiceListFilter : PageRequest -{ - private ServiceListFilter(int page, int pageSize, string? search, Guid? categoryId, Guid? tagId) - : base(page, pageSize) - { - Search = search; - CategoryId = categoryId; - TagId = tagId; - } - - public string? Search { get; } - - public Guid? CategoryId { get; } - - public Guid? TagId { get; } - - public static ServiceListFilter Create(int page, int pageSize, string? search, Guid? categoryId, Guid? tagId) - { - return new ServiceListFilter(page, pageSize, SearchOf(search), categoryId, tagId); - } - - private static string? SearchOf(string? search) - { - if (string.IsNullOrWhiteSpace(search)) - { - return null; - } - - return search.Trim(); - } -} +public sealed record ServiceListFilter( + int Page, + int PageSize, + string? Search, + Guid? CategoryId, + Guid? TagId) : PageRequest(Page, PageSize); diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs index 1af2012c..bc888c97 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients; +using ServicesService.Application.Clients.ListClients; using ServicesService.Infrastructure.Persistence; using ServicesService.Infrastructure.Repositories; @@ -76,9 +77,27 @@ private static async Task> List( ClientStatus? status = null) { await using var context = PersistenceContext.Create(databaseName, tenantId); - var searchText = search is null ? null : SearchText.Create(search).Value; - var filter = ClientListFilter.Create(page, pageSize, searchText, status); - return await new ClientRepository(context).ListAsync(filter, CancellationToken.None); + var query = new ListClientsQuery(search is null ? null : SearchText.Create(search).Value, ToStatusFilter(status)) + { + Page = page, + PageSize = pageSize, + }; + return await new ClientRepository(context).ListAsync(query.ToFilter(), CancellationToken.None); + } + + private static ClientStatusFilter ToStatusFilter(ClientStatus? status) + { + if (status is null) + { + return ClientStatusFilter.All; + } + + if (status == ClientStatus.Inactive) + { + return ClientStatusFilter.Inactive; + } + + return ClientStatusFilter.Active; } private static string[] Names(PagedResult page) @@ -134,7 +153,7 @@ public async Task List_WithNoTenantInContext_ReturnsNothing() await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); var page = await new ClientRepository(tenantlessContext) - .ListAsync(ClientListFilter.Create(1, 20, null, null), CancellationToken.None); + .ListAsync(new ClientListFilter(1, 20, null, null, null), CancellationToken.None); page.Items.Should().BeEmpty(); page.TotalCount.Should().Be(0); diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs index bcd0a4f0..f95b6c46 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs @@ -1,6 +1,7 @@ using Admin.SharedKernel; using Microsoft.EntityFrameworkCore; using ServicesService.Application.Services; +using ServicesService.Application.Services.ListServices; using ServicesService.Infrastructure.Persistence; using ServicesService.Infrastructure.Repositories; @@ -84,8 +85,8 @@ private static async Task> List( Guid? tagId = null) { await using var context = PersistenceContext.Create(databaseName, tenantId); - var filter = ServiceListFilter.Create(page, pageSize, null, categoryId, tagId); - return await new ServiceRepository(context).ListAsync(filter, CancellationToken.None); + var query = new ListServicesQuery(CategoryId: categoryId, TagId: tagId) { Page = page, PageSize = pageSize }; + return await new ServiceRepository(context).ListAsync(query.ToFilter(), CancellationToken.None); } private static string[] Names(PagedResult page) @@ -246,7 +247,7 @@ public async Task List_WithNoTenantInContext_ReturnsNothing() await using var tenantlessContext = new ServicesDataContext(options, tenantProvider); var page = await new ServiceRepository(tenantlessContext) - .ListAsync(ServiceListFilter.Create(1, 20, null, null, null), CancellationToken.None); + .ListAsync(new ServiceListFilter(1, 20, null, null, null), CancellationToken.None); page.Items.Should().BeEmpty(); page.TotalCount.Should().Be(0); diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs index 5e355484..a949729f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs @@ -5,15 +5,10 @@ namespace ServicesService.Tests.Clients; public class ClientListFilterTests { - private static SearchText SearchTextOf(string raw) - { - return SearchText.Create(raw).Value; - } - [Fact] - public void Create_KeepsThePageAndExposesItsOffset() + public void ThePageIsKeptAndItsOffsetIsExposed() { - var filter = ClientListFilter.Create(3, 50, null, null); + var filter = new ClientListFilter(3, 50, null, null, null); filter.Page.Should().Be(3); filter.PageSize.Should().Be(50); @@ -25,102 +20,38 @@ public void Create_KeepsThePageAndExposesItsOffset() [InlineData(0, 20)] [InlineData(1, 0)] [InlineData(21_474_838, 100)] - public void Create_WithAPageNoRepositoryCouldServe_Throws(int page, int pageSize) + public void APageNoRepositoryCouldServe_Throws(int page, int pageSize) { - var act = () => ClientListFilter.Create(page, pageSize, null, null); + var act = () => new ClientListFilter(page, pageSize, null, null, null); act.Should().Throw(); } [Fact] - public void Create_WithoutSearch_HasNoTextAndNoCpf() - { - var filter = ClientListFilter.Create(1, 20, null, null); - - filter.Text.Should().BeNull(); - filter.CpfDigits.Should().BeNull(); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Create_WithAnEmptySearchText_IsNoSearch(string raw) - { - var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); - - filter.Text.Should().BeNull(); - filter.CpfDigits.Should().BeNull(); - } - - [Theory] - [InlineData(" Maria SILVA ", "maria silva")] - [InlineData("MARIA@Example.COM", "maria@example.com")] - [InlineData("josé", "josé")] - [InlineData("50%_off", "50%_off")] - public void Create_KeepsTheNormalizedTextForTheNameAndTheEmail(string raw, string expected) - { - ClientListFilter.Create(1, 20, SearchTextOf(raw), null).Text.Should().Be(expected); - } - - [Theory] - [InlineData("529.982.247-25", "52998224725")] - [InlineData("52998224725", "52998224725")] - [InlineData(" 529 982 247 25 ", "52998224725")] - [InlineData("123", "123")] - [InlineData("456.789", "456789")] - [InlineData("0", "0")] - public void Create_WithDigitsAndCpfMask_AlsoCarriesTheCpfWithoutTheMask(string raw, string expectedDigits) - { - var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); - - filter.CpfDigits.Should().Be(expectedDigits); - filter.Text.Should().Be(raw.Trim()); - } - - [Theory] - [InlineData("maria")] - [InlineData("maria.silva@example.com")] - [InlineData("ana-lima")] - [InlineData("a1")] - [InlineData("1a")] - [InlineData("529.982.247-2x")] - [InlineData("12/34")] - [InlineData("12,5")] - [InlineData(".")] - [InlineData("-")] - [InlineData("...---")] - public void Create_WithAnythingButDigitsAndTheMask_DoesNotCarryTheCpf(string raw) + public void TheTextTheCpfDigitsAndTheStatus_AreKeptExactlyAsGiven() { - var filter = ClientListFilter.Create(1, 20, SearchTextOf(raw), null); + var filter = new ClientListFilter(1, 20, " Maria ", "123", ClientStatus.Inactive); - filter.CpfDigits.Should().BeNull(); - filter.Text.Should().Be(raw); + filter.Text.Should().Be(" Maria "); + filter.CpfDigits.Should().Be("123"); + filter.Status.Should().Be(ClientStatus.Inactive); } [Fact] - public void Create_OnlyAcceptsAsciiDigitsForTheCpf() + public void TwoFiltersWithTheSameValues_AreEqual() { - ClientListFilter.Create(1, 20, SearchTextOf("١٢٣"), null).CpfDigits.Should().BeNull(); - ClientListFilter.Create(1, 20, SearchTextOf("123"), null).CpfDigits.Should().BeNull(); - } + var filter = new ClientListFilter(2, 20, "maria", null, ClientStatus.Active); - [Theory] - [InlineData(null)] - [InlineData(ClientStatus.Active)] - [InlineData(ClientStatus.Inactive)] - public void Create_KeepsTheStatusItWasGiven(ClientStatus? status) - { - ClientListFilter.Create(1, 20, null, status).Status.Should().Be(status); + filter.Should().Be(new ClientListFilter(2, 20, "maria", null, ClientStatus.Active)); + filter.Should().NotBe(new ClientListFilter(3, 20, "maria", null, ClientStatus.Active)); + filter.Should().NotBe(new ClientListFilter(2, 20, "maria", null, ClientStatus.Inactive)); + filter.Should().NotBe(new ClientListFilter(2, 20, "mario", null, ClientStatus.Active)); + filter.Should().NotBe(new ClientListFilter(2, 20, "maria", "123", ClientStatus.Active)); } [Fact] - public void TwoFiltersWithTheSameValues_AreEqual() + public void AFilterIsNeverEqualToAPlainRequestOfTheSamePage() { - var filter = ClientListFilter.Create(2, 20, SearchTextOf("Maria"), ClientStatus.Active); - - filter.Should().Be(ClientListFilter.Create(2, 20, SearchTextOf(" MARIA "), ClientStatus.Active)); - filter.Should().NotBe(ClientListFilter.Create(3, 20, SearchTextOf("Maria"), ClientStatus.Active)); - filter.Should().NotBe(ClientListFilter.Create(2, 20, SearchTextOf("Maria"), ClientStatus.Inactive)); - filter.Should().NotBe(ClientListFilter.Create(2, 20, SearchTextOf("Mario"), ClientStatus.Active)); + new ClientListFilter(2, 20, null, null, null).Should().NotBe(new PageRequest(2, 20)); } } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs index 5b837c62..60991db2 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs @@ -10,6 +10,11 @@ private static SearchText SearchTextOf(string raw) return SearchText.Create(raw).Value; } + private static ClientListFilter FilterOf(string raw) + { + return new ListClientsQuery(Search: SearchTextOf(raw)).ToFilter(); + } + [Theory] [InlineData(ClientStatusFilter.Active, ClientStatus.Active)] [InlineData(ClientStatusFilter.Inactive, ClientStatus.Inactive)] @@ -30,7 +35,7 @@ public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageOfTheActiveOnesWithout { var filter = new ListClientsQuery().ToFilter(); - filter.Should().Be(ClientListFilter.Create(1, 20, null, ClientStatus.Active)); + filter.Should().Be(new ClientListFilter(1, 20, null, null, ClientStatus.Active)); } [Fact] @@ -44,10 +49,9 @@ public void ToFilter_CarriesThePageTheSizeAndTheSearchOfTheQuery() var filter = query.ToFilter(); - filter.Should().Be(ClientListFilter.Create(3, 50, SearchTextOf("maria silva"), ClientStatus.Inactive)); + filter.Should().Be(new ClientListFilter(3, 50, "maria silva", null, ClientStatus.Inactive)); filter.Page.Should().Be(3); filter.PageSize.Should().Be(50); - filter.Text.Should().Be("maria silva"); } [Fact] @@ -57,4 +61,76 @@ public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() act.Should().Throw(); } + + [Fact] + public void ToFilter_WithoutSearch_HasNoTextAndNoCpf() + { + var filter = new ListClientsQuery().ToFilter(); + + filter.Text.Should().BeNull(); + filter.CpfDigits.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ToFilter_WithAnEmptySearchText_IsNoSearch(string raw) + { + var filter = FilterOf(raw); + + filter.Text.Should().BeNull(); + filter.CpfDigits.Should().BeNull(); + } + + [Theory] + [InlineData(" Maria SILVA ", "maria silva")] + [InlineData("MARIA@Example.COM", "maria@example.com")] + [InlineData("josé", "josé")] + [InlineData("50%_off", "50%_off")] + public void ToFilter_KeepsTheNormalizedTextForTheNameAndTheEmail(string raw, string expected) + { + FilterOf(raw).Text.Should().Be(expected); + } + + [Theory] + [InlineData("529.982.247-25", "52998224725")] + [InlineData("52998224725", "52998224725")] + [InlineData(" 529 982 247 25 ", "52998224725")] + [InlineData("123", "123")] + [InlineData("456.789", "456789")] + [InlineData("0", "0")] + public void ToFilter_WithDigitsAndCpfMask_AlsoCarriesTheCpfWithoutTheMask(string raw, string expectedDigits) + { + var filter = FilterOf(raw); + + filter.CpfDigits.Should().Be(expectedDigits); + filter.Text.Should().Be(raw.Trim()); + } + + [Theory] + [InlineData("maria")] + [InlineData("maria.silva@example.com")] + [InlineData("ana-lima")] + [InlineData("a1")] + [InlineData("1a")] + [InlineData("529.982.247-2x")] + [InlineData("12/34")] + [InlineData("12,5")] + [InlineData(".")] + [InlineData("-")] + [InlineData("...---")] + public void ToFilter_WithAnythingButDigitsAndTheMask_DoesNotCarryTheCpf(string raw) + { + var filter = FilterOf(raw); + + filter.CpfDigits.Should().BeNull(); + filter.Text.Should().Be(raw); + } + + [Fact] + public void ToFilter_OnlyAcceptsAsciiDigitsForTheCpf() + { + FilterOf("١٢٣").CpfDigits.Should().BeNull(); + FilterOf("123").CpfDigits.Should().BeNull(); + } } diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs index a2642949..a138715f 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs @@ -8,7 +8,7 @@ public class ListServicesQueryExtensionsTests [Fact] public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageWithNoFilter() { - new ListServicesQuery().ToFilter().Should().Be(ServiceListFilter.Create(1, 20, null, null, null)); + new ListServicesQuery().ToFilter().Should().Be(new ServiceListFilter(1, 20, null, null, null)); } [Fact] @@ -20,10 +20,28 @@ public void ToFilter_CarriesThePageTheSizeAndEveryFilterOfTheQuery() var filter = query.ToFilter(); - filter.Should().Be(ServiceListFilter.Create(2, 5, "cut", categoryId, tagId)); + filter.Should().Be(new ServiceListFilter(2, 5, "cut", categoryId, tagId)); filter.Search.Should().Be("cut"); } + [Theory] + [InlineData(" cut ", "cut")] + [InlineData("Hair Cut", "Hair Cut")] + [InlineData("100%", "100%")] + public void ToFilter_TrimsTheSearchAndKeepsItsCase(string raw, string expected) + { + new ListServicesQuery(Search: raw).ToFilter().Search.Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ToFilter_WithABlankSearch_IsNoSearch(string? raw) + { + new ListServicesQuery(Search: raw).ToFilter().Search.Should().BeNull(); + } + [Fact] public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() { diff --git a/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs index edd62bd5..70c90a22 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs @@ -6,9 +6,9 @@ namespace ServicesService.Tests.Services; public class ServiceListFilterTests { [Fact] - public void Create_KeepsThePageAndExposesItsOffset() + public void ThePageIsKeptAndItsOffsetIsExposed() { - var filter = ServiceListFilter.Create(3, 50, null, null, null); + var filter = new ServiceListFilter(3, 50, null, null, null); filter.Page.Should().Be(3); filter.PageSize.Should().Be(50); @@ -20,39 +20,22 @@ public void Create_KeepsThePageAndExposesItsOffset() [InlineData(0, 20)] [InlineData(1, 0)] [InlineData(21_474_838, 100)] - public void Create_WithAPageNoRepositoryCouldServe_Throws(int page, int pageSize) + public void APageNoRepositoryCouldServe_Throws(int page, int pageSize) { - var act = () => ServiceListFilter.Create(page, pageSize, null, null, null); + var act = () => new ServiceListFilter(page, pageSize, null, null, null); act.Should().Throw(); } - [Theory] - [InlineData(" cut ", "cut")] - [InlineData("Hair Cut", "Hair Cut")] - [InlineData("100%", "100%")] - public void Create_TrimsTheSearchAndKeepsItsCase(string raw, string expected) - { - ServiceListFilter.Create(1, 20, raw, null, null).Search.Should().Be(expected); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Create_WithABlankSearch_IsNoSearch(string? raw) - { - ServiceListFilter.Create(1, 20, raw, null, null).Search.Should().BeNull(); - } - [Fact] - public void Create_KeepsTheCategoryAndTheTag() + public void TheSearchTheCategoryAndTheTag_AreKeptExactlyAsGiven() { var categoryId = Guid.NewGuid(); var tagId = Guid.NewGuid(); - var filter = ServiceListFilter.Create(1, 20, null, categoryId, tagId); + var filter = new ServiceListFilter(1, 20, " Cut ", categoryId, tagId); + filter.Search.Should().Be(" Cut "); filter.CategoryId.Should().Be(categoryId); filter.TagId.Should().Be(tagId); } @@ -61,11 +44,11 @@ public void Create_KeepsTheCategoryAndTheTag() public void TwoFiltersWithTheSameValues_AreEqual() { var categoryId = Guid.NewGuid(); - var filter = ServiceListFilter.Create(2, 20, "cut", categoryId, null); + var filter = new ServiceListFilter(2, 20, "cut", categoryId, null); - filter.Should().Be(ServiceListFilter.Create(2, 20, " cut ", categoryId, null)); - filter.Should().NotBe(ServiceListFilter.Create(3, 20, "cut", categoryId, null)); - filter.Should().NotBe(ServiceListFilter.Create(2, 20, "cuts", categoryId, null)); - filter.Should().NotBe(ServiceListFilter.Create(2, 20, "cut", null, null)); + filter.Should().Be(new ServiceListFilter(2, 20, "cut", categoryId, null)); + filter.Should().NotBe(new ServiceListFilter(3, 20, "cut", categoryId, null)); + filter.Should().NotBe(new ServiceListFilter(2, 20, "cuts", categoryId, null)); + filter.Should().NotBe(new ServiceListFilter(2, 20, "cut", null, null)); } } diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 564e2ae1..c4408e2f 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -66,8 +66,8 @@ it as "contains": that person; `982` finds the CPFs that contain 982 and, as any term, the names and e-mails that contain it. The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is -found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): its -`Create` derives the two plain values (`Text`, from the `SearchText` of `query.Search`, and `CpfDigits`), and the +found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): the +query's `ToFilter()` derives the two plain values (`Text`, from the `SearchText` of `query.Search`, and `CpfDigits`), and the repository port takes the filter. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index 946d4d7f..ad000c14 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -36,8 +36,9 @@ explicit order is a bug. **The filter of a list extends it.** A paged list has one record, `ClientListFilter : PageRequest`, that carries the page and the plain filters of that list (`Text`, `CpfDigits`, `Status`): one object from the handler to the -repository, where a new criterion is one more property and not one more parameter. Its `Create` normalizes what comes -from the query (the search text, the digits of the CPF) and the base constructor keeps the guard. The port is +repository, where a new criterion is one more property and not one more parameter. It is a plain positional record, a +DTO with no rule of its own: the query's `ToFilter()` normalizes what comes from the wire (the search text, the digits +of the CPF, the status) and the base constructor keeps the guard. The port is `ListAsync(ClientListFilter filter, ct)` returning `PagedResult`, and the adapter hands the filter to `ListPagedAsync(filter, filterAndOrder, ct)`, which reads only the page from it. The filter holds plain values: no expression, no specification, nothing generic (see below). Only the filter of a list extends `PageRequest`; a query or @@ -114,6 +115,9 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared and `Status`. The filter, built after the validator, can extend it. - **The filter holding a `PageRequest`** instead of extending it. Equally simple; one flat object was preferred, and `ListPagedAsync(PageRequest, …)` accepts the filter as it is. +- **A filter that normalizes in its own `Create`** (the version before this one): a private constructor and a static + `Create(page, pageSize, SearchText?, ClientStatus?)` holding the trim, the lowercase and the extraction of the CPF + digits. The rules belong to the query that knows the wire's shape, and the filter stays data. Changed after review. - **The handler building the filter** (the version before the extension): `ClientListFilter.Create(query.Page, query.PageSize, query.Search, status)` and a private status mapping in every list handler. Changed after review. - **The first version of this change**: `ListAsync(PageRequest page, string? text, string? cpfDigits, ClientStatus? From bf384d0e5615e6213b1fd7a737b0e47e1ecdd554 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 12:54:45 -0300 Subject: [PATCH 13/24] =?UTF-8?q?refactor(services):=20ToFilter()=20passa?= =?UTF-8?q?=20a=20ser=20m=C3=A9todo=20da=20pr=C3=B3pria=20query=20de=20lis?= =?UTF-8?q?ta,=20sem=20arquivo=20de=20extens=C3=A3o=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListClientsQueryExtensions e ListServicesQueryExtensions existiam só para hospedar ToFilter() e os helpers de normalização. O padrão de extensão da ADR 0007 vale para um comando que vira objeto de domínio; aqui a query vira um record da Application, sem chamar nada do domínio, e a extensão só a separava dos dados que ela lê. - ToFilter() e seus helpers privados (TextOf, CpfDigitsOf, ToClientStatus; SearchOf em serviços) passam para dentro de ListClientsQuery e ListServicesQuery. - Os dois arquivos de extensão saem. O handler continua chamando query.ToFilter(). - Os testes mudam de nome, de ListClientsQueryExtensionsTests e ListServicesQueryExtensionsTests para ListClientsQueryTests e ListServicesQueryTests, e não mudam de conteúdo. - ADR 0064 (a extensão vira "versão anterior, mudada após revisão"), ARCHITECTURE §2 e §6 e use-case.md ajustados. Movimento puro, mas o record é o tipo ligado pela query string e publicado no OpenAPI, então a verificação ao vivo foi repetida: 94 verificações de pessoas, 10 de edição e 40 de serviços passam, e o services-api.d.ts regenerado não tem diferença. Co-Authored-By: Claude Sonnet 5.5 --- .../references/use-case.md | 2 +- backend/docs/ARCHITECTURE.md | 7 ++- .../Clients/ListClients/ListClientsQuery.cs | 51 +++++++++++++++++- .../ListClients/ListClientsQueryExtensions.cs | 52 ------------------- .../ListServices/ListServicesQuery.cs | 18 ++++++- .../ListServicesQueryExtensions.cs | 19 ------- ...sionsTests.cs => ListClientsQueryTests.cs} | 2 +- ...ionsTests.cs => ListServicesQueryTests.cs} | 2 +- ...ged-reads-page-request-and-paged-result.md | 11 ++-- 9 files changed, 81 insertions(+), 83 deletions(-) delete mode 100644 backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs delete mode 100644 backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs rename backend/services/services-service/ServicesService.Tests/Clients/ListClients/{ListClientsQueryExtensionsTests.cs => ListClientsQueryTests.cs} (98%) rename backend/services/services-service/ServicesService.Tests/Services/ListServices/{ListServicesQueryExtensionsTests.cs => ListServicesQueryTests.cs} (97%) diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index b3d56737..42fb4d21 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -17,7 +17,7 @@ messages", plus the earlier slices for reads, edits and paging. This file is how the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. - A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as - optional members; its `ToFilter()` extension builds the `ListFilter` (a `PageRequest` with the list's + optional members; its own `ToFilter()` method builds the `ListFilter` (a `PageRequest` with the list's filters), and the validator writes the three `Paging.*` rules (ADR 0064). ## 2. The validator — simple and clean diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 972d0b4e..4e3eddb9 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -109,8 +109,7 @@ Vertical slices organise the Application layer; they do not replace the layers Command.cs | Query.cs a record; nested input records live in the same file CommandHandler.cs | QueryHandler.cs CommandValidator.cs | QueryValidator.cs whenever the request carries input - CommandExtensions.cs extension methods ToModel(…) / ApplyTo(entity) / ToData(): command → domain input; - QueryExtensions.cs: ToFilter(), the list query → its ListFilter + CommandExtensions.cs extension methods ToModel(…) / ApplyTo(entity) / ToData(): command → domain input .Infrastructure/ Persistence/Configurations/Configuration.cs Persistence/Migrations/ @@ -395,8 +394,8 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult - The query of a paged list extends `PagedQuery` (`Page` and `PageSize`, with the `PageRequest` defaults, unguarded) and its validator writes the three rules (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared - `Paging.*` codes; the query's `ToFilter()` extension builds the list's filter (`ClientListFilter`, a `PageRequest` with the - plain filters), the port takes it and returns `PagedResult`, and the handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` + `Paging.*` codes; the query's own `ToFilter()` method builds the list's filter (`ClientListFilter`, a `PageRequest` with the + plain filters; it maps into an Application record, not into the domain, so there is no extension file as for a command), the port takes it and returns `PagedResult`, and the handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). A change to an endpoint's contract regenerates the frontend's types in the same change — skill diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index dc0b30a3..8f329419 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -4,4 +4,53 @@ namespace ServicesService.Application.Clients.ListClients; public sealed record ListClientsQuery( SearchText? Search = null, - ClientStatusFilter? Status = ClientStatusFilter.Active) : PagedQuery, IQuery>; + ClientStatusFilter? Status = ClientStatusFilter.Active) : PagedQuery, IQuery> +{ + public ClientListFilter ToFilter() + { + var text = TextOf(Search); + + return new ClientListFilter(Page, PageSize, text, CpfDigitsOf(text), ToClientStatus(Status)); + } + + private static string? TextOf(SearchText? search) + { + if (search is null || search.IsEmpty) + { + return null; + } + + return search.Value; + } + + private static string? CpfDigitsOf(string? text) + { + if (text is null) + { + return null; + } + + var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return digits; + } + + private static ClientStatus? ToClientStatus(ClientStatusFilter? status) + { + if (status == ClientStatusFilter.All) + { + return null; + } + + if (status == ClientStatusFilter.Inactive) + { + return ClientStatus.Inactive; + } + + return ClientStatus.Active; + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs deleted file mode 100644 index 8484f0a7..00000000 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace ServicesService.Application.Clients.ListClients; - -public static class ListClientsQueryExtensions -{ - public static ClientListFilter ToFilter(this ListClientsQuery query) - { - var text = TextOf(query.Search); - - return new ClientListFilter(query.Page, query.PageSize, text, CpfDigitsOf(text), ToClientStatus(query.Status)); - } - - private static string? TextOf(SearchText? search) - { - if (search is null || search.IsEmpty) - { - return null; - } - - return search.Value; - } - - private static string? CpfDigitsOf(string? text) - { - if (text is null) - { - return null; - } - - var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); - if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) - { - return null; - } - - return digits; - } - - private static ClientStatus? ToClientStatus(ClientStatusFilter? status) - { - if (status == ClientStatusFilter.All) - { - return null; - } - - if (status == ClientStatusFilter.Inactive) - { - return ClientStatus.Inactive; - } - - return ClientStatus.Active; - } -} diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs index c8820e4a..807d6005 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs @@ -5,4 +5,20 @@ namespace ServicesService.Application.Services.ListServices; public sealed record ListServicesQuery( string? Search = null, Guid? CategoryId = null, - Guid? TagId = null) : PagedQuery, IQuery>; + Guid? TagId = null) : PagedQuery, IQuery> +{ + public ServiceListFilter ToFilter() + { + return new ServiceListFilter(Page, PageSize, SearchOf(Search), CategoryId, TagId); + } + + private static string? SearchOf(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + { + return null; + } + + return search.Trim(); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs deleted file mode 100644 index 926e90f0..00000000 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQueryExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ServicesService.Application.Services.ListServices; - -public static class ListServicesQueryExtensions -{ - public static ServiceListFilter ToFilter(this ListServicesQuery query) - { - return new ServiceListFilter(query.Page, query.PageSize, SearchOf(query.Search), query.CategoryId, query.TagId); - } - - private static string? SearchOf(string? search) - { - if (string.IsNullOrWhiteSpace(search)) - { - return null; - } - - return search.Trim(); - } -} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs similarity index 98% rename from backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs rename to backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs index 60991db2..fa93366f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryExtensionsTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs @@ -3,7 +3,7 @@ namespace ServicesService.Tests.Clients.ListClients; -public class ListClientsQueryExtensionsTests +public class ListClientsQueryTests { private static SearchText SearchTextOf(string raw) { diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs similarity index 97% rename from backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs rename to backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs index a138715f..6e1579cd 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryExtensionsTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs @@ -3,7 +3,7 @@ namespace ServicesService.Tests.Services.ListServices; -public class ListServicesQueryExtensionsTests +public class ListServicesQueryTests { [Fact] public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageWithNoFilter() diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index ad000c14..56cb501c 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -61,9 +61,11 @@ holds, and the error says so. the validator is the gate. `ListClientsQuery` extends it and declares only its own members (`Search`, `Status`), so the wire stays flat (`?page=&pageSize=&search=&status=`), a new list does not redeclare the two members or their defaults, and a paging value is set with an initializer (`new ListClientsQuery { Page = 2 }`). The query turns itself into its filter -with a `ToFilter()` extension method, the mapping pattern of [ARCHITECTURE](../../backend/docs/ARCHITECTURE.md) §2 -(`ToModel`, `ToData()`), so the handler stays orchestration and the mapping of the wire enum `ClientStatusFilter` -to the domain's `ClientStatus?` lives with the query. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other +with a `ToFilter()` method of the record, so the handler stays orchestration and the normalization and the mapping of the +wire enum `ClientStatusFilter` to the domain's `ClientStatus?` live with the query that knows the wire's shape. A command +maps into the domain through an extension method ([ADR 0007](0007-direct-command-binding-and-mapping-extensions.md)); a +list query maps into an Application record, with no domain call, so it carries the method itself and there is no +extension file. `PagedQuery` and `PageRequest` are two types on purpose: one is input that may be wrong, the other is a page a repository can serve. The query is never the filter, and the filter is never bound from the wire. **Services follows.** The services list was converted after review, in the same pull request, the way clients was: @@ -115,6 +117,9 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared and `Status`. The filter, built after the validator, can extend it. - **The filter holding a `PageRequest`** instead of extending it. Equally simple; one flat object was preferred, and `ListPagedAsync(PageRequest, …)` accepts the filter as it is. +- **`ToFilter()` as an extension method in a file of its own** (the version before this one), the pattern of ADR 0007 for + commands. The conversion has no other user and calls nothing of the domain, so a second file only separates it from the + data it reads. Changed after review. - **A filter that normalizes in its own `Create`** (the version before this one): a private constructor and a static `Create(page, pageSize, SearchText?, ClientStatus?)` holding the trim, the lowercase and the extraction of the CPF digits. The rules belong to the query that knows the wire's shape, and the filter stays data. Changed after review. From ab5187aeedd532a8a7cd071de6bd56a30844b0ae Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 12:57:47 -0300 Subject: [PATCH 14/24] refactor(services): nomear os valores do filtro em ListClientsQuery.ToFilter() (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O ToFilter() chamava CpfDigitsOf(text) e ToClientStatus(Status) dentro da lista de argumentos do construtor. Os dois valores ganham nome (cpfDigits e status), e o construtor passa a receber só variáveis, como já fazia com o texto. Sem mudança de comportamento; os testes de ListClientsQueryTests e de persistência continuam passando. Co-Authored-By: Claude Sonnet 5.5 --- .../Clients/ListClients/ListClientsQuery.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 8f329419..88fdac91 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -9,8 +9,10 @@ public sealed record ListClientsQuery( public ClientListFilter ToFilter() { var text = TextOf(Search); + var cpfDigits = CpfDigitsOf(text); + var status = ToClientStatus(Status); - return new ClientListFilter(Page, PageSize, text, CpfDigitsOf(text), ToClientStatus(Status)); + return new ClientListFilter(Page, PageSize, text, cpfDigits, status); } private static string? TextOf(SearchText? search) From b0d6d0ce84e87989a6628058e3c0cab783834157 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 12:59:23 -0300 Subject: [PATCH 15/24] Update ListClientsQueryHandler.cs --- .../Clients/ListClients/ListClientsQueryHandler.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs index 195167c2..1f211fcb 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -3,14 +3,12 @@ namespace ServicesService.Application.Clients.ListClients; -public sealed class ListClientsQueryHandler(IClientRepository clientRepository) - : IQueryHandler> +public sealed class ListClientsQueryHandler(IClientRepository clientRepository) : IQueryHandler> { - public async Task>> Handle( - ListClientsQuery query, - CancellationToken cancellationToken) + public async Task>> Handle(ListClientsQuery query, CancellationToken cancellationToken) { - var clients = await clientRepository.ListAsync(query.ToFilter(), cancellationToken); + var clientFilter = query.ToFilter(); + var clients = await clientRepository.ListAsync(clientFilter, cancellationToken); return Result.Success(clients.Map(ClientListItemResponse.FromClient)); } From 5946f0a20c8ea066b6db48ddfe8bd8b5e128143d Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 13:03:20 -0300 Subject: [PATCH 16/24] =?UTF-8?q?refactor(services):=20os=20d=C3=ADgitos?= =?UTF-8?q?=20do=20CPF=20da=20busca=20de=20pessoas=20viram=20o=20value=20o?= =?UTF-8?q?bject=20CpfDigits=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O ClientListFilter guardava os dígitos do CPF numa string?. O value object que já existe para CPF, o CpfNumber, não serve: ele valida os dígitos verificadores e recusa todo fragmento (123, 456.789, até dez dígitos corretos), e o Restore existe só para o EF reler o que está gravado. - CpfDigits (Application/Clients): os dígitos de um termo sem a máscara, de um a onze dígitos ASCII; Create devolve null para qualquer outra coisa. Um número com mais dígitos que um CPF não pode ser parte dele, então simplesmente não busca no CPF; o nome e o e-mail continuam sendo buscados. - ClientListFilter passa a ter CpfDigits? CpfDigits, e ListClientsQuery.ToFilter() chama CpfDigits.Create(text); o helper privado CpfDigitsOf sai da query. - O repositório lê cpfDigits.Value para o Contains, e o SQL não muda. - CpfDigitsTests recebe a matriz de dígitos e máscara, de só dígitos ASCII e do limite de onze; ListClientsQueryTests fica com a conversão da query. - ADR 0063 (por que não CpfNumber nem o kernel) e ADR 0064 ajustadas. Ao vivo: 94 verificações de pessoas, 10 de edição, 40 de serviços e 6 de CPF (inteiro, com máscara, parcial, fragmento com máscara, doze dígitos e letras) passam; o contrato gerado não muda. Co-Authored-By: Claude Sonnet 5.5 --- .../Clients/ClientListFilter.cs | 2 +- .../Clients/CpfDigits.cs | 27 +++++++ .../Clients/ListClients/ListClientsQuery.cs | 18 +---- .../Repositories/ClientRepository.cs | 6 +- .../Clients/ClientListFilterTests.cs | 10 ++- .../Clients/CpfDigitsTests.cs | 81 +++++++++++++++++++ .../ListClientsQueryHandlerTests.cs | 2 +- .../ListClients/ListClientsQueryTests.cs | 36 +++------ .../0063-clients-list-and-search-contract.md | 10 ++- ...ged-reads-page-request-and-paged-result.md | 2 +- 10 files changed, 141 insertions(+), 53 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs diff --git a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs index f6075194..dc3006c2 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs @@ -6,5 +6,5 @@ public sealed record ClientListFilter( int Page, int PageSize, string? Text, - string? CpfDigits, + CpfDigits? CpfDigits, ClientStatus? Status) : PageRequest(Page, PageSize); diff --git a/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs b/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs new file mode 100644 index 00000000..c0225915 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs @@ -0,0 +1,27 @@ +namespace ServicesService.Application.Clients; + +public sealed record CpfDigits +{ + private CpfDigits(string value) + { + Value = value; + } + + public string Value { get; } + + public static CpfDigits? Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var digits = new string(raw.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || digits.Length > CpfNumber.Length || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return new CpfDigits(digits); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 88fdac91..555c0514 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -9,7 +9,7 @@ public sealed record ListClientsQuery( public ClientListFilter ToFilter() { var text = TextOf(Search); - var cpfDigits = CpfDigitsOf(text); + var cpfDigits = CpfDigits.Create(text); var status = ToClientStatus(Status); return new ClientListFilter(Page, PageSize, text, cpfDigits, status); @@ -25,22 +25,6 @@ public ClientListFilter ToFilter() return search.Value; } - private static string? CpfDigitsOf(string? text) - { - if (text is null) - { - return null; - } - - var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); - if (digits.Length == 0 || !digits.All(char.IsAsciiDigit)) - { - return null; - } - - return digits; - } - private static ClientStatus? ToClientStatus(ClientStatusFilter? status) { if (status == ClientStatusFilter.All) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index fcfc34ed..1c03c90d 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -44,14 +44,16 @@ private static IQueryable FilterAndOrder(IQueryable query, Clien .ThenBy(client => client.Id); } - private static IQueryable Search(IQueryable query, string text, string? cpfDigits) + private static IQueryable Search(IQueryable query, string text, CpfDigits? cpfDigits) { if (cpfDigits is not null) { + var digits = cpfDigits.Value; + return query.Where(client => EF.Property(client, "NameNormalized").Contains(text) || EF.Property(client, "EmailNormalized").Contains(text) - || EF.Property(client, "CpfNormalized").Contains(cpfDigits)); + || EF.Property(client, "CpfNormalized").Contains(digits)); } return query.Where(client => diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs index a949729f..e70fa73d 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs @@ -30,10 +30,12 @@ public void APageNoRepositoryCouldServe_Throws(int page, int pageSize) [Fact] public void TheTextTheCpfDigitsAndTheStatus_AreKeptExactlyAsGiven() { - var filter = new ClientListFilter(1, 20, " Maria ", "123", ClientStatus.Inactive); + var cpfDigits = CpfDigits.Create("123"); + + var filter = new ClientListFilter(1, 20, " Maria ", cpfDigits, ClientStatus.Inactive); filter.Text.Should().Be(" Maria "); - filter.CpfDigits.Should().Be("123"); + filter.CpfDigits.Should().BeSameAs(cpfDigits); filter.Status.Should().Be(ClientStatus.Inactive); } @@ -46,7 +48,9 @@ public void TwoFiltersWithTheSameValues_AreEqual() filter.Should().NotBe(new ClientListFilter(3, 20, "maria", null, ClientStatus.Active)); filter.Should().NotBe(new ClientListFilter(2, 20, "maria", null, ClientStatus.Inactive)); filter.Should().NotBe(new ClientListFilter(2, 20, "mario", null, ClientStatus.Active)); - filter.Should().NotBe(new ClientListFilter(2, 20, "maria", "123", ClientStatus.Active)); + filter.Should().NotBe(new ClientListFilter(2, 20, "maria", CpfDigits.Create("123"), ClientStatus.Active)); + new ClientListFilter(2, 20, "maria", CpfDigits.Create("123"), null).Should() + .Be(new ClientListFilter(2, 20, "maria", CpfDigits.Create("1.2-3"), null)); } [Fact] diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs new file mode 100644 index 00000000..a80fd674 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs @@ -0,0 +1,81 @@ +using ServicesService.Application.Clients; + +namespace ServicesService.Tests.Clients; + +public class CpfDigitsTests +{ + [Theory] + [InlineData("529.982.247-25", "52998224725")] + [InlineData("52998224725", "52998224725")] + [InlineData(" 529 982 247 25 ", "52998224725")] + [InlineData("123", "123")] + [InlineData("456.789", "456789")] + [InlineData("0", "0")] + [InlineData("1.2-3", "123")] + public void Create_WithDigitsAndCpfMask_KeepsTheDigitsWithoutTheMask(string raw, string expected) + { + CpfDigits.Create(raw)!.Value.Should().Be(expected); + } + + [Fact] + public void Create_AcceptsAsManyDigitsAsACpfHas() + { + CpfDigits.Create("12345678901")!.Value.Should().HaveLength(CpfNumber.Length); + } + + [Fact] + public void Create_WithMoreDigitsThanACpfHas_IsNotACpfFragment() + { + CpfDigits.Create("123456789012").Should().BeNull(); + CpfDigits.Create("529.982.247-255").Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("-")] + [InlineData("...---")] + [InlineData(". - .")] + public void Create_WithNothingButMaskOrBlank_IsNotACpfFragment(string? raw) + { + CpfDigits.Create(raw).Should().BeNull(); + } + + [Theory] + [InlineData("maria")] + [InlineData("maria.silva@example.com")] + [InlineData("ana-lima")] + [InlineData("a1")] + [InlineData("1a")] + [InlineData("529.982.247-2x")] + [InlineData("12/34")] + [InlineData("12,5")] + public void Create_WithAnythingButDigitsAndTheMask_IsNotACpfFragment(string raw) + { + CpfDigits.Create(raw).Should().BeNull(); + } + + [Fact] + public void Create_OnlyAcceptsAsciiDigits() + { + CpfDigits.Create("١٢٣").Should().BeNull(); + CpfDigits.Create("123").Should().BeNull(); + } + + [Fact] + public void ACompleteValidCpf_IsAlsoAFragment_AndAnInvalidOneToo() + { + CpfDigits.Create("529.982.247-25").Should().NotBeNull(); + CpfDigits.Create("529.982.247-24").Should().NotBeNull(); + CpfNumber.Create("529.982.247-24").IsSuccess.Should().BeFalse(); + } + + [Fact] + public void TwoValuesWithTheSameDigits_AreEqualWhateverTheirMask() + { + CpfDigits.Create("529.982").Should().Be(CpfDigits.Create("529982")); + CpfDigits.Create("529.982").Should().NotBe(CpfDigits.Create("529983")); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 9c96fd5f..f179868f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -64,7 +64,7 @@ private static bool FilterIs(ClientListFilter filter, string? text, string? cpfD return filter.Page == page && filter.PageSize == pageSize && filter.Text == text - && filter.CpfDigits == cpfDigits + && filter.CpfDigits?.Value == cpfDigits && filter.Status == status; } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs index fa93366f..ed9889a7 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs @@ -39,9 +39,9 @@ public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageOfTheActiveOnesWithout } [Fact] - public void ToFilter_CarriesThePageTheSizeAndTheSearchOfTheQuery() + public void ToFilter_CarriesThePageTheSizeTheSearchAndTheCpfDigitsOfTheQuery() { - var query = new ListClientsQuery(Search: SearchTextOf(" Maria SILVA "), Status: ClientStatusFilter.Inactive) + var query = new ListClientsQuery(Search: SearchTextOf(" 529.982.247-25 "), Status: ClientStatusFilter.Inactive) { Page = 3, PageSize = 50, @@ -49,7 +49,7 @@ public void ToFilter_CarriesThePageTheSizeAndTheSearchOfTheQuery() var filter = query.ToFilter(); - filter.Should().Be(new ClientListFilter(3, 50, "maria silva", null, ClientStatus.Inactive)); + filter.Should().Be(new ClientListFilter(3, 50, "529.982.247-25", CpfDigits.Create("52998224725"), ClientStatus.Inactive)); filter.Page.Should().Be(3); filter.PageSize.Should().Be(50); } @@ -94,43 +94,25 @@ public void ToFilter_KeepsTheNormalizedTextForTheNameAndTheEmail(string raw, str [Theory] [InlineData("529.982.247-25", "52998224725")] - [InlineData("52998224725", "52998224725")] - [InlineData(" 529 982 247 25 ", "52998224725")] - [InlineData("123", "123")] [InlineData("456.789", "456789")] - [InlineData("0", "0")] - public void ToFilter_WithDigitsAndCpfMask_AlsoCarriesTheCpfWithoutTheMask(string raw, string expectedDigits) + [InlineData("123", "123")] + public void ToFilter_WithDigitsAndCpfMask_AlsoCarriesTheCpfDigitsWithoutTheMask(string raw, string expectedDigits) { var filter = FilterOf(raw); - filter.CpfDigits.Should().Be(expectedDigits); - filter.Text.Should().Be(raw.Trim()); + filter.CpfDigits.Should().Be(CpfDigits.Create(expectedDigits)); + filter.Text.Should().Be(raw); } [Theory] [InlineData("maria")] - [InlineData("maria.silva@example.com")] [InlineData("ana-lima")] - [InlineData("a1")] - [InlineData("1a")] - [InlineData("529.982.247-2x")] - [InlineData("12/34")] - [InlineData("12,5")] - [InlineData(".")] - [InlineData("-")] - [InlineData("...---")] - public void ToFilter_WithAnythingButDigitsAndTheMask_DoesNotCarryTheCpf(string raw) + [InlineData("529982247255")] + public void ToFilter_WithAnythingThatIsNotACpfFragment_DoesNotCarryTheCpfButKeepsTheText(string raw) { var filter = FilterOf(raw); filter.CpfDigits.Should().BeNull(); filter.Text.Should().Be(raw); } - - [Fact] - public void ToFilter_OnlyAcceptsAsciiDigitsForTheCpf() - { - FilterOf("١٢٣").CpfDigits.Should().BeNull(); - FilterOf("123").CpfDigits.Should().BeNull(); - } } diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index c4408e2f..1cacc71a 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -67,7 +67,10 @@ it as "contains": The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): the -query's `ToFilter()` derives the two plain values (`Text`, from the `SearchText` of `query.Search`, and `CpfDigits`), and the +query's `ToFilter()` derives two values: `Text`, from the `SearchText` of `query.Search`, and `CpfDigits`, a small value +object of the Application (`Clients/CpfDigits`) that holds the digits of the term without the CPF mask, from one to eleven +ASCII digits, and is absent for anything else. A digit string longer than a CPF cannot be part of one, so it simply does not +search the CPF (the name and the e-mail are still searched). The repository port takes the filter. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not @@ -161,6 +164,11 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, - **An accent-insensitive normalization**, by `lower(translate(...))` (the character list would live in the migration and again in .NET, and the two could drift) or by the `unaccent` extension (a `CREATE EXTENSION` in the infra init, an `IMMUTABLE` wrapper to fit a generated column, a new mechanism). Nobody asked for it yet. +- **`CpfNumber` as the type of the CPF digits.** It validates the check digits, so it refuses every fragment (`123`, + `456.789`, even ten correct digits). Its `Restore` skips the validation, but it exists for EF to read what is stored, and + using it for a fragment would break the invariant of the type. +- **A `CpfDigits` in the shared kernel.** It would share the mask rule with `CpfNumber`, but the kernel needs a second real + caller and today there is one. - **One combined search column.** A term with a space could match across two fields. - **`search` as a plain string normalized in the handler** (the first version of this change). The rule sat in the use case and in a validator rule instead of in a type; changed after review (ADR 0065). diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index 56cb501c..4916f3aa 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -37,7 +37,7 @@ explicit order is a bug. **The filter of a list extends it.** A paged list has one record, `ClientListFilter : PageRequest`, that carries the page and the plain filters of that list (`Text`, `CpfDigits`, `Status`): one object from the handler to the repository, where a new criterion is one more property and not one more parameter. It is a plain positional record, a -DTO with no rule of its own: the query's `ToFilter()` normalizes what comes from the wire (the search text, the digits +DTO with no rule of its own (the CPF digits are a `CpfDigits` value object, not a string): the query's `ToFilter()` normalizes what comes from the wire (the search text, the digits of the CPF, the status) and the base constructor keeps the guard. The port is `ListAsync(ClientListFilter filter, ct)` returning `PagedResult`, and the adapter hands the filter to `ListPagedAsync(filter, filterAndOrder, ct)`, which reads only the page from it. The filter holds plain values: no From 28cc81163cbaaf9d30fa71d174d7f1d504bbfca1 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 15:00:17 -0300 Subject: [PATCH 17/24] asdasd --- .../Repositories/ClientRepository.cs | 16 ++++++++++------ .../Repositories/ServiceRepository.cs | 2 +- .../RepositoryBase.cs | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 1c03c90d..092b303b 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -16,18 +16,22 @@ public ClientRepository(ServicesDataContext dbContext) public Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken) { - return Set - .Include(client => client.Guardians.OrderBy(guardian => guardian.Name).ThenBy(guardian => guardian.Id)) - .Include(client => client.ReferenceContacts.OrderBy(contact => contact.Name).ThenBy(contact => contact.Id)) - .FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); + return WithContacts(Set).FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); } public Task> ListAsync(ClientListFilter filter, CancellationToken cancellationToken) { - return ListPagedAsync(filter, query => FilterAndOrder(query, filter), cancellationToken); + return ListPagedAsync(filter, query => FilterAndOrder(WithContacts(query), filter), cancellationToken); + } + + private static IQueryable WithContacts(IQueryable query) + { + return query + .Include(client => client.Guardians.OrderBy(guardian => guardian.Name).ThenBy(guardian => guardian.Id)) + .Include(client => client.ReferenceContacts.OrderBy(contact => contact.Name).ThenBy(contact => contact.Id)); } - private static IQueryable FilterAndOrder(IQueryable query, ClientListFilter filter) + private static IOrderedQueryable FilterAndOrder(IQueryable query, ClientListFilter filter) { if (filter.Status is { } wantedStatus) { diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs index 9317dac5..e01082b7 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs @@ -19,7 +19,7 @@ public Task> ListAsync(ServiceListFilter filter, Cancellati return ListPagedAsync(filter, query => FilterAndOrder(query, filter), cancellationToken); } - private static IQueryable FilterAndOrder(IQueryable query, ServiceListFilter filter) + private static IOrderedQueryable FilterAndOrder(IQueryable query, ServiceListFilter filter) { query = query.Include(service => service.Tags); diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs index 6b0fe0ca..b98450d7 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs @@ -50,7 +50,7 @@ protected async Task> ListAsync( protected async Task> ListPagedAsync( PageRequest page, - Func, IQueryable> filterAndOrder, + Func, IOrderedQueryable> filterAndOrder, CancellationToken cancellationToken) { var query = filterAndOrder(Set.AsNoTracking()); From 815fc643fdf009d5573578578c3bd532b5c65335 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 15:07:55 -0300 Subject: [PATCH 18/24] test(services): fixar a lista de pessoas com cada cliente inteiro e a ordem exigida pelo tipo (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testes e documentação do 28cc811, que mudou o código: o ClientRepository.ListAsync passou a devolver cada Client com responsáveis e contatos de referência (o mesmo WithContacts do GetByIdAsync), e o RepositoryBase.ListPagedAsync passou a exigir um IOrderedQueryable no filterAndOrder. - ClientListPersistenceTests: sai List_ReturnsTheRootWithoutLoadingItsContacts; entram um teste de que a página traz os contatos de cada pessoa, em ordem, sem mudar a página nem o total, e um de regressão: uma pessoa lida da página, desativada e gravada com UpdateAsync mantém responsáveis e contatos (antes os perdia). Os dois falham sem o WithContacts na lista. - ADR 0063: a decisão de não carregar os filhos na lista é revertida, com o motivo; o split query fica rejeitado por falta de medição; os tempos de "No index" são de antes da mudança e não foram medidos de novo. - ADR 0064, ARCHITECTURE §5 e persistence.md: a página traz cada raiz inteira, e o filterAndOrder devolve um IOrderedQueryable, então uma página sem OrderBy não compila. Não verificado no PostgreSQL: o SQL do Include junto com Skip e Take e o tempo da lista com 50.000 pessoas. Co-Authored-By: Claude Opus 5.5 --- .../references/persistence.md | 3 +- backend/docs/ARCHITECTURE.md | 7 +- .../ClientListPersistenceTests.cs | 81 +++++++++++++++---- .../0063-clients-list-and-search-contract.md | 12 ++- ...ged-reads-page-request-and-paged-result.md | 5 +- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/.claude/skills/agenza-backend-slice/references/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index e2e93eda..7a2c1ca5 100644 --- a/.claude/skills/agenza-backend-slice/references/persistence.md +++ b/.claude/skills/agenza-backend-slice/references/persistence.md @@ -17,7 +17,7 @@ no `Remove` until a delete exists, no `ListAsync` until a list exists. | `CountByAsync(id)` | `int` | an "in use" rule | | `GetByIdsAsync(ids)` | `IReadOnlyList` | one query for a set — never one per row | | `ListAsync(filters)` | `IReadOnlyList` | an unpaged list, ordered | -| `ListAsync(ListFilter)` | `PagedResult` | a paged list, ordered: the filter extends `PageRequest` and carries the list's plain filters; the adapter calls `ListPagedAsync(filter, filterAndOrder, ct)` | +| `ListAsync(ListFilter)` | `PagedResult` | a paged list, ordered: the filter extends `PageRequest` and carries the list's plain filters; the adapter calls `ListPagedAsync(filter, filterAndOrder, ct)` with the root's `Include`s, and `filterAndOrder` returns an `IOrderedQueryable` | | `Add(entity)`, `UpdateAsync(entity)`, `Remove(entity)` | `void` / `Task` | stage; the handler commits | Parameters are ids, value objects for value-object columns and plain filter values — a list with several of them takes @@ -87,5 +87,6 @@ The index decides under concurrency. The pre-check exists only to answer with th - `SqlQuery`, `FromSql`, `ExecuteSql`, `BeginTransaction`, a cache — without evidence in the PR - an `Id`-only foreign key between two tenant-owned entities - a list or page without an explicit order +- a list or page whose roots come without their children: `UpdateAsync` on one deletes them - a literal length in a configuration where a constant exists - an edited migration that was already committed diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 4e3eddb9..03354ec4 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -339,15 +339,16 @@ either; `IgnoreQueryFilters()` belongs to a persistence test that asserts a soft **Repositories.** One per aggregate root — never one for a child. The port `IRepository` lives in `Application/Abstractions` and declares only what a handler calls; the adapter extends -`RepositoryBase` in Infrastructure. It returns its own root (with its children), a list or a page -of them, a `bool` or a count — never a DTO, an `IQueryable` or another aggregate. Methods say what they +`RepositoryBase` in Infrastructure. It returns its own root with its children, a list or a page +of them (each root whole there too, because `UpdateAsync` stages the children the root holds), a `bool` or a count — +never a DTO, an `IQueryable` or another aggregate. Methods say what they are for (`FindActiveByEmailAsync`), take value objects for value-object columns, and use no tracking for reads. `ServicesDataContext` sets that behavior globally; shared helpers may preserve it explicitly for a service outside that decision. A command loads its aggregate with `GetByIdAsync`, applies the domain behaviour, then stages persistence with `UpdateAsync`; the port does not expose EF tracking. An EF adapter makes any required state changes explicitly inside that operation. Paged reads take a `PageRequest` — or the filter of the list, a record that extends it with the list's plain filters — and return a -`PagedResult` through `ListPagedAsync` ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). The tenant +`PagedResult` through `ListPagedAsync`, whose filter-and-order function returns an `IOrderedQueryable` ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). The tenant and soft-delete filters come from the `DbContext`: a repository never writes a `TenantId` or `DeletedAt` predicate. A query cannot reach `.Value` through a converter: compare whole value objects or order by the property. Matching or sorting a value-object column as text — `EF.Property` over it throws — goes through a diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs index bc888c97..7d727de7 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -17,7 +17,12 @@ public class ClientListPersistenceTests private const string PauloCpf = "11144477735"; private static readonly DateOnly Today = new(2026, 10, 12); - private static Client NewClient(string fullName, string? cpf = null, string? email = null) + private static Client NewClient( + string fullName, + string? cpf = null, + string? email = null, + IReadOnlyCollection? guardians = null, + IReadOnlyCollection? referenceContacts = null) { var data = new ClientData( FullName.Create(fullName).Value, @@ -26,11 +31,21 @@ private static Client NewClient(string fullName, string? cpf = null, string? ema email is null ? null : EmailAddress.Create(email).Value, cpf is null ? null : CpfNumber.Create(cpf).Value, null, - [], - []); + guardians ?? [], + referenceContacts ?? []); return Client.Create(data, Today).Value; } + private static GuardianData Guardian(string name) + { + return new GuardianData(name, "Mãe", null, null); + } + + private static ReferenceContactData ReferenceContact(string name) + { + return new ReferenceContactData(FullName.Create(name).Value, "Tio", null, new HashSet { ContactPurpose.Emergency }); + } + private static async Task Save(ServicesDataContext context, Client client) { context.Clients.Add(client); @@ -351,24 +366,58 @@ public async Task List_CombinesTheSearchWithTheSituationAndThePage() } [Fact] - public async Task List_ReturnsTheRootWithoutLoadingItsContacts() + public async Task List_ReturnsEachClientWithItsContacts_WithoutChangingThePageOrTheTotal() { var databaseName = Guid.NewGuid().ToString(); var tenantId = Guid.NewGuid(); - var data = new ClientData( - FullName.Create("Ana Lima").Value, - null, - null, - null, - null, - null, - [new GuardianData("Bia Lima", "Mãe", null, null)], - []); - await SaveAll(databaseName, tenantId, Client.Create(data, Today).Value); + await SaveAll( + databaseName, + tenantId, + NewClient( + "Ana Lima", + guardians: [Guardian("Bia Lima"), Guardian("Abel Lima")], + referenceContacts: [ReferenceContact("Davi Lima"), ReferenceContact("Caio Lima")]), + NewClient( + "Bruno Dias", + guardians: [Guardian("Eva Dias"), Guardian("Fabio Dias")], + referenceContacts: [ReferenceContact("Gil Dias")]), + NewClient("Carla Souza")); - var page = await List(databaseName, tenantId); + var page = await List(databaseName, tenantId, page: 1, pageSize: 2); + + Names(page).Should().Equal("Ana Lima", "Bruno Dias"); + page.TotalCount.Should().Be(3); + page.Items[0].Guardians.Select(guardian => guardian.Name).Should().Equal("Abel Lima", "Bia Lima"); + page.Items[0].ReferenceContacts.Select(contact => contact.Name).Should().Equal("Caio Lima", "Davi Lima"); + page.Items[1].Guardians.Should().HaveCount(2); + page.Items[1].ReferenceContacts.Should().ContainSingle(); + } + + [Fact] + public async Task List_AClientChangedFromThePageAndSaved_KeepsItsContacts() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var client = NewClient("Ana Lima", guardians: [Guardian("Bia Lima")], referenceContacts: [ReferenceContact("Caio Lima")]); + await SaveAll(databaseName, tenantId, client); - page.Items.Should().ContainSingle().Which.Guardians.Should().BeEmpty(); + await using (var context = PersistenceContext.Create(databaseName, tenantId)) + { + var repository = new ClientRepository(context); + var page = await repository.ListAsync(new ListClientsQuery().ToFilter(), CancellationToken.None); + var listed = page.Items.Single(); + listed.Inactivate(); + await repository.UpdateAsync(listed, CancellationToken.None); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using (var context = PersistenceContext.Create(databaseName, tenantId)) + { + var reloaded = await new ClientRepository(context).GetByIdAsync(client.Id, CancellationToken.None); + reloaded!.Status.Should().Be(ClientStatus.Inactive); + reloaded.Guardians.Should().ContainSingle().Which.Name.Should().Be("Bia Lima"); + reloaded.ReferenceContacts.Should().ContainSingle().Which.Name.Should().Be("Caio Lima"); + } } [Fact] diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 1cacc71a..46aa3e01 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -34,8 +34,9 @@ services, tags and categories lists, and not the `q` the issue #155 wrote; the t as the CPF. The old `?q=` is not an error: nothing binds it and the list is not filtered. **The item.** `ClientListItemResponse`: `id`, `fullName`, `birthDate`, `phone`, `email`, `cpf`, `status`. No -guardians, reference contacts or notes, no `age` (ADR 0060). The repository returns the client roots without their -contacts loaded, and the mapping reads the root only; the list is not a place to load children. +guardians, reference contacts or notes, no `age` (ADR 0060). The mapping reads the root only, but the repository still +returns each client whole, with its guardians and reference contacts, in the shape `GetByIdAsync` loads: `UpdateAsync` +stages the contacts the root holds in memory, so a client read from a page without them and saved would lose them. **The CPF is not masked.** It comes whole, digits only, as the detail returns it. This is a product decision of the owner on 2026-10-12, taken against the text of #155 ("Mascarar CPF na lista") and the criterion of the parent #139 @@ -130,6 +131,8 @@ and an ADR of its own. and search. - `count` and the page are two statements without a transaction, so with concurrent writes the total and the page can disagree, as in the other lists. +- The page statement also reads the guardians and reference contacts of its clients, in one query like + `GetByIdAsync`. The timings under **No index** were measured before that change and were not taken again. - The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL would, and a model test pins the expressions. What the real columns produce was verified by hand. - Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does. @@ -169,6 +172,11 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, using it for a fragment would break the invariant of the type. - **A `CpfDigits` in the shared kernel.** It would share the mask rule with `CpfNumber`, but the kernel needs a second real caller and today there is one. +- **Client roots without their contacts in the page** (the first version of this change). Two joins fewer, but the port + returned a `Client` that looked whole and was not: a client from the page, inactivated and staged with `UpdateAsync`, + lost every guardian and reference contact on save (a persistence test now pins the opposite). Changed after review. +- **A split query for the two collections of the page.** An optimization without a measured problem; `GetByIdAsync` + loads them in one query too. - **One combined search column.** A term with a space could match across two fields. - **`search` as a plain string normalized in the handler** (the first version of this change). The rule sat in the use case and in a validator rule instead of in a type; changed after review (ADR 0065). diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index 4916f3aa..a4551420 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -28,8 +28,9 @@ error codes below and `OffsetFits(page, pageSize)`, the one formula the validato not `sealed` on purpose: the filter of a list extends it (below), so the guard comes with the page. **`RepositoryBase.ListPagedAsync(PageRequest, filterAndOrder, ct)`** returns `PagedResult`: the items, the -real total, and the page and size it was asked for. The filter-and-order function is required: a page without an -explicit order is a bug. +real total, and the page and size it was asked for. The filter-and-order function is required, because a page without an +explicit order is a bug, and it returns an `IOrderedQueryable`, so a page without an `OrderBy` does not compile. +Ending the order in a unique key (`ThenBy(e => e.Id)`) stays the repository's to write. **`PagedResult.Map(...)`** converts the items and keeps the total, the page and the size, so a handler is `repository.ListAsync(query.ToFilter(), ct)` followed by `.Map(ListItemResponse.From)`. From ebdf9fd75f35c7fbd7f5fd8c01badb720277a2ae Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 17:28:00 -0300 Subject: [PATCH 19/24] asd --- .../references/use-case.md | 5 +-- backend/docs/ARCHITECTURE.md | 5 +-- .../ListServices/ListServicesQuery.cs | 10 +++--- .../Services/ServiceListFilter.cs | 2 +- .../Repositories/ServiceRepository.cs | 4 +-- .../ServiceListPersistenceTests.cs | 33 ++++++++++++++++--- .../ListServicesQueryHandlerTests.cs | 19 +++-------- .../ListServices/ListServicesQueryTests.cs | 29 +++++++++------- .../Services/ServiceListFilterTests.cs | 4 +-- docs/API.md | 2 +- .../0063-clients-list-and-search-contract.md | 7 ++-- ...ged-reads-page-request-and-paged-result.md | 29 +++++++++++++--- ...tring-value-objects-in-a-query-or-route.md | 12 ++++--- 13 files changed, 102 insertions(+), 59 deletions(-) diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 42fb4d21..0488dfeb 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -17,8 +17,9 @@ messages", plus the earlier slices for reads, edits and paging. This file is how the domain's code (ADR 0058). - A route id is a member (`Id`) that the controller fills with `with { … }`. - A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as - optional members; its own `ToFilter()` method builds the `ListFilter` (a `PageRequest` with the list's - filters), and the validator writes the three `Paging.*` rules (ADR 0064). + optional members, a free-text search as a `SearchText?`; its own `ToFilter()` method builds the + `ListFilter` (a `PageRequest` with the list's filters), and the validator writes the three `Paging.*` + rules (ADR 0064). ## 2. The validator — simple and clean diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 03354ec4..989c7c5a 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -396,7 +396,8 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult its validator writes the three rules (page at least 1, size from 1 to `PageRequest.MaxPageSize`, offset within `PageRequest.OffsetFits`) with the shared `Paging.*` codes; the query's own `ToFilter()` method builds the list's filter (`ClientListFilter`, a `PageRequest` with the - plain filters; it maps into an Application record, not into the domain, so there is no extension file as for a command), the port takes it and returns `PagedResult`, and the handler `Map`s it to the response. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` + plain filters; it maps into an Application record, not into the domain, so there is no extension file as for a command), the port takes it and returns `PagedResult`, and the handler `Map`s it to the response. + A free-text search is a `SearchText`. An unpaged list returns `IReadOnlyList`. A page past the last answers `200` with no items ([0064](../../docs/adr/0064-paged-reads-page-request-and-paged-result.md)). A change to an endpoint's contract regenerates the frontend's types in the same change — skill @@ -468,7 +469,7 @@ touches that slice, not in bulk. | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | | State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | -| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a query whose `ToFilter()` normalizes the search term, a repository that filters and orders over generated string columns | `EF.Functions.ILike` with an unescaped pattern (`ServiceRepository`, `TagRepository`, `CategoryRepository`) | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | +| List, paging, search | `ListClientsQueryHandler.cs`, `ListClientsQueryValidator.cs`, `ClientRepository.ListAsync`: a `ClientListFilter` (a `PageRequest` with the list's plain filters) in and a `PagedResult` out, a validator with the `Paging.*` codes, a query whose `ToFilter()` normalizes the search term, a repository that filters and orders over generated string columns | `EF.Functions.ILike` with an unescaped pattern and a `string?` search (`TagRepository`, `CategoryRepository`) | §4, §5, §6, [0063](../../docs/adr/0063-clients-list-and-search-contract.md) | | Delete | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs index 807d6005..7f89fcb1 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs @@ -3,22 +3,22 @@ namespace ServicesService.Application.Services.ListServices; public sealed record ListServicesQuery( - string? Search = null, + SearchText? Search = null, Guid? CategoryId = null, Guid? TagId = null) : PagedQuery, IQuery> { public ServiceListFilter ToFilter() { - return new ServiceListFilter(Page, PageSize, SearchOf(Search), CategoryId, TagId); + return new ServiceListFilter(Page, PageSize, TextOf(Search), CategoryId, TagId); } - private static string? SearchOf(string? search) + private static string? TextOf(SearchText? search) { - if (string.IsNullOrWhiteSpace(search)) + if (search is null || search.IsEmpty) { return null; } - return search.Trim(); + return search.Value; } } diff --git a/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs index b5b85d74..d6bb6db7 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs @@ -5,6 +5,6 @@ namespace ServicesService.Application.Services; public sealed record ServiceListFilter( int Page, int PageSize, - string? Search, + string? Text, Guid? CategoryId, Guid? TagId) : PageRequest(Page, PageSize); diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs index e01082b7..37165e7c 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs @@ -23,9 +23,9 @@ private static IOrderedQueryable FilterAndOrder(IQueryable que { query = query.Include(service => service.Tags); - if (filter.Search is { } search) + if (filter.Text is { } text) { - query = query.Where(service => EF.Functions.ILike(service.Name, $"%{search}%")); + query = query.Where(service => EF.Property(service, "NameNormalized").Contains(text)); } if (filter.CategoryId is { } categoryId) diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs index f95b6c46..a950f317 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs @@ -29,13 +29,18 @@ private static Tag NewTag(string name) return Tag.Create(Guid.NewGuid(), name, TagColor.Create("#0d9488").Value, null).Value; } + private static void Add(ServicesDataContext context, Service service) + { + new ServiceRepository(context).Add(service); + context.Entry(service).Property("NameNormalized").CurrentValue = service.Name.ToLowerInvariant(); + } + private static async Task Save(string databaseName, Guid tenantId, params Service[] services) { await using var context = PersistenceContext.Create(databaseName, tenantId); - var serviceRepository = new ServiceRepository(context); foreach (var service in services) { - serviceRepository.Add(service); + Add(context, service); } await context.SaveChangesAsync(TestContext.Current.CancellationToken); @@ -63,7 +68,7 @@ private static async Task SaveTagged( await using var context = PersistenceContext.Create(databaseName, tenantId); var ids = serviceTags.Select(tag => tag.Id).ToList(); service.SetTags(await new TagRepository(context).GetByIdsAsync(ids, CancellationToken.None)); - new ServiceRepository(context).Add(service); + Add(context, service); await context.SaveChangesAsync(TestContext.Current.CancellationToken); } } @@ -82,10 +87,15 @@ private static async Task> List( int page = 1, int pageSize = 20, Guid? categoryId = null, - Guid? tagId = null) + Guid? tagId = null, + string? search = null) { await using var context = PersistenceContext.Create(databaseName, tenantId); - var query = new ListServicesQuery(CategoryId: categoryId, TagId: tagId) { Page = page, PageSize = pageSize }; + var query = new ListServicesQuery(search is null ? null : SearchText.Create(search).Value, categoryId, tagId) + { + Page = page, + PageSize = pageSize, + }; return await new ServiceRepository(context).ListAsync(query.ToFilter(), CancellationToken.None); } @@ -188,6 +198,19 @@ await Save( page.TotalCount.Should().Be(2); } + [Fact] + public async Task List_SearchesTheNameAnywhereIgnoringCase() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + await Save(databaseName, tenantId, NewService("Corte Masculino"), NewService("Barba e CORTE"), NewService("Manicure")); + + var page = await List(databaseName, tenantId, search: " Corte "); + + Names(page).Should().Equal("Barba e CORTE", "Corte Masculino"); + page.TotalCount.Should().Be(2); + } + [Fact] public async Task List_FiltersByTagAndLoadsTheTagsOfEachService() { diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs index cbcffe60..a9a94f39 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryHandlerTests.cs @@ -140,24 +140,20 @@ public async Task Handle_WithAPagePastTheLast_ReturnsAnEmptyPageAndTheTotal() } [Fact] - public async Task Handle_PassesThePageAndTheFiltersToTheRepositoryAsOneFilter() + public async Task Handle_PassesTheFiltersAndThePageToTheRepositoryAsOneFilter() { var handler = CreateHandler(out var serviceRepository, out _); var categoryId = Guid.NewGuid(); var tagId = Guid.NewGuid(); + var search = SearchText.Create(" Cut ").Value; var result = await handler.Handle( - new ListServicesQuery(Search: " cut ", CategoryId: categoryId, TagId: tagId) { Page = 2, PageSize = 5 }, + new ListServicesQuery(Search: search, CategoryId: categoryId, TagId: tagId) { Page = 2, PageSize = 5 }, CancellationToken.None); result.IsSuccess.Should().BeTrue(); await serviceRepository.Received(1).ListAsync( - Arg.Is(filter => - filter.Page == 2 - && filter.PageSize == 5 - && filter.Search == "cut" - && filter.CategoryId == categoryId - && filter.TagId == tagId), + Arg.Is(new ServiceListFilter(2, 5, "cut", categoryId, tagId)), Arg.Any()); } @@ -169,12 +165,7 @@ public async Task Handle_WithoutFilters_AsksForTheFirstPageWithNoFilter() await handler.Handle(new ListServicesQuery(), CancellationToken.None); await serviceRepository.Received(1).ListAsync( - Arg.Is(filter => - filter.Page == 1 - && filter.PageSize == 20 - && filter.Search == null - && filter.CategoryId == null - && filter.TagId == null), + Arg.Is(new ServiceListFilter(1, 20, null, null, null)), Arg.Any()); } diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs index 6e1579cd..3199dfab 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs @@ -5,6 +5,11 @@ namespace ServicesService.Tests.Services.ListServices; public class ListServicesQueryTests { + private static SearchText SearchTextOf(string raw) + { + return SearchText.Create(raw).Value; + } + [Fact] public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageWithNoFilter() { @@ -16,30 +21,30 @@ public void ToFilter_CarriesThePageTheSizeAndEveryFilterOfTheQuery() { var categoryId = Guid.NewGuid(); var tagId = Guid.NewGuid(); - var query = new ListServicesQuery(Search: " cut ", CategoryId: categoryId, TagId: tagId) { Page = 2, PageSize = 5 }; - - var filter = query.ToFilter(); + var query = new ListServicesQuery(Search: SearchTextOf(" Cut "), CategoryId: categoryId, TagId: tagId) + { + Page = 2, + PageSize = 5, + }; - filter.Should().Be(new ServiceListFilter(2, 5, "cut", categoryId, tagId)); - filter.Search.Should().Be("cut"); + query.ToFilter().Should().Be(new ServiceListFilter(2, 5, "cut", categoryId, tagId)); } [Theory] - [InlineData(" cut ", "cut")] - [InlineData("Hair Cut", "Hair Cut")] + [InlineData(" Hair CUT ", "hair cut")] [InlineData("100%", "100%")] - public void ToFilter_TrimsTheSearchAndKeepsItsCase(string raw, string expected) + [InlineData("a_b", "a_b")] + public void ToFilter_KeepsTheNormalizedTextOfTheSearch(string raw, string expected) { - new ListServicesQuery(Search: raw).ToFilter().Search.Should().Be(expected); + new ListServicesQuery(Search: SearchTextOf(raw)).ToFilter().Text.Should().Be(expected); } [Theory] - [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ToFilter_WithABlankSearch_IsNoSearch(string? raw) + public void ToFilter_WithAnEmptySearchText_IsNoSearch(string raw) { - new ListServicesQuery(Search: raw).ToFilter().Search.Should().BeNull(); + new ListServicesQuery(Search: SearchTextOf(raw)).ToFilter().Text.Should().BeNull(); } [Fact] diff --git a/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs index 70c90a22..817acb6b 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs @@ -28,14 +28,14 @@ public void APageNoRepositoryCouldServe_Throws(int page, int pageSize) } [Fact] - public void TheSearchTheCategoryAndTheTag_AreKeptExactlyAsGiven() + public void TheTextTheCategoryAndTheTag_AreKeptExactlyAsGiven() { var categoryId = Guid.NewGuid(); var tagId = Guid.NewGuid(); var filter = new ServiceListFilter(1, 20, " Cut ", categoryId, tagId); - filter.Search.Should().Be(" Cut "); + filter.Text.Should().Be(" Cut "); filter.CategoryId.Should().Be(categoryId); filter.TagId.Should().Be(tagId); } diff --git a/docs/API.md b/docs/API.md index 597e8e3a..67c90751 100644 --- a/docs/API.md +++ b/docs/API.md @@ -279,7 +279,7 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | | Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | -| Busca acima de 254 caracteres (§4.3, [ADR 0065](adr/0065-string-value-objects-in-a-query-or-route.md)) | `GET /api/v1/clients?search=…` | 400 (mensagem do value object, em pt-BR) | `Validation.Failed` (`q`: `Validation.Failed`, "A busca deve ter no máximo 254 caracteres.") | +| Busca acima de 254 caracteres (§4.3, [ADR 0065](adr/0065-string-value-objects-in-a-query-or-route.md)) | `GET /api/v1/clients?search=…`, `GET /api/v1/services?search=…` | 400 (mensagem do value object, em pt-BR) | `Validation.Failed` (`search`: `Validation.Failed`, "A busca deve ter no máximo 254 caracteres.") | O detalhe do meio da tabela (`00000000-...-0000`) é a pegadinha mais fácil de esquecer: a constraint de rota `{id:guid}` só valida **formato**, então um GUID zerado passa pelo roteamento normalmente e só é rejeitado dentro do `DeleteTagCommandValidator` (`NotEmpty`) — chega como 400 de diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 46aa3e01..7549b534 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -91,8 +91,8 @@ migration `AddClientSearchColumns`: They are shadow properties of type `string` in `ClientConfiguration`, so `EF.Property(c, "NameNormalized") .Contains(text)` translates to `LIKE @p`, and EF escapes `%`, `_` and `\` in the term: they are literal. (The `ILike` -of `Service`, `Tag` and `Category` sends `ESCAPE ''`, so `%` and `_` are wildcards there. This change does not touch -them.) PostgreSQL fills the columns for existing rows when the migration runs, and `Down` drops only them. Each +of `Tag` and `Category` sends `ESCAPE ''`, so `%` and `_` are wildcards there; the services list moved to the same +`Contains` over its own `NameNormalized` in [ADR 0064](0064-paged-reads-page-request-and-paged-result.md).) PostgreSQL fills the columns for existing rows when the migration runs, and `Down` drops only them. Each client load now reads three more short strings, because EF loads every mapped column of an entity. `lower()` follows the database ctype, as it already does for the service names. @@ -132,7 +132,8 @@ and an ADR of its own. - `count` and the page are two statements without a transaction, so with concurrent writes the total and the page can disagree, as in the other lists. - The page statement also reads the guardians and reference contacts of its clients, in one query like - `GetByIdAsync`. The timings under **No index** were measured before that change and were not taken again. + `GetByIdAsync`: the page is limited in a subquery and the contacts are joined to it. Measured again with them, on + 50,003 people with 28,576 guardians and 12,861 reference contacts: 25 to 45 ms at the median, 63 ms at page 2000. - The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL would, and a model test pins the expressions. What the real columns produce was verified by hand. - Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does. diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index a4551420..cc0299c7 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -70,13 +70,15 @@ extension file. `PagedQuery` and `PageRequest` are two types on purpose: one is is a page a repository can serve. The query is never the filter, and the filter is never bound from the wire. **Services follows.** The services list was converted after review, in the same pull request, the way clients was: -`ServiceListFilter : PageRequest` (`Search`, `CategoryId`, `TagId`), `ListServicesQuery : PagedQuery`, the port +`ServiceListFilter : PageRequest` (`Text`, `CategoryId`, `TagId`), `ListServicesQuery : PagedQuery`, the port `ListAsync(ServiceListFilter, ct)`, a `Map` in the handler (which still reads the names of the page's categories in one query) and the three `Paging.*` rules in its validator. The older `ListPagedAsync(order, page, pageSize)` was removed, because nothing else called it. `GET /api/v1/services?page=2147483647&pageSize=100` now answers `400 Paging.PageTooLarge` instead of `500`, and `page=43000000` answers the same `400` instead of a wrong offset. The services order also ends in -the id, so a tie cannot repeat or skip a record. The `Search` of services stays a plain `string?` and its `ILike` pattern -stays unescaped (`%` and `_` are wildcards in it), as before: neither belongs to this change. +the id, so a tie cannot repeat or skip a record. Its `Search` is a `SearchText` as in the clients list +([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)): trimmed and lowercased at binding, at most 254 characters, +and matched as "contains" over the generated column the service already had (`NameNormalized`, `lower("Name")`), so `%` +and `_` are literal characters instead of the wildcards of the older unescaped `ILike`. The order is unchanged. ## Consequences @@ -94,6 +96,9 @@ stays unescaped (`%` and `_` are wildcards in it), as before: neither belongs to members in the generated `query` object, because the properties are inherited. - A filter that extends `PageRequest` is never equal to a plain `PageRequest` of the same page: record equality includes the type. A test that wants the filter compares the filter. +- `GET /services?search=` changes too: a term is trimmed and lowercased at binding, one longer than 254 characters + answers `400 Validation.Failed` under `search` with the message of `SearchText` (it had no limit), and `%` and `_` + match themselves. The generated type stays `Search?: string`. Verified on the same disposable PostgreSQL 18.3 as [ADR 0063](0063-clients-list-and-search-contract.md), on the final code: the 90 checks of the clients list with the `Paging.*` codes and a page echoed from the repository's answer; @@ -104,6 +109,14 @@ service, nothing written by a listing, and an edit followed by the list); the vo 50,000 people (30 to 45 ms at the median, 133 ms at page 2000); and the generated frontend types equal to a fresh generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.SharedKernel.Tests`. +The `SearchText` of services and the clients page with its contacts were verified again on the final code, on a fresh +disposable PostgreSQL 18.3 with the real services and a real login, in 23 checks: for services, a trimmed and +case-insensitive search, `%`, `_` and `e_f` as literal text, a blank search, 255 characters refused under `search` +without echoing the term, 254 accepted after the trim, a searched list paged with no repeat, and the three `Paging.*` +codes; for clients, two pages with the contacts joined and no repeat or omission, the order, an item still without +contacts, a partial CPF, a deactivation that keeps the contacts, and the three codes. The logged SQL shows +`"NameNormalized" LIKE $2` with `$2 = '%\%%'`, and the generated frontend types are equal to a fresh generation. + ## Considered and rejected - **A shared `PagedQueryValidator` (`Include`) over an `IPagedQuery` interface.** One source for the rules, but it @@ -116,8 +129,14 @@ generation. `PageRequest` and `PagedResult.Map` have unit tests in `Admin.Shared - **`ListClientsQuery` extending `PageRequest`.** Binding would run the guard on raw input (`?page=0` would throw instead of answering `Paging.PageInvalid`), and the handler would hand the port a query carrying the wire's `Search` and `Status`. The filter, built after the validator, can extend it. -- **The filter holding a `PageRequest`** instead of extending it. Equally simple; one flat object was preferred, and - `ListPagedAsync(PageRequest, …)` accepts the filter as it is. +- **The filter holding a `PageRequest`** (`ClientListFilter(Text, CpfDigits, Status, PageRequest Page)`, tried after + review). One positional parameter instead of two forwarded ones, and `PageRequest` could be `sealed`; but `Page`, + `PageSize` and `Offset` would be read through `filter.Page`, and the filter would no longer be the page that + `ListPagedAsync` takes. The owner found the inheritance simpler and clearer, and accepts its costs: each filter + redeclares and forwards the two members, `PageRequest` stays open, and equality across the hierarchy is pinned by a test. +- **The page beside the filter** (`ListAsync(ClientListFilter filter, PageRequest page, ct)`, tried after review). The + criteria could exist without a page, for an unpaged read of the same list (an export, a count by filter), but no such + read exists, and a list's filter is always paged. - **`ToFilter()` as an extension method in a file of its own** (the version before this one), the pattern of ADR 0007 for commands. The conversion has no other user and calls nothing of the domain, so a second file only separates it from the data it reads. Changed after review. diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index 1b4c7390..7e9d65d9 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -59,17 +59,19 @@ A parameter that comes from the body is left to the JSON converters. `AdministrativeNotes` do not declare the converter, and a test pins it. A new type that may appear in a URL declares it on purpose. -**`ListClientsQuery.Search` is a `SearchText?`.** The validator lost its length rule and the handler its normalization: the -handler reads `query.Search?.Value`. The digits of a CPF mask stay in the handler, because they are a rule of the clients -search and not of a text. +**`ListClientsQuery.Search` and `ListServicesQuery.Search` are `SearchText?`.** The validator has no length rule and nothing +normalizes the term again: the query's `ToFilter()` reads its `Value` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). +The digits of a CPF mask stay in the clients query (`CpfDigits`), because they are a rule of the clients search and not of +a text. ## Consequences - A term longer than 254 characters now fails at binding: `400 Validation.Failed` under `search`, with the message "A busca deve ter no máximo 254 caracteres.". The code `Client.SearchTooLong` of the first version never reached a merged build. - The generated frontend types change only by the rename of the member: `Search?: string`, as in the other lists. -- Other free-text searches (`Search` of services, tags and categories, each trimmed in its repository) can adopt - `SearchText` when their lists are touched. That is the second caller that justifies the shared type; none is converted here. +- The services list, converted to the paged shape in the same change, takes `SearchText` too: the second caller that + justifies the shared type. Its term was unbounded and matched by an unescaped `ILike`; it is now bounded at binding and + matched as literal text. Tags and categories keep a `string?` trimmed in the repository until their lists are touched. - The probe of the document is a test: `StringValueObjectContractTests` fails if `SearchText` becomes a complex type to MVC again, and the provider tests fail if personal data starts binding from a URL. - A date value object is not bound from a URL. This ADR covers strings. From 002e85c70eb4d0bf168685f63f58d66cf875ccdd Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 17:42:10 -0300 Subject: [PATCH 20/24] Simplify client CPF filtering Removes the separate `CpfDigits` application value object and passes normalized CPF digits as plain strings through `ClientListFilter` and the repository search. `ListClientsQuery` now extracts digits in-place, preserving the original search text while keeping CPF-only matches valid. Tests and ADRs were updated to reflect the simpler client-list contract. --- .../Clients/ClientListFilter.cs | 2 +- .../Clients/CpfDigits.cs | 27 ------- .../Clients/ListClients/ListClientsQuery.cs | 18 ++++- .../Repositories/ClientRepository.cs | 6 +- .../Clients/ClientListFilterTests.cs | 10 +-- .../Clients/CpfDigitsTests.cs | 81 ------------------- .../ListClientsQueryHandlerTests.cs | 2 +- .../ListClients/ListClientsQueryTests.cs | 26 ++++-- .../0063-clients-list-and-search-contract.md | 16 ++-- ...ged-reads-page-request-and-paged-result.md | 2 +- ...tring-value-objects-in-a-query-or-route.md | 2 +- 11 files changed, 57 insertions(+), 135 deletions(-) delete mode 100644 backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs delete mode 100644 backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs diff --git a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs index dc3006c2..f6075194 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs @@ -6,5 +6,5 @@ public sealed record ClientListFilter( int Page, int PageSize, string? Text, - CpfDigits? CpfDigits, + string? CpfDigits, ClientStatus? Status) : PageRequest(Page, PageSize); diff --git a/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs b/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs deleted file mode 100644 index c0225915..00000000 --- a/backend/services/services-service/ServicesService.Application/Clients/CpfDigits.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace ServicesService.Application.Clients; - -public sealed record CpfDigits -{ - private CpfDigits(string value) - { - Value = value; - } - - public string Value { get; } - - public static CpfDigits? Create(string? raw) - { - if (string.IsNullOrWhiteSpace(raw)) - { - return null; - } - - var digits = new string(raw.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); - if (digits.Length == 0 || digits.Length > CpfNumber.Length || !digits.All(char.IsAsciiDigit)) - { - return null; - } - - return new CpfDigits(digits); - } -} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 555c0514..5f0d1d57 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -9,7 +9,7 @@ public sealed record ListClientsQuery( public ClientListFilter ToFilter() { var text = TextOf(Search); - var cpfDigits = CpfDigits.Create(text); + var cpfDigits = CpfDigitsOf(text); var status = ToClientStatus(Status); return new ClientListFilter(Page, PageSize, text, cpfDigits, status); @@ -25,6 +25,22 @@ public ClientListFilter ToFilter() return search.Value; } + private static string? CpfDigitsOf(string? text) + { + if (text is null) + { + return null; + } + + var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + if (digits.Length == 0 || digits.Length > CpfNumber.Length || !digits.All(char.IsAsciiDigit)) + { + return null; + } + + return digits; + } + private static ClientStatus? ToClientStatus(ClientStatusFilter? status) { if (status == ClientStatusFilter.All) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 092b303b..ff88cc71 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -48,16 +48,14 @@ private static IOrderedQueryable FilterAndOrder(IQueryable query .ThenBy(client => client.Id); } - private static IQueryable Search(IQueryable query, string text, CpfDigits? cpfDigits) + private static IQueryable Search(IQueryable query, string text, string? cpfDigits) { if (cpfDigits is not null) { - var digits = cpfDigits.Value; - return query.Where(client => EF.Property(client, "NameNormalized").Contains(text) || EF.Property(client, "EmailNormalized").Contains(text) - || EF.Property(client, "CpfNormalized").Contains(digits)); + || EF.Property(client, "CpfNormalized").Contains(cpfDigits)); } return query.Where(client => diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs index e70fa73d..cbe95731 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs @@ -30,12 +30,10 @@ public void APageNoRepositoryCouldServe_Throws(int page, int pageSize) [Fact] public void TheTextTheCpfDigitsAndTheStatus_AreKeptExactlyAsGiven() { - var cpfDigits = CpfDigits.Create("123"); - - var filter = new ClientListFilter(1, 20, " Maria ", cpfDigits, ClientStatus.Inactive); + var filter = new ClientListFilter(1, 20, " Maria ", "1.2-3", ClientStatus.Inactive); filter.Text.Should().Be(" Maria "); - filter.CpfDigits.Should().BeSameAs(cpfDigits); + filter.CpfDigits.Should().Be("1.2-3"); filter.Status.Should().Be(ClientStatus.Inactive); } @@ -48,9 +46,7 @@ public void TwoFiltersWithTheSameValues_AreEqual() filter.Should().NotBe(new ClientListFilter(3, 20, "maria", null, ClientStatus.Active)); filter.Should().NotBe(new ClientListFilter(2, 20, "maria", null, ClientStatus.Inactive)); filter.Should().NotBe(new ClientListFilter(2, 20, "mario", null, ClientStatus.Active)); - filter.Should().NotBe(new ClientListFilter(2, 20, "maria", CpfDigits.Create("123"), ClientStatus.Active)); - new ClientListFilter(2, 20, "maria", CpfDigits.Create("123"), null).Should() - .Be(new ClientListFilter(2, 20, "maria", CpfDigits.Create("1.2-3"), null)); + filter.Should().NotBe(new ClientListFilter(2, 20, "maria", "123", ClientStatus.Active)); } [Fact] diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs deleted file mode 100644 index a80fd674..00000000 --- a/backend/services/services-service/ServicesService.Tests/Clients/CpfDigitsTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using ServicesService.Application.Clients; - -namespace ServicesService.Tests.Clients; - -public class CpfDigitsTests -{ - [Theory] - [InlineData("529.982.247-25", "52998224725")] - [InlineData("52998224725", "52998224725")] - [InlineData(" 529 982 247 25 ", "52998224725")] - [InlineData("123", "123")] - [InlineData("456.789", "456789")] - [InlineData("0", "0")] - [InlineData("1.2-3", "123")] - public void Create_WithDigitsAndCpfMask_KeepsTheDigitsWithoutTheMask(string raw, string expected) - { - CpfDigits.Create(raw)!.Value.Should().Be(expected); - } - - [Fact] - public void Create_AcceptsAsManyDigitsAsACpfHas() - { - CpfDigits.Create("12345678901")!.Value.Should().HaveLength(CpfNumber.Length); - } - - [Fact] - public void Create_WithMoreDigitsThanACpfHas_IsNotACpfFragment() - { - CpfDigits.Create("123456789012").Should().BeNull(); - CpfDigits.Create("529.982.247-255").Should().BeNull(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData(".")] - [InlineData("-")] - [InlineData("...---")] - [InlineData(". - .")] - public void Create_WithNothingButMaskOrBlank_IsNotACpfFragment(string? raw) - { - CpfDigits.Create(raw).Should().BeNull(); - } - - [Theory] - [InlineData("maria")] - [InlineData("maria.silva@example.com")] - [InlineData("ana-lima")] - [InlineData("a1")] - [InlineData("1a")] - [InlineData("529.982.247-2x")] - [InlineData("12/34")] - [InlineData("12,5")] - public void Create_WithAnythingButDigitsAndTheMask_IsNotACpfFragment(string raw) - { - CpfDigits.Create(raw).Should().BeNull(); - } - - [Fact] - public void Create_OnlyAcceptsAsciiDigits() - { - CpfDigits.Create("١٢٣").Should().BeNull(); - CpfDigits.Create("123").Should().BeNull(); - } - - [Fact] - public void ACompleteValidCpf_IsAlsoAFragment_AndAnInvalidOneToo() - { - CpfDigits.Create("529.982.247-25").Should().NotBeNull(); - CpfDigits.Create("529.982.247-24").Should().NotBeNull(); - CpfNumber.Create("529.982.247-24").IsSuccess.Should().BeFalse(); - } - - [Fact] - public void TwoValuesWithTheSameDigits_AreEqualWhateverTheirMask() - { - CpfDigits.Create("529.982").Should().Be(CpfDigits.Create("529982")); - CpfDigits.Create("529.982").Should().NotBe(CpfDigits.Create("529983")); - } -} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index f179868f..9c96fd5f 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -64,7 +64,7 @@ private static bool FilterIs(ClientListFilter filter, string? text, string? cpfD return filter.Page == page && filter.PageSize == pageSize && filter.Text == text - && filter.CpfDigits?.Value == cpfDigits + && filter.CpfDigits == cpfDigits && filter.Status == status; } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs index ed9889a7..1cc83d0e 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs @@ -49,7 +49,7 @@ public void ToFilter_CarriesThePageTheSizeTheSearchAndTheCpfDigitsOfTheQuery() var filter = query.ToFilter(); - filter.Should().Be(new ClientListFilter(3, 50, "529.982.247-25", CpfDigits.Create("52998224725"), ClientStatus.Inactive)); + filter.Should().Be(new ClientListFilter(3, 50, "529.982.247-25", "52998224725", ClientStatus.Inactive)); filter.Page.Should().Be(3); filter.PageSize.Should().Be(50); } @@ -94,20 +94,36 @@ public void ToFilter_KeepsTheNormalizedTextForTheNameAndTheEmail(string raw, str [Theory] [InlineData("529.982.247-25", "52998224725")] + [InlineData("52998224725", "52998224725")] + [InlineData(" 529 982 247 25 ", "52998224725")] + [InlineData("529.982.247-24", "52998224724")] + [InlineData("12345678901", "12345678901")] [InlineData("456.789", "456789")] + [InlineData("1.2-3", "123")] [InlineData("123", "123")] + [InlineData("0", "0")] public void ToFilter_WithDigitsAndCpfMask_AlsoCarriesTheCpfDigitsWithoutTheMask(string raw, string expectedDigits) { - var filter = FilterOf(raw); - - filter.CpfDigits.Should().Be(CpfDigits.Create(expectedDigits)); - filter.Text.Should().Be(raw); + FilterOf(raw).CpfDigits.Should().Be(expectedDigits); } [Theory] [InlineData("maria")] + [InlineData("maria.silva@example.com")] [InlineData("ana-lima")] + [InlineData("a1")] + [InlineData("1a")] + [InlineData("529.982.247-2x")] + [InlineData("12/34")] + [InlineData("12,5")] + [InlineData(".")] + [InlineData("-")] + [InlineData("...---")] + [InlineData(". - .")] [InlineData("529982247255")] + [InlineData("529.982.247-255")] + [InlineData("١٢٣")] + [InlineData("123")] public void ToFilter_WithAnythingThatIsNotACpfFragment_DoesNotCarryTheCpfButKeepsTheText(string raw) { var filter = FilterOf(raw); diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index 7549b534..ceea1d3f 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -68,10 +68,10 @@ it as "contains": The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): the -query's `ToFilter()` derives two values: `Text`, from the `SearchText` of `query.Search`, and `CpfDigits`, a small value -object of the Application (`Clients/CpfDigits`) that holds the digits of the term without the CPF mask, from one to eleven -ASCII digits, and is absent for anything else. A digit string longer than a CPF cannot be part of one, so it simply does not -search the CPF (the name and the e-mail are still searched). The +query's `ToFilter()` derives two values: `Text`, from the `SearchText` of `query.Search`, and `CpfDigits`, a `string?` +with the digits of the term without the CPF mask, from one to eleven ASCII digits, and absent for anything else. A digit +string longer than a CPF cannot be part of one, so it simply does not search the CPF (the name and the e-mail are still +searched). The repository port takes the filter. **Order.** The normalized name, then the id, so that a page never repeats or skips a record while the data does not @@ -171,8 +171,12 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, - **`CpfNumber` as the type of the CPF digits.** It validates the check digits, so it refuses every fragment (`123`, `456.789`, even ten correct digits). Its `Restore` skips the validation, but it exists for EF to read what is stored, and using it for a fragment would break the invariant of the type. -- **A `CpfDigits` in the shared kernel.** It would share the mask rule with `CpfNumber`, but the kernel needs a second real - caller and today there is one. +- **A `CpfDigits` value object in the Application** (tried after review): a record whose `Create` held the extraction. + It had one producer (`ToFilter()`) and one reader (the repository, through `.Value`), and no invariant beyond the + extraction itself, so it added a type without adding a guarantee. Removed after review; the extraction is a private + method of the query. +- **The extraction in the shared kernel.** It would share the mask rule with `CpfNumber`, but the kernel needs a second + real caller and today there is one. - **Client roots without their contacts in the page** (the first version of this change). Two joins fewer, but the port returned a `Client` that looked whole and was not: a client from the page, inactivated and staged with `UpdateAsync`, lost every guardian and reference contact on save (a persistence test now pins the opposite). Changed after review. diff --git a/docs/adr/0064-paged-reads-page-request-and-paged-result.md b/docs/adr/0064-paged-reads-page-request-and-paged-result.md index cc0299c7..7931c5b8 100644 --- a/docs/adr/0064-paged-reads-page-request-and-paged-result.md +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -38,7 +38,7 @@ Ending the order in a unique key (`ThenBy(e => e.Id)`) stays the repository's to **The filter of a list extends it.** A paged list has one record, `ClientListFilter : PageRequest`, that carries the page and the plain filters of that list (`Text`, `CpfDigits`, `Status`): one object from the handler to the repository, where a new criterion is one more property and not one more parameter. It is a plain positional record, a -DTO with no rule of its own (the CPF digits are a `CpfDigits` value object, not a string): the query's `ToFilter()` normalizes what comes from the wire (the search text, the digits +DTO with no rule of its own: the query's `ToFilter()` normalizes what comes from the wire (the search text, the digits of the CPF, the status) and the base constructor keeps the guard. The port is `ListAsync(ClientListFilter filter, ct)` returning `PagedResult`, and the adapter hands the filter to `ListPagedAsync(filter, filterAndOrder, ct)`, which reads only the page from it. The filter holds plain values: no diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index 7e9d65d9..27546ea1 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -61,7 +61,7 @@ purpose. **`ListClientsQuery.Search` and `ListServicesQuery.Search` are `SearchText?`.** The validator has no length rule and nothing normalizes the term again: the query's `ToFilter()` reads its `Value` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). -The digits of a CPF mask stay in the clients query (`CpfDigits`), because they are a rule of the clients search and not of +The digits of a CPF mask stay in the clients query (`ToFilter()`), because they are a rule of the clients search and not of a text. ## Consequences From ee1ec6e32c8781e824653439622774d280f62428 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 19:06:14 -0300 Subject: [PATCH 21/24] refactor(services): SearchText volta a recusar texto em branco, como os demais value objects (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O texto em branco como SearchText vazio nunca chegava de uma requisição: o BlankIsAbsent já transforma um parâmetro em branco em `null` antes do Create, na query e no corpo (StringValueObjectModelBinderTests prova). A exceção ainda custava um caso especial no contrato dos value objects de string, uma mensagem de reserva no conversor JSON genérico e um ramo "vazio é sem busca" repetido em cada query de lista. - SearchText.Create(null), Create("") e Create(" ") falham com "Informe um texto para a busca."; sai o IsEmpty. - ListClientsQuery e ListServicesQuery leem `Search?.Value`; sai o TextOf. - O conversor JSON volta a pegar a mensagem de um token que não é texto em Create(null).Error, como na main. - StringValueObjectContractTests volta a cobrir todos os tipos, sem lista de exceção; saem os testes do SearchText vazio. - ARCHITECTURE §3, ADR 0065 e a skill de value objects sem a exceção; a ADR registra a versão anterior entre as rejeitadas. Co-Authored-By: Claude Opus 5.5 --- .../references/value-objects.md | 4 ++-- backend/docs/ARCHITECTURE.md | 4 +--- .../Clients/ListClients/ListClientsQuery.cs | 12 +----------- .../Services/ListServices/ListServicesQuery.cs | 12 +----------- .../ListClientsQueryHandlerTests.cs | 8 -------- .../ListClients/ListClientsQueryTests.cs | 11 ----------- .../ListServices/ListServicesQueryTests.cs | 8 -------- .../SearchTextTests.cs | 13 +++---------- .../StringValueObjectContractTests.cs | 16 +--------------- .../StringValueObjectJsonConverterTests.cs | 16 ---------------- .../SearchText.cs | 9 +++++++-- .../StringValueObjectJsonConverter.cs | 13 +------------ ...string-value-objects-in-a-query-or-route.md | 18 ++++++++---------- 13 files changed, 25 insertions(+), 119 deletions(-) diff --git a/.claude/skills/agenza-backend-slice/references/value-objects.md b/.claude/skills/agenza-backend-slice/references/value-objects.md index e57adbfe..8bb3e48e 100644 --- a/.claude/skills/agenza-backend-slice/references/value-objects.md +++ b/.claude/skills/agenza-backend-slice/references/value-objects.md @@ -28,8 +28,8 @@ Ask in order and stop at the first no: - `Create(raw)` returns a `ParseResult`: trim and normalize, then validate; one pt-BR message per rule, and a message never carries the value (it reaches the logs and the client). - `Create(null)`, `""` and whitespace must fail — the converter takes the message of a non-string - token from `Create(null)`. The one exception is a search text, whose blank is the empty search - (`SearchText`, ADR 0065); `StringValueObjectContractTests` lists the types that accept a blank. + token from `Create(null)`. A search text too: a blank search is an absent one, and `BlankIsAbsent` + already binds it to `null` before `Create` runs (ADR 0065). - `Restore(stored)` only rebuilds; no rule runs. Limits are `public const`. - `BlankIsAbsent` stays `true` (blank binds to `null`, so the member is nullable). Declare `false` only for a required member that should answer with its own message (`FullName`). diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 989c7c5a..3a13df86 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -205,9 +205,7 @@ phone, e-mail, birth date, administrative notes). They behave like a scalar on the wire: a `sealed record` implementing `IStringValueObject`, with `Value`, `Restore` and a `Create(raw)` that returns a `ParseResult` — the value, or the pt-BR message of the rule that failed; no `DomainResult`, no `DomainError`, no `Parse`. A blank string is not a value — -`Create("")` fails — and an optional member is nullable. The one exception is `SearchText`: an empty search is a search -with nothing in it, so its `Create` accepts a blank and returns the empty value -([0065](../../docs/adr/0065-string-value-objects-in-a-query-or-route.md)). What a blank means is the type's to say: +`Create("")` fails — and an optional member is nullable. What a blank means is the type's to say: `BlankIsAbsent` (default `true`) binds it to `null`, for an optional field; a type that declares it `false` (`FullName`) answers its own message instead. The kernel does the rest for every implementer: the JSON converter (`AddValueObjectConverters`), the OpenAPI `string` schema (`MapValueObjectsToStrings`) and the column diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index 5f0d1d57..c1b4e2de 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -8,23 +8,13 @@ public sealed record ListClientsQuery( { public ClientListFilter ToFilter() { - var text = TextOf(Search); + var text = Search?.Value; var cpfDigits = CpfDigitsOf(text); var status = ToClientStatus(Status); return new ClientListFilter(Page, PageSize, text, cpfDigits, status); } - private static string? TextOf(SearchText? search) - { - if (search is null || search.IsEmpty) - { - return null; - } - - return search.Value; - } - private static string? CpfDigitsOf(string? text) { if (text is null) diff --git a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs index 7f89fcb1..98c2efc5 100644 --- a/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs @@ -9,16 +9,6 @@ public sealed record ListServicesQuery( { public ServiceListFilter ToFilter() { - return new ServiceListFilter(Page, PageSize, TextOf(Search), CategoryId, TagId); - } - - private static string? TextOf(SearchText? search) - { - if (search is null || search.IsEmpty) - { - return null; - } - - return search.Value; + return new ServiceListFilter(Page, PageSize, Search?.Value, CategoryId, TagId); } } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs index 9c96fd5f..dc50ec69 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -190,14 +190,6 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str await AssertListed(search, null, ClientStatus.Active); } - [Fact] - public async Task Handle_WithAnEmptySearchText_DoesNotSearch() - { - await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(" ")), CancellationToken.None); - - await AssertListed(null, null, ClientStatus.Active); - } - [Fact] public async Task Handle_CombinesTheSearchWithTheStatusFilter() { diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs index 1cc83d0e..51352e0d 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs @@ -71,17 +71,6 @@ public void ToFilter_WithoutSearch_HasNoTextAndNoCpf() filter.CpfDigits.Should().BeNull(); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public void ToFilter_WithAnEmptySearchText_IsNoSearch(string raw) - { - var filter = FilterOf(raw); - - filter.Text.Should().BeNull(); - filter.CpfDigits.Should().BeNull(); - } - [Theory] [InlineData(" Maria SILVA ", "maria silva")] [InlineData("MARIA@Example.COM", "maria@example.com")] diff --git a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs index 3199dfab..1e1ec915 100644 --- a/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs @@ -39,14 +39,6 @@ public void ToFilter_KeepsTheNormalizedTextOfTheSearch(string raw, string expect new ListServicesQuery(Search: SearchTextOf(raw)).ToFilter().Text.Should().Be(expected); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public void ToFilter_WithAnEmptySearchText_IsNoSearch(string raw) - { - new ListServicesQuery(Search: SearchTextOf(raw)).ToFilter().Text.Should().BeNull(); - } - [Fact] public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() { diff --git a/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs index bec83204..b01a016d 100644 --- a/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/SearchTextTests.cs @@ -28,19 +28,12 @@ public void Create_KeepsTheInnerSpacingAndTheAccents() [InlineData("")] [InlineData(" ")] [InlineData("\t\n")] - public void Create_WithNoText_IsTheEmptySearch(string? raw) + public void Create_WithNoText_FailsLikeEveryOtherValueObject(string? raw) { var result = SearchText.Create(raw); - result.IsSuccess.Should().BeTrue(); - result.Value.Value.Should().BeEmpty(); - result.Value.IsEmpty.Should().BeTrue(); - } - - [Fact] - public void Create_WithText_IsNotEmpty() - { - SearchText.Create(" a ").Value.IsEmpty.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("Informe um texto para a busca."); } [Fact] diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs index 5454286e..d1f546a0 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectContractTests.cs @@ -5,12 +5,10 @@ namespace Admin.SharedKernel.Tests; public class StringValueObjectContractTests { - private static readonly Type[] AcceptBlankText = [typeof(SearchText)]; - public static TheoryData SharedValueObjects() { var data = new TheoryData(); - foreach (var type in StringValueObjects.InThisProject().Except(AcceptBlankText)) + foreach (var type in StringValueObjects.InThisProject()) { data.Add(type); } @@ -33,18 +31,6 @@ public void Create_WithNullOrBlank_FailsWithAMessage(Type valueObject) } } - [Fact] - public void SearchText_AcceptsABlankText_BecauseAnEmptySearchIsASearchWithNothingInIt() - { - foreach (var raw in new string?[] { null, "", " " }) - { - var result = SearchText.Create(raw); - - result.IsSuccess.Should().BeTrue(); - result.Value.IsEmpty.Should().BeTrue(); - } - } - [Fact] public void SearchText_DeclaresTheConverter_SoTheFrameworkReadsItAsOneValueNamedQ() { diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs index 54a1b32c..f1c7f3a5 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectJsonConverterTests.cs @@ -39,22 +39,6 @@ public static ParseResult Create(string? raw) public static ThreeLetters Restore(string value) => new(value); } - private sealed record SearchSample(SearchText? Query); - - [Fact] - public void Read_ANonStringTokenForATypeThatAcceptsBlank_StillAnswersWithAMessage() - { - var read = () => JsonSerializer.Deserialize("{ \"query\": 5 }", Options); - - read.Should().Throw().WithMessage("O valor informado deve ser um texto."); - } - - [Fact] - public void Read_ABlankStringForATypeThatAcceptsBlank_IsAbsent() - { - JsonSerializer.Deserialize("{ \"query\": \" \" }", Options)!.Query.Should().BeNull(); - } - private sealed record RequiredSample(MustBeFilled? Field); private sealed record MustBeFilled : IStringValueObject diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs index fbac4c10..adea048a 100644 --- a/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs +++ b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs @@ -7,12 +7,12 @@ public sealed record SearchText : IStringValueObject { public const int MaxLength = 254; + private const string Blank = "Informe um texto para a busca."; + private static readonly string TooLong = $"A busca deve ter no máximo {MaxLength} caracteres."; public string Value { get; } - public bool IsEmpty => Value.Length == 0; - private SearchText(string value) { Value = value; @@ -22,6 +22,11 @@ public static ParseResult Create(string? raw) { var normalized = raw?.Trim().ToLowerInvariant() ?? string.Empty; + if (normalized.Length == 0) + { + return ParseResult.Failure(Blank); + } + if (normalized.Length > MaxLength) { return ParseResult.Failure(TooLong); diff --git a/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs b/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs index b71abb41..d3d1cfc4 100644 --- a/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs +++ b/backend/shared/Admin.SharedKernel/StringValueObjectJsonConverter.cs @@ -11,7 +11,7 @@ internal sealed class StringValueObjectJsonConverter : JsonConverter { if (reader.TokenType != JsonTokenType.String) { - throw new JsonException(NotText()); + throw new JsonException(T.Create(null).Error); } var text = reader.GetString(); @@ -31,17 +31,6 @@ internal sealed class StringValueObjectJsonConverter : JsonConverter return result.IsSuccess ? result.Value : throw new JsonException(result.Error); } - private static string NotText() - { - var result = T.Create(null); - if (result.IsFailure) - { - return result.Error; - } - - return "O valor informado deve ser um texto."; - } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { writer.WriteStringValue(value.Value); diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index 27546ea1..c5555268 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -25,16 +25,10 @@ The kernel reads those types from a JSON body only. Probes with this repository' invariant culture, at most 254 characters, and its pt-BR message when it is too long. The 254 is the longest thing a list searches (an e-mail). -**A blank text is a valid `SearchText`.** Every other string value object refuses a blank in `Create`, because a blank -is not a value of a CPF or a name. An empty search is different: it is a search with nothing in it, and a user filtering -by another field sends exactly that. `Create(null)`, `Create("")` and `Create(" ")` succeed with the empty value -(`IsEmpty`). The type keeps `BlankIsAbsent` at its default, so the binder and the converter still turn a blank -parameter into `null`; the member is nullable, and an absent parameter, a blank one and an empty instance all mean "do -not filter by text". The handler reads the three the same way. - -The exception is explicit in `StringValueObjectContractTests`, which lists the types that accept a blank text. The only -generic code that assumed `Create(null)` fails, the JSON converter's answer to a token that is not a string, now falls -back to "O valor informado deve ser um texto." when `Create(null)` succeeds. +**A blank search is an absent one.** `SearchText` follows the contract of every string value object: `Create(null)`, +`Create("")` and `Create(" ")` fail ("Informe um texto para a busca."). The type keeps `BlankIsAbsent` at its default, +so the binder and the JSON converter turn a blank parameter into `null` before `Create` runs; the member is nullable, +and an absent parameter and a blank one both mean "do not filter by text". The query reads `Search?.Value`. **The opt-in is a type converter.** `StringValueObjectTypeConverter`, in the same project and built only on the base class library, converts a `string` through `Create`. A type declares @@ -96,5 +90,9 @@ including a term of 255 characters (`400`, the message of the value object, the personal data included. - **A client-specific term type carrying the text and the CPF digits.** A request carries the shared string value objects and nothing else of the domain, and the CPF digits are a rule of one search. +- **A blank text as a valid, empty `SearchText`** (an earlier version of this change, with `IsEmpty`). No request ever + built one: `BlankIsAbsent` binds a blank to `null` before `Create` runs, from a query and from a body alike. It still + cost an exception to the contract of every string value object, a fallback message in the generic JSON converter, and a + branch that turned the empty value into "no search" in every list query. Removed after review. - **`search` as a plain string normalized in the handler** (the first version of this change). It works, but the rule sits in the use case and in a validator rule instead of in the type, which is how every other shared text is carried. From 5b06a6726ef135ba5010a047dbc54804b5c42b6b Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 19:07:26 -0300 Subject: [PATCH 22/24] =?UTF-8?q?refactor(services):=20a=20busca=20de=20pe?= =?UTF-8?q?ssoas=20tira=20a=20m=C3=A1scara=20do=20CPF=20com=20o=20CpfNumbe?= =?UTF-8?q?r.StripMask=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ListClientsQuery repetia, caractere por caractere, o StripMask privado do CpfNumber. Uma máscara aceita no cadastro e não na busca (ou o contrário) gravaria um CPF que a busca não encontra pelo mesmo texto. - CpfNumber.StripMask fica público, com corpo em bloco, e ganha testes. - ListClientsQuery.CpfDigitsOf chama o StripMask e mantém só a regra própria da busca: de um a onze dígitos ASCII. - ADR 0063 e ADR 0065 descrevem a máscara compartilhada; a cópia da regra entra entre as alternativas rejeitadas, no lugar de "a extração no kernel". Co-Authored-By: Claude Opus 5.5 --- .../Clients/ListClients/ListClientsQuery.cs | 2 +- .../shared/Admin.SharedKernel.Tests/CpfNumberTests.cs | 11 +++++++++++ .../Admin.SharedKernel.ValueObjects/CpfNumber.cs | 6 ++++-- docs/adr/0063-clients-list-and-search-contract.md | 10 +++++++--- .../0065-string-value-objects-in-a-query-or-route.md | 4 ++-- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs index c1b4e2de..12f6cc44 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -22,7 +22,7 @@ public ClientListFilter ToFilter() return null; } - var digits = new string(text.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + var digits = CpfNumber.StripMask(text); if (digits.Length == 0 || digits.Length > CpfNumber.Length || !digits.All(char.IsAsciiDigit)) { return null; diff --git a/backend/shared/Admin.SharedKernel.Tests/CpfNumberTests.cs b/backend/shared/Admin.SharedKernel.Tests/CpfNumberTests.cs index c0d59509..047f432f 100644 --- a/backend/shared/Admin.SharedKernel.Tests/CpfNumberTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/CpfNumberTests.cs @@ -55,4 +55,15 @@ public void Restore_AcceptsACpfThatCreateWouldReject() { CpfNumber.Restore("00000000000").Value.Should().Be("00000000000"); } + + [Theory] + [InlineData("529.982.247-25", "52998224725")] + [InlineData(" 529 982 247 25 ", "52998224725")] + [InlineData("982.2", "9822")] + [InlineData("529.98a", "52998a")] + [InlineData("", "")] + public void StripMask_RemovesOnlyTheMaskAndKeepsAnythingElseForTheCallerToJudge(string raw, string expected) + { + CpfNumber.StripMask(raw).Should().Be(expected); + } } diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/CpfNumber.cs b/backend/shared/Admin.SharedKernel.ValueObjects/CpfNumber.cs index a0648856..b6e8e016 100644 --- a/backend/shared/Admin.SharedKernel.ValueObjects/CpfNumber.cs +++ b/backend/shared/Admin.SharedKernel.ValueObjects/CpfNumber.cs @@ -30,8 +30,10 @@ public static CpfNumber Restore(string value) return new CpfNumber(value); } - private static string StripMask(string value) => - new(value.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + public static string StripMask(string value) + { + return new string(value.Where(character => character is not ('.' or '-') && !char.IsWhiteSpace(character)).ToArray()); + } private static bool HasValidCheckDigits(string digits) { diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index ceea1d3f..ce40112d 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -69,7 +69,9 @@ it as "contains": The three matches are joined by "or" and combined with the situation by "and". A person with no e-mail and no CPF is found by name. The search is part of the list's filter, `ClientListFilter` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)): the query's `ToFilter()` derives two values: `Text`, from the `SearchText` of `query.Search`, and `CpfDigits`, a `string?` -with the digits of the term without the CPF mask, from one to eleven ASCII digits, and absent for anything else. A digit +with the digits of the term without the CPF mask, from one to eleven ASCII digits, and absent for anything else. The mask +is removed by `CpfNumber.StripMask`, the same method that `CpfNumber.Create` uses, so the search and the registration accept +the same mask; the rule of one to eleven digits is the query's own. A digit string longer than a CPF cannot be part of one, so it simply does not search the CPF (the name and the e-mail are still searched). The repository port takes the filter. @@ -175,8 +177,10 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, It had one producer (`ToFilter()`) and one reader (the repository, through `.Value`), and no invariant beyond the extraction itself, so it added a type without adding a guarantee. Removed after review; the extraction is a private method of the query. -- **The extraction in the shared kernel.** It would share the mask rule with `CpfNumber`, but the kernel needs a second - real caller and today there is one. +- **A copy of the mask rule in the query** (the version before this one). The query repeated, character for character, + the private `StripMask` of `CpfNumber`, on the argument that the kernel needs a second real caller. `CpfNumber.Create` is + the first one and the query the second, and a mask accepted by one and not by the other would register a CPF that the + search cannot find from the same text. `StripMask` became public; only the mask is shared, not the fragment rule. - **Client roots without their contacts in the page** (the first version of this change). Two joins fewer, but the port returned a `Client` that looked whole and was not: a client from the page, inactivated and staged with `UpdateAsync`, lost every guardian and reference contact on save (a persistence test now pins the opposite). Changed after review. diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index c5555268..e89e0a0b 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -55,8 +55,8 @@ purpose. **`ListClientsQuery.Search` and `ListServicesQuery.Search` are `SearchText?`.** The validator has no length rule and nothing normalizes the term again: the query's `ToFilter()` reads its `Value` ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). -The digits of a CPF mask stay in the clients query (`ToFilter()`), because they are a rule of the clients search and not of -a text. +The digits of a CPF stay in the clients query (`ToFilter()`), because they are a rule of the clients search and not of +a text; the query removes the mask with `CpfNumber.StripMask` ([ADR 0063](0063-clients-list-and-search-contract.md)). ## Consequences From ac8ce7d94bf80b4a0f4d6b727e48174975672a43 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 19:15:03 -0300 Subject: [PATCH 23/24] =?UTF-8?q?feat(shared):=20o=20kernel=20l=C3=AA=20um?= =?UTF-8?q?=20enum=20da=20query=20ou=20da=20rota=20por=20um=20=C3=BAnico?= =?UTF-8?q?=20nome=20(#155,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O binder do framework lê o enum da query com o EnumConverter: aceita o número de um membro (?status=3) e junta nomes separados por vírgula com OR. Com valores 1, 2 e 3, `active,inactive` virava `all` em silêncio e passava no IsInEnum. A lista de pessoas escapava disso com valores em bits (1, 2, 4), uma convenção que o próximo filtro teria de lembrar. - EnumNameModelBinderProvider no Admin.SharedKernel.AspNetCore, registrado pelo AddWireJson() logo depois do binder de value object e antes dos do framework: todo enum, anulável ou não, de fora do corpo, aceita exatamente um nome, em qualquer caixa; número, nomes juntos ou nome desconhecido respondem 400 Validation.Failed sob o parâmetro com "O valor informado deve ser active, inactive ou all.", sem ecoar o valor. O corpo continua com os conversores JSON. - ClientStatusFilter volta a 1, 2 e 3; o ListClientsQueryValidator perde o IsInEnum com o código literal Client.StatusFilterInvalid. - Testes do provider, do binder e do pipeline do MVC (ParameterBinder com o AddWireJson), incluindo `3` e `active,inactive` num enum 1, 2, 3. - ADR 0066; ADR 0063, ARCHITECTURE §4 e §6, docs/API.md §4.3, a skill de caso de uso e o índice de ADRs atualizados. O documento OpenAPI, servido numa porta privada sem banco, gera tipos do frontend idênticos aos commitados. Co-Authored-By: Claude Opus 5.5 --- .../references/use-case.md | 7 +- backend/docs/ARCHITECTURE.md | 16 +- .../Clients/ListClients/ClientStatusFilter.cs | 2 +- .../ListClients/ListClientsQueryValidator.cs | 5 - .../ListClientsQueryValidatorTests.cs | 11 +- .../EnumNameModelBinder.cs | 52 ++++ .../EnumNameModelBinderProvider.cs | 24 ++ .../MvcBuilderExtensions.cs | 6 +- .../EnumNameModelBinderTests.cs | 280 ++++++++++++++++++ docs/API.md | 6 +- .../0063-clients-list-and-search-contract.md | 24 +- ...ead-from-a-query-or-a-route-by-one-name.md | 73 +++++ docs/adr/README.md | 7 +- 13 files changed, 471 insertions(+), 42 deletions(-) create mode 100644 backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinder.cs create mode 100644 backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinderProvider.cs create mode 100644 backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs create mode 100644 docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 0488dfeb..f4ab1946 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -12,9 +12,10 @@ messages", plus the earlier slices for reads, edits and paging. This file is how `IReadOnlyList<Input>?`, with the `Input` records in the same file. No domain type — except a shared string value object such as `CpfNumber?`, which binds from the JSON string and needs no validator rule (ADR 0055) — no tenant. An enum is allowed: the kernel's `AddWireJson()` already names - every enum in camelCase and refuses integers, in the MVC and the minimal-API JSON options, so there is - nothing to register and no `Names` class. A list of enums gets an `IsInEnum` rule per item with - the domain's code (ADR 0058). + every enum in camelCase and refuses integers, in the MVC and the minimal-API JSON options, and reads one from + a query or a route by exactly one name (ADR 0066), so there is nothing to register, no `Names` class + and no values chosen to dodge the parser. A list of enums in a body gets an `IsInEnum` rule per item with + the domain's code (ADR 0058); a query enum needs no rule. - A route id is a member (`Id`) that the controller fills with `with { … }`. - A list query extends `PagedQuery` (`Page`/`PageSize`, defaults from `PageRequest`) and carries its filters as optional members, a free-text search as a `SearchText?`; its own `ToFilter()` method builds the diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 3a13df86..0292b655 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -282,9 +282,11 @@ and transactional cleanup. A `try/catch` in a handler is a finding. **What dies in gate 1** — malformed JSON, a non-nullable member absent from the body, a value of the wrong JSON type, an unknown JSON enum — reaches the client in the canonical shape (`AddModelStateProblemDetails`), but with the framework's English message and the generic code -`Validation.Failed`; only a shared value object brings its own pt-BR message. An enum is bound by name -(§6), so an unknown name dies here too; the converter can also yield a value that is not a member -(several names in one string), which the validator refuses with `IsInEnum`. A failed body read also +`Validation.Failed`; only a shared value object and an enum read from a query or a route bring their own pt-BR +message. An enum is bound by name (§6), so an unknown name dies here too. In a body, the JSON converter can also yield a +value that is not a member (several names in one string), which the validator refuses with `IsInEnum`; from a query or +a route the kernel's binder accepts exactly one name, so a number or several names die here +([0066](../../docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md)). A failed body read also carries the framework's entry for the body parameter (`command`), which the backend does not remove. The shape is described in [`docs/API.md`](../../docs/API.md) §4.3. @@ -386,9 +388,11 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult `[RequestSizeLimit]`. - Enums travel as camelCase strings: a plain enum, named by the kernel for every enum with integers refused (`AddEnumNameConverter`, registered by `AddWireJson()` in the MVC options and in the - minimal-API options, which the OpenAPI generator reads). Nothing to register per enum and no - translation class. Each item of a list of enums has an `IsInEnum` rule with the domain's code - ([0058](../../docs/adr/0058-closed-set-members-are-plain-enums.md)). + minimal-API options, which the OpenAPI generator reads). From a query or a route, the same call's binder reads + exactly one name in any case and refuses a number or several names, with a pt-BR message that lists the names + ([0066](../../docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md)). Nothing to register per enum, no + translation class and no values chosen to dodge the parser. Each item of a list of enums in a body has an `IsInEnum` + rule with the domain's code ([0058](../../docs/adr/0058-closed-set-members-are-plain-enums.md)). - The wire is camelCase English; calendar dates are `DateOnly` (`yyyy-MM-dd`); instants are UTC. - The query of a paged list extends `PagedQuery` (`Page` and `PageSize`, with the `PageRequest` defaults, unguarded) and its validator writes the three rules diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs index 9f819db3..0cf4cab4 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientStatusFilter.cs @@ -4,5 +4,5 @@ public enum ClientStatusFilter { Active = 1, Inactive = 2, - All = 4, + All = 3, } diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs index 53f86be7..3b9b5b78 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -20,10 +20,5 @@ public ListClientsQueryValidator() .InclusiveBetween(1, PageRequest.MaxPageSize) .WithErrorCode(PageRequest.PageSizeInvalidCode) .WithMessage($"O tamanho da página deve ser entre 1 e {PageRequest.MaxPageSize}."); - - RuleFor(query => query.Status) - .IsInEnum() - .WithErrorCode("Client.StatusFilterInvalid") - .WithMessage("A situação deve ser active, inactive ou all."); } } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs index 737fde8f..31acb49b 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -16,11 +16,6 @@ public static TheoryData EveryRule() { "page just past the offset limit", new ListClientsQuery { Page = 21_474_838, PageSize = 100 }, "Page", "Paging.PageTooLarge" }, { "page size below one", new ListClientsQuery { PageSize = 0 }, "PageSize", "Paging.PageSizeInvalid" }, { "page size above the maximum", new ListClientsQuery { PageSize = 101 }, "PageSize", "Paging.PageSizeInvalid" }, - { "a status past the last member", new ListClientsQuery(Status: (ClientStatusFilter)7), "Status", "Client.StatusFilterInvalid" }, - { "a negative status", new ListClientsQuery(Status: (ClientStatusFilter)(-1)), "Status", "Client.StatusFilterInvalid" }, - { "zero, which is no member", new ListClientsQuery(Status: default(ClientStatusFilter)), "Status", "Client.StatusFilterInvalid" }, - { "two names joined by the parser", new ListClientsQuery(Status: ClientStatusFilter.Active | ClientStatusFilter.Inactive), "Status", "Client.StatusFilterInvalid" }, - { "every name joined by the parser", new ListClientsQuery(Status: ClientStatusFilter.Active | ClientStatusFilter.Inactive | ClientStatusFilter.All), "Status", "Client.StatusFilterInvalid" }, }; } @@ -78,26 +73,24 @@ public void Validate_WithAPageBelowOne_ReportsOnlyThePageRule() [Fact] public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() { - var result = _validator.Validate(new ListClientsQuery(Status: (ClientStatusFilter)9) { Page = 0, PageSize = 0 }); + var result = _validator.Validate(new ListClientsQuery { Page = 0, PageSize = 0 }); result.Errors.Select(error => (error.PropertyName, error.ErrorCode)).Should().BeEquivalentTo( [ ("Page", "Paging.PageInvalid"), ("PageSize", "Paging.PageSizeInvalid"), - ("Status", "Client.StatusFilterInvalid"), ]); } [Fact] public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() { - var result = _validator.Validate(new ListClientsQuery(Status: (ClientStatusFilter)9) { Page = 0, PageSize = 0 }); + var result = _validator.Validate(new ListClientsQuery { Page = 0, PageSize = 0 }); result.Errors.Select(error => error.ErrorMessage).Should().BeEquivalentTo( [ "A página deve ser maior ou igual a 1.", "O tamanho da página deve ser entre 1 e 100.", - "A situação deve ser active, inactive ou all.", ]); } } diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinder.cs b/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinder.cs new file mode 100644 index 00000000..573c781c --- /dev/null +++ b/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinder.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Text.Json; + +namespace Admin.SharedKernel.AspNetCore; + +internal sealed class EnumNameModelBinder : IModelBinder + where TEnum : struct, Enum +{ + private static readonly string[] Names = Enum.GetNames(); + + private static readonly string Invalid = $"O valor informado deve ser {Choices()}."; + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + return Task.CompletedTask; + } + + bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); + + var raw = valueProviderResult.FirstValue?.Trim(); + if (string.IsNullOrEmpty(raw) && bindingContext.ModelMetadata.IsReferenceOrNullableType) + { + bindingContext.Result = ModelBindingResult.Success(null); + return Task.CompletedTask; + } + + var name = Array.Find(Names, candidate => string.Equals(candidate, raw, StringComparison.OrdinalIgnoreCase)); + if (name is null) + { + bindingContext.ModelState.TryAddModelError(modelName, Invalid); + return Task.CompletedTask; + } + + bindingContext.Result = ModelBindingResult.Success(Enum.Parse(name)); + return Task.CompletedTask; + } + + private static string Choices() + { + var names = Names.Select(JsonNamingPolicy.CamelCase.ConvertName).ToArray(); + if (names.Length < 2) + { + return string.Concat(names); + } + + return $"{string.Join(", ", names[..^1])} ou {names[^1]}"; + } +} diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinderProvider.cs b/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinderProvider.cs new file mode 100644 index 00000000..66110fe7 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.AspNetCore/EnumNameModelBinderProvider.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Admin.SharedKernel.AspNetCore; + +internal sealed class EnumNameModelBinderProvider : IModelBinderProvider +{ + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + if (context.BindingInfo.BindingSource == BindingSource.Body) + { + return null; + } + + var enumType = context.Metadata.UnderlyingOrModelType; + if (!enumType.IsEnum) + { + return null; + } + + var binderType = typeof(EnumNameModelBinder<>).MakeGenericType(enumType); + + return (IModelBinder)Activator.CreateInstance(binderType)!; + } +} diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs index 8804f6a1..dce0302e 100644 --- a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs +++ b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs @@ -16,7 +16,11 @@ public static IMvcBuilder AddWireJson(this IMvcBuilder builder) builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddEnumNameConverter()); - builder.AddMvcOptions(options => options.ModelBinderProviders.Insert(0, new StringValueObjectModelBinderProvider())); + builder.AddMvcOptions(options => + { + options.ModelBinderProviders.Insert(0, new StringValueObjectModelBinderProvider()); + options.ModelBinderProviders.Insert(1, new EnumNameModelBinderProvider()); + }); return builder; } diff --git a/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs b/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs new file mode 100644 index 00000000..548c3d41 --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs @@ -0,0 +1,280 @@ +using Admin.SharedKernel.AspNetCore; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using System.Globalization; + +namespace Admin.SharedKernel.Tests; + +public class EnumNameModelBinderTests +{ + public enum Situation + { + Active = 1, + Inactive = 2, + All = 3, + } + + public enum Stage + { + NotStarted = 1, + InProgress = 2, + } + + public sealed record SituationQuery(Situation? Status = Situation.Active, Stage Phase = Stage.NotStarted); + + private const string InvalidSituation = "O valor informado deve ser active, inactive ou all."; + + private static readonly EmptyModelMetadataProvider MetadataProvider = new(); + + private static IList Providers() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddControllers().AddWireJson(); + + return services.BuildServiceProvider().GetRequiredService>().Value.ModelBinderProviders; + } + + private static IModelBinder? BinderFor(Type type, BindingSource? source) + { + var context = new ProviderContext(MetadataProvider.GetMetadataForType(type), new BindingInfo { BindingSource = source }); + + return Providers()[1].GetBinder(context); + } + + private static async Task Bind(string? value, bool present = true) + { + var binder = BinderFor(typeof(T), BindingSource.Query)!; + var values = present + ? new Dictionary { ["status"] = value } + : new Dictionary(); + var valueProvider = new QueryStringValueProvider(BindingSource.Query, new QueryCollection(values), CultureInfo.InvariantCulture); + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var bindingContext = DefaultModelBindingContext.CreateBindingContext( + actionContext, + valueProvider, + MetadataProvider.GetMetadataForType(typeof(T)), + new BindingInfo { BindingSource = BindingSource.Query }, + "status"); + + await binder.BindModelAsync(bindingContext); + + return bindingContext; + } + + private static async Task<(SituationQuery? Model, ModelStateDictionary State)> BindQuery(Dictionary values) + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddLogging(); + services.AddControllers().AddWireJson(); + var provider = services.BuildServiceProvider(); + + var metadata = provider.GetRequiredService().GetMetadataForType(typeof(SituationQuery)); + var parameter = new ParameterDescriptor + { + Name = "query", + ParameterType = typeof(SituationQuery), + BindingInfo = new BindingInfo { BindingSource = BindingSource.Query }, + }; + var binder = provider.GetRequiredService().CreateBinder( + new ModelBinderFactoryContext { Metadata = metadata, BindingInfo = parameter.BindingInfo, CacheToken = parameter }); + var actionContext = new ActionContext( + new DefaultHttpContext { RequestServices = provider }, + new RouteData(), + new ActionDescriptor()); + var valueProvider = new QueryStringValueProvider(BindingSource.Query, new QueryCollection(new Dictionary(values, StringComparer.OrdinalIgnoreCase)), CultureInfo.InvariantCulture); + + var result = await provider.GetRequiredService() + .BindModelAsync(actionContext, binder, valueProvider, parameter, metadata, value: null); + + return (result.Model as SituationQuery, actionContext.ModelState); + } + + [Fact] + public void AddWireJson_PutsTheEnumBinderBeforeTheFrameworkOnes() + { + var names = Providers().Select(provider => provider.GetType().Name).ToList(); + + names[1].Should().Be("EnumNameModelBinderProvider"); + names.IndexOf("EnumTypeModelBinderProvider").Should().BeGreaterThan(1); + names.IndexOf("SimpleTypeModelBinderProvider").Should().BeGreaterThan(1); + } + + [Theory] + [InlineData(typeof(Situation))] + [InlineData(typeof(Situation?))] + public void Provider_BindsAnEnumFromTheQueryTheRouteOrAProperty(Type enumType) + { + BinderFor(enumType, BindingSource.Query).Should().NotBeNull(); + BinderFor(enumType, BindingSource.Path).Should().NotBeNull(); + BinderFor(enumType, null).Should().NotBeNull(); + } + + [Fact] + public void Provider_LeavesABodyParameterToTheJsonConverters() + { + BinderFor(typeof(Situation), BindingSource.Body).Should().BeNull(); + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(Guid))] + [InlineData(typeof(SearchText))] + public void Provider_LeavesEveryOtherTypeToTheOtherBinders(Type type) + { + BinderFor(type, BindingSource.Query).Should().BeNull(); + } + + [Theory] + [InlineData("active", Situation.Active)] + [InlineData("ACTIVE", Situation.Active)] + [InlineData("Inactive", Situation.Inactive)] + [InlineData(" all ", Situation.All)] + public async Task Binder_BindsOneNameInAnyCase(string raw, Situation expected) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeTrue(); + context.Result.Model.Should().Be(expected); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Theory] + [InlineData("inProgress")] + [InlineData("InProgress")] + [InlineData("INPROGRESS")] + public async Task Binder_BindsTheCamelCaseNameOfTheWireAndThePascalCaseNameOfTheCode(string raw) + { + var context = await Bind(raw); + + context.Result.Model.Should().Be(Stage.InProgress); + } + + [Theory] + [InlineData("1")] + [InlineData("3")] + [InlineData("0")] + [InlineData("-1")] + public async Task Binder_RefusesANumber_EvenOneOfAMember(string raw) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState["status"]!.Errors.Should().ContainSingle().Which.ErrorMessage.Should().Be(InvalidSituation); + } + + [Theory] + [InlineData("active,inactive")] + [InlineData("active, inactive")] + [InlineData("inactive,all")] + public async Task Binder_RefusesSeveralNames_EvenWhenTheFrameworkWouldJoinThemIntoAMember(string raw) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState["status"]!.Errors.Should().ContainSingle().Which.ErrorMessage.Should().Be(InvalidSituation); + } + + [Fact] + public async Task Binder_RefusesAnUnknownName_WithoutEchoingIt() + { + var context = await Bind("secret-value"); + + var message = context.ModelState["status"]!.Errors.Single().ErrorMessage; + + message.Should().Be(InvalidSituation); + message.Should().NotContain("secret"); + } + + [Fact] + public async Task Binder_NamesTheChoicesOfAnEnumWithTwoMembers() + { + var context = await Bind("done"); + + context.ModelState["status"]!.Errors.Single().ErrorMessage + .Should().Be("O valor informado deve ser notStarted ou inProgress."); + } + + [Fact] + public async Task Binder_WithoutTheParameter_BindsNothing() + { + var context = await Bind(null, present: false); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Binder_WithABlankValueForANullableEnum_BindsNull(string raw) + { + var context = await Bind(raw); + + context.Result.IsModelSet.Should().BeTrue(); + context.Result.Model.Should().BeNull(); + context.ModelState.ErrorCount.Should().Be(0); + } + + [Fact] + public async Task Binder_WithABlankValueForAnEnumThatIsNotNullable_Fails() + { + var context = await Bind(" "); + + context.Result.IsModelSet.Should().BeFalse(); + context.ModelState["status"]!.Errors.Should().ContainSingle().Which.ErrorMessage.Should().Be(InvalidSituation); + } + + [Fact] + public async Task TheMvcBinder_ReadsTheEnumsOfAQueryByName() + { + var (model, state) = await BindQuery(new Dictionary { ["status"] = "ALL", ["phase"] = "inProgress" }); + + state.ErrorCount.Should().Be(0); + model!.Status.Should().Be(Situation.All); + model.Phase.Should().Be(Stage.InProgress); + } + + [Fact] + public async Task TheMvcBinder_KeepsTheDefaultsWhenTheEnumsAreNotInTheQueryString() + { + var (model, state) = await BindQuery(new Dictionary()); + + state.ErrorCount.Should().Be(0); + model!.Status.Should().Be(Situation.Active); + model.Phase.Should().Be(Stage.NotStarted); + } + + [Theory] + [InlineData("active,inactive")] + [InlineData("3")] + public async Task TheMvcBinder_AnswersTheCanonicalShapeUnderTheParameter(string raw) + { + var (_, state) = await BindQuery(new Dictionary { ["status"] = raw }); + + var error = ModelStateErrorMapper.ToError(state); + + error.FieldErrors!.Should().ContainKey("Status") + .WhoseValue.Should().ContainSingle() + .Which.Should().Be(new FieldError("Validation.Failed", InvalidSituation)); + } + + private sealed class ProviderContext(ModelMetadata metadata, BindingInfo bindingInfo) : ModelBinderProviderContext + { + public override BindingInfo BindingInfo => bindingInfo; + + public override ModelMetadata Metadata => metadata; + + public override IModelMetadataProvider MetadataProvider => new EmptyModelMetadataProvider(); + + public override IModelBinder CreateBinder(ModelMetadata metadata) => throw new NotSupportedException(); + } +} diff --git a/docs/API.md b/docs/API.md index 67c90751..88382aee 100644 --- a/docs/API.md +++ b/docs/API.md @@ -198,7 +198,9 @@ que muda é o conteúdo: - a `message` é o texto do framework, **em inglês** ("The FullName field is required.", "A non-empty request body is required."), exceto a de um value object compartilhado, que vem em pt-BR ("O CPF informado é inválido.", "O nome completo é obrigatório.", "Informe um e-mail válido.", "A data de nascimento deve estar no passado.") e nunca ecoa o valor - recebido. Um valor de tipo errado traz o texto do + recebido, e a de um enum lido da query ou da rota, que aceita um único nome em qualquer caixa e recusa número + ou nomes juntos com "O valor informado deve ser active, inactive ou all." (os nomes do enum, [ADR 0066](adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md)), + também em pt-BR e sem ecoar o valor. Um valor de tipo errado traz o texto do `System.Text.Json` — nome interno do tipo e posição do parser, não texto para o usuário; - só a **primeira** falha de valor é reportada: o `System.Text.Json` para no primeiro valor inválido, então dois CPFs inválidos, ou um nome vazio e um CPF inválido, no mesmo corpo geram uma única entrada, a @@ -275,7 +277,7 @@ descartável (2026-10-02; chaves de `errors` revistas em 2026-10-04), com os dad | Campos de contato inválidos | `POST /api/v1/clients` | 400 | `Validation.Failed` (`guardians[0].name`: `ClientContact.NameRequired`, `referenceContacts[0].purposes`: `ClientReferenceContact.PurposesRequired`…) | | CPF inválido, em qualquer formatação (§4.3) | `POST /api/v1/clients` (`cpf` ou `guardians[0].cpf`) | 400 | `Validation.Failed` (`cpf`: `Validation.Failed`, "O CPF informado é inválido.") | | `fullName` ausente do JSON | `POST /api/v1/clients` | 400 (§4.3: mensagem do framework, em inglês) | `Validation.Failed` (`fullName`: `Validation.Failed`) | -| Filtro de situação desconhecido (§4.3) | `GET /api/v1/clients?status=deleted` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`status`: `Validation.Failed`, "The value 'deleted' is not valid.") | +| Filtro de situação desconhecido, numérico ou com nomes juntos (§4.3, [ADR 0066](adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md)) | `GET /api/v1/clients?status=deleted`, `?status=3`, `?status=active,inactive` | 400 (mensagem do binder do kernel, em pt-BR) | `Validation.Failed` (`status`: `Validation.Failed`, "O valor informado deve ser active, inactive ou all.") | | Página abaixo de 1, tamanho fora de 1–100 | `GET /api/v1/clients?page=0` | 400 | `Validation.Failed` (`page`: `Paging.PageInvalid`; `pageSize`: `Paging.PageSizeInvalid`) | | Página cujo deslocamento não cabe em `int` | `GET /api/v1/clients?page=21474838&pageSize=100` | 400 | `Validation.Failed` (`page`: `Paging.PageTooLarge`) | | Parâmetro de query que não é número (§4.3) | `GET /api/v1/clients?page=abc` | 400 (mensagem do framework, em inglês) | `Validation.Failed` (`page`: `Validation.Failed`) | diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md index ce40112d..8c40f9da 100644 --- a/docs/adr/0063-clients-list-and-search-contract.md +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -44,19 +44,16 @@ owner on 2026-10-12, taken against the text of #155 ("Mascarar CPF na lista") an has no roles, every authenticated user of the tenant sees the whole CPF in the detail, and the list adds no new kind of reader. What changes is volume: up to 100 whole CPFs per response. -**Situation.** `status` is a plain enum, `ClientStatusFilter { Active = 1, Inactive = 2, All = 4 }`, named in +**Situation.** `status` is a plain enum, `ClientStatusFilter { Active = 1, Inactive = 2, All = 3 }`, named in camelCase on the wire ([ADR 0058](0058-closed-set-members-are-plain-enums.md)): `active` (default), `inactive` or `all`, in any case. Omitted or blank is `active`. `all` is "no restriction", and soft-deleted people never appear, whatever the value. `ClientStatus` cannot be the type: it has no `All` and it is persisted. The generated contract -publishes `'active' | 'inactive' | 'all'`. The values are bits on purpose: the framework's parser joins names -separated by a comma with an OR, so with `0, 1, 2` the text `active,inactive` would become `inactive`; with `1, 2, 4` -every join is a value no member has, and the binder refuses it. +publishes `'active' | 'inactive' | 'all'`. -The binder, not the validator, answers a bad value: an unknown name, `0`, a number past the members or a joined list -fails at binding (gate 1) with `400 Validation.Failed` under `status` and the framework's English text, like -`page=abc`. The validator keeps an `IsInEnum` rule with `Client.StatusFilterInvalid` for a value that reaches it -anyway. A number that is a member is accepted by the binder (`status=4` is `all`): a known limit, left as it is, -because refusing it needs a binder of its own. +The binder, not the validator, answers a bad value. The kernel reads an enum from a query by exactly one name +([ADR 0066](0066-enums-read-from-a-query-or-a-route-by-one-name.md)), so an unknown name, a number (`status=3`, even +the number of a member) or a joined list (`active,inactive`) fails at binding (gate 1) with `400 Validation.Failed` under +`status` and the message "O valor informado deve ser active, inactive ou all.". The validator has no rule for `status`. **Search.** `search` is a `SearchText` ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)): the framework reads it from the query string trimmed and lowercased, and a blank one is absent, which means no search. The handler matches @@ -105,9 +102,8 @@ client load now reads three more short strings, because EF loads every mapped co | `page` at least 1 | `Paging.PageInvalid` | | `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | | `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | -| `status` a member of `ClientStatusFilter`, or absent | `Client.StatusFilterInvalid` | -`search` has no validator rule: `SearchText` refuses a term past 254 characters, the longest searchable value (an e-mail), at +`status` has no validator rule: the binder reads one member or refuses the value (above). `search` has no validator rule: `SearchText` refuses a term past 254 characters, the longest searchable value (an e-mail), at binding (gate 1), as `400 Validation.Failed` under `search` with its own pt-BR message. The three page rules are shared by every paged list ([ADR 0064](0064-paged-reads-page-request-and-paged-result.md)). @@ -162,7 +158,11 @@ the generated columns present: they follow a rename, a new CPF and a new e-mail, - **`status` as a string checked by the validator** (the first version of this change). A pt-BR message with a code of its own, but three constants, a list of known words and string comparisons in the handler, a contract that said `string`, and a rule that ADR 0058 already settles for every enum. Changed after review. -- **Enum values `0, 1, 2`.** A comma-joined list collides with a member (`active,inactive` becomes `inactive`). +- **Enum values chosen as bits (`1, 2, 4`) and an `IsInEnum` rule with `Client.StatusFilterInvalid`** (the version + before this one). The framework's parser joins comma-separated names with an OR, so the values had to keep every join + off the members (with `1, 2, 3`, `active,inactive` became `all`), a convention each filter would have to remember, and + a number that is a member (`status=4`) was still accepted. The kernel's binder replaced it + ([ADR 0066](0066-enums-read-from-a-query-or-a-route-by-one-name.md)). - **Ordering or matching by an expression over the value-object column**, and a **string alias property on the same column.** Neither translates (see Context). - **Exact match only for the CPF or the e-mail, or a prefix match.** Cheaper (fewer or no extra columns) but the diff --git a/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md b/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md new file mode 100644 index 00000000..1dd95cbf --- /dev/null +++ b/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md @@ -0,0 +1,73 @@ +# ADR 0066 — An enum is read from a query or a route by one name + +Status: accepted (2026-10); extends [ADR 0058](0058-closed-set-members-are-plain-enums.md) (enums bound by name, integers +refused) from a JSON body to a query and a route; replaces the bit values of `ClientStatusFilter` in +[ADR 0063](0063-clients-list-and-search-contract.md) + +## Context + +ADR 0058 names every enum in camelCase on the wire and refuses integers, but only in a JSON body. An enum read from a +query or a route went through the framework's binder, which parses with `EnumConverter`: + +- it accepts the number of a member (`?status=3`), which the body refuses; +- it joins names separated by a comma with an OR, even for an enum that is not `[Flags]`, and accepts the join when the + result is the value of a member. A probe on this repository's runtime: with `Active = 1, Inactive = 2, All = 3`, + `active,inactive` becomes `All` and `inactive,all` becomes `All`, both members, so neither the binder nor `IsInEnum` sees + anything wrong; +- an unknown name answers in English and echoes the value ("The value 'deleted' is not valid."). + +The clients list ([ADR 0063](0063-clients-list-and-search-contract.md)) dodged the join with values chosen as bits +(`1, 2, 4`), so that every join is a value no member has. It worked for that enum, but it is a convention the next filter +would have to know, nothing pins it, and numbers stayed accepted. + +## Decision + +**`EnumNameModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore`, registered by `AddWireJson()` right after the +binder of string value objects ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)) and before every binder of +the framework, so no service has to remember it. It gives every enum, nullable or not, read from anything but the body +(a query, a route, a header, a form field) a binder that: + +- binds nothing when the parameter is absent, so the member keeps its default; +- binds `null` for a blank value of a nullable enum, and refuses a blank one for an enum that is not nullable; +- accepts exactly one name of a member, trimmed and in any case: the camelCase name of the wire and the PascalCase name of + the code are the same text ignoring case; +- refuses anything else — a number, several names, an unknown name — with a model error under the parameter, "O valor + informado deve ser active, inactive ou all.", built from the camelCase names of the members. The canonical problem + answers it as `400 Validation.Failed` ([`docs/API.md`](../API.md) §4.3), and the message never carries the value. + +A parameter that comes from the body is left to the JSON converters (ADR 0058), unchanged. + +**Enum values are free again.** No value has to be chosen to keep the parser away from a member: `ClientStatusFilter` +goes back to `Active = 1, Inactive = 2, All = 3`, and `ListClientsQueryValidator` loses its `IsInEnum` rule and the +literal code `Client.StatusFilterInvalid`, because no value that is not a member can reach it from the query. + +## Consequences + +- `GET /api/v1/clients?status=3` and `?status=active,inactive` answer `400 Validation.Failed` under `status`; before, + the first was `all`, and the second a `400` in English only because of the bit values. An unknown name keeps the `400`, + now in pt-BR and without the value. +- The OpenAPI document does not change: the API explorer reads the metadata of the parameter, not its binder. The + generated frontend types stay the same. +- It reaches every service that calls `AddWireJson()`. `identity-service` does not call it and keeps the framework's + binder. +- A list of enums in a body still needs `IsInEnum` per item (ADR 0058): the JSON converter can still yield a joined value. +- A `[Flags]` enum read from a URL would need several names. None exists, and ADR 0058 keeps a set of options out of + `[Flags]`; a first one would amend this ADR. + +Verified with the kernel's tests: the provider and the binder on their own (each case above, the message, no echo), and +the MVC parameter binder built by `AddWireJson()` reading a query record, where `status=3` and `status=active,inactive` +on an enum with values `1, 2, 3` answer the canonical `Validation.Failed` under `Status` with the pt-BR message. The +service's OpenAPI document, served on a private port with no database, generates frontend types identical to the +committed ones. The binder was not exercised through an authenticated request to the running service. + +## Considered and rejected + +- **Values chosen as bits, as ADR 0063 had.** A convention per enum, not pinned by anything, and numbers stay accepted. +- **An `IsInEnum` rule in each validator.** It runs after the binder, when a join is already a member, and cannot tell + `3` from `all`. +- **An opt-in per enum, like the type converter of ADR 0065.** The opt-in there keeps personal data out of URLs; an enum + carries none, and an opt-in is one more thing each filter would have to remember. +- **Replacing `EnumConverter` through `TypeDescriptor.AddAttributes` at startup.** Process-wide state that reaches every + other use of the converter, not only binding. +- **Leaving the framework's binder and documenting the limits** (the version before this one). Every future filter would + repeat the analysis of ADR 0063. diff --git a/docs/adr/README.md b/docs/adr/README.md index d9837e04..6e0b5d2b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ superseded is historical evidence, not current implementation guidance. | Service and data topology | 0001, 0002, 0003, 0024, 0029 | | Backend CQRS, validation, Result flow | 0005, 0006, 0007, 0009, 0012 as narrowed by 0049, 0014, 0018, 0048, 0049 as narrowed by 0061, 0051, 0057 | | Tenant assignment and persistence | 0006, 0008, 0009, 0017, 0019, 0024, 0028, 0046 | -| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063, 0064, 0065 | +| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062, 0063, 0064, 0065, 0066 | | Dates, times and time zones | 0045 | | Testing and runtime smokes | 0004, 0015 as narrowed by 0019/0026, 0026, 0050 | | Authentication UX and AI delegation | 0020, 0022 | @@ -86,6 +86,7 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0060 clients read by id: one response, no age on the wire, contacts ordered by name · 0061 validators write their own rules · 0062 clients deactivate and reactivate · -0063 clients list and search: generated string columns, whole CPF, status as a validated string · +0063 clients list and search: generated string columns, whole CPF, status as a plain enum · 0064 paged reads: one page request in, one paged result out · -0065 string value objects read from a query or a route, by opt-in. +0065 string value objects read from a query or a route, by opt-in · +0066 enums read from a query or a route by one name. From d0831707e4c2d38469c91d690e82ca30f3a2e043 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 19:16:14 -0300 Subject: [PATCH 24/24] refactor(shared): renomear AddWireJson() para AddWireFormat() (#155, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com os binders de value object (ADR 0065) e de enum (ADR 0066), o método passou a registrar como o fio é lido da query e da rota, não só os conversores JSON, e o nome deixou de descrever o que faz. - MvcBuilderExtensions.AddWireFormat(); o services-service, único chamador, renomeado junto; testes do kernel renomeados. - ARCHITECTURE e skills com o nome novo; as ADRs 0055, 0058 e 0065 ganham a nota "formerly", como já faziam para o AddValueObjectJson(). - ADR 0065: a consequência dizia que todo serviço chama o método, inclusive o identity-service, que não o chama; corrigida. - ADR 0066: a consequência sobre o comportamento anterior citava `?status=3` como `all`; com os valores em bit, era `?status=4`. Corrigida, e o rename registrado. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/agenza-backend-review/SKILL.md | 2 +- .../agenza-backend-slice/references/use-case.md | 2 +- .../references/value-objects.md | 2 +- backend/docs/ARCHITECTURE.md | 4 ++-- .../ServicesService.Api/Program.cs | 2 +- .../MvcBuilderExtensions.cs | 2 +- .../EnumNameModelBinderTests.cs | 6 +++--- .../MvcBuilderExtensionsTests.cs | 10 +++++----- .../Admin.SharedKernel.Tests/PagedQueryTests.cs | 2 +- .../StringValueObjectModelBinderTests.cs | 4 ++-- docs/adr/0055-shared-string-value-objects.md | 2 +- .../0058-closed-set-members-are-plain-enums.md | 4 ++-- ...5-string-value-objects-in-a-query-or-route.md | 7 ++++--- ...s-read-from-a-query-or-a-route-by-one-name.md | 16 ++++++++++------ 14 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.claude/skills/agenza-backend-review/SKILL.md b/.claude/skills/agenza-backend-review/SKILL.md index 6c5b25f7..e2ec9a7e 100644 --- a/.claude/skills/agenza-backend-review/SKILL.md +++ b/.claude/skills/agenza-backend-review/SKILL.md @@ -50,7 +50,7 @@ Cheap, and it catches most of what matters. Each hit is a question, not a verdic | `new DomainError(` inside a method | Domain | an unnamed error | Média | | a `RuleFor` chain with no `.WithErrorCode` | `*Validator.cs` | FluentValidation's internal name leaks to the API | Média | | a domain type as a member (a shared string value object such as `CpfNumber` is fine, ADR 0055; so is a plain enum, ADR 0058) | commands, inputs, responses | the wire carries strings and primitives | Média | -| a list of enums with no `IsInEnum` rule, a `Names` class, a per-enum or per-feature JSON converter | validators, Application, `Program.cs` | an unvalidated value reaches the response; `AddWireJson()` already names every enum (ADR 0058) | Média | +| a list of enums with no `IsInEnum` rule, a `Names` class, a per-enum or per-feature JSON converter | validators, Application, `Program.cs` | an unvalidated value reaches the response; `AddWireFormat()` already names every enum (ADR 0058) | Média | | `//`, `///` | anywhere | "what" comments are not allowed | Baixa | ## 3. Read it, in this order diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index f4ab1946..a1568f4e 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -11,7 +11,7 @@ messages", plus the earlier slices for reads, edits and paging. This file is how - Members are what crosses the wire: primitives, strings, `DateOnly?`, `Guid`, and lists as `IReadOnlyList<Input>?`, with the `Input` records in the same file. No domain type — except a shared string value object such as `CpfNumber?`, which binds from the JSON string and needs - no validator rule (ADR 0055) — no tenant. An enum is allowed: the kernel's `AddWireJson()` already names + no validator rule (ADR 0055) — no tenant. An enum is allowed: the kernel's `AddWireFormat()` already names every enum in camelCase and refuses integers, in the MVC and the minimal-API JSON options, and reads one from a query or a route by exactly one name (ADR 0066), so there is nothing to register, no `Names` class and no values chosen to dodge the parser. A list of enums in a body gets an `IsInEnum` rule per item with diff --git a/.claude/skills/agenza-backend-slice/references/value-objects.md b/.claude/skills/agenza-backend-slice/references/value-objects.md index 8bb3e48e..5c25398d 100644 --- a/.claude/skills/agenza-backend-slice/references/value-objects.md +++ b/.claude/skills/agenza-backend-slice/references/value-objects.md @@ -45,7 +45,7 @@ Ask in order and stop at the first no: had, `ToModel` makes no call for it, and the EF configuration keeps only `HasMaxLength` (with the type's length constant) and `IsRequired`. 4. **The service**, once per service: its Domain references `Admin.SharedKernel.ValueObjects`; the MVC setup - calls `.AddWireJson()`; the OpenAPI setup calls `MapValueObjectsToStrings()`; the `DbContext` + calls `.AddWireFormat()`; the OpenAPI setup calls `MapValueObjectsToStrings()`; the `DbContext` calls `AddValueObjectConversions()` from `ConfigureConventions`. A service that has the three does nothing here. 5. **The wire test**: `CommandBindingTests` ([tests.md](tests.md) §1). diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 0292b655..783b9a55 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -72,7 +72,7 @@ Shared projects in `backend/shared/` hold infrastructure, never business rules: | Project | Holds | Referenced by | | --- | --- | --- | | `Admin.SharedKernel` | `Result`, `Error`/`ErrorType`/`FieldError`, CQRS contracts, `IDispatcher`, `PagedQuery`, `PageRequest` and `PagedResult`, the JSON converters of the wire (value objects, enum names) | Application | -| `Admin.SharedKernel.AspNetCore` | `ToActionResult`, `ApiResponse`, `ApiProblemDetails`, `AgenzaControllerBase`, `GenericExceptionHandler`, `AddWireJson()` | Api | +| `Admin.SharedKernel.AspNetCore` | `ToActionResult`, `ApiResponse`, `ApiProblemDetails`, `AgenzaControllerBase`, `GenericExceptionHandler`, `AddWireFormat()` | Api | | `Admin.SharedKernel.EntityFrameworkCore` | `RepositoryBase` (with the paged read over `PageRequest`), `ApplyAuditableConventions` (soft-delete and tenant filters), the column conversion of the shared value objects | Infrastructure | | `Admin.SharedKernel.ValueObjects` | `IStringValueObject` and the string value objects every service shares (`CpfNumber`); no reference at all | Domain | | `Admin.Identity.Client` | JWT validation, `ITenantAccessor`, `ICurrentUserAccessor`, `TenantHeaderFilter`, `[IgnoreTenant]` | Infrastructure, Api | @@ -387,7 +387,7 @@ injects `IDispatcher`, holds no logic — bind, dispatch, `result.ToActionResult - A body with a list is bounded twice: the validator caps the count and the action has a `[RequestSizeLimit]`. - Enums travel as camelCase strings: a plain enum, named by the kernel for every enum with integers - refused (`AddEnumNameConverter`, registered by `AddWireJson()` in the MVC options and in the + refused (`AddEnumNameConverter`, registered by `AddWireFormat()` in the MVC options and in the minimal-API options, which the OpenAPI generator reads). From a query or a route, the same call's binder reads exactly one name in any case and refuses a number or several names, with a pt-BR message that lists the names ([0066](../../docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md)). Nothing to register per enum, no diff --git a/backend/services/services-service/ServicesService.Api/Program.cs b/backend/services/services-service/ServicesService.Api/Program.cs index 01e0fea1..cc57e1b6 100644 --- a/backend/services/services-service/ServicesService.Api/Program.cs +++ b/backend/services/services-service/ServicesService.Api/Program.cs @@ -16,7 +16,7 @@ { options.Filters.Add(); }) - .AddWireJson() + .AddWireFormat() .AddModelStateProblemDetails(); builder.Services.AddApiDocumentation(builder.Configuration); diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs index dce0302e..ef104f86 100644 --- a/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs +++ b/backend/shared/Admin.SharedKernel.AspNetCore/MvcBuilderExtensions.cs @@ -6,7 +6,7 @@ namespace Admin.SharedKernel.AspNetCore; public static class MvcBuilderExtensions { - public static IMvcBuilder AddWireJson(this IMvcBuilder builder) + public static IMvcBuilder AddWireFormat(this IMvcBuilder builder) { builder.Services.AddOptions() .Configure((options, timeProvider) => diff --git a/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs b/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs index 548c3d41..43fcd4a4 100644 --- a/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs @@ -36,7 +36,7 @@ private static IList Providers() { var services = new ServiceCollection(); services.AddSingleton(TimeProvider.System); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); return services.BuildServiceProvider().GetRequiredService>().Value.ModelBinderProviders; } @@ -73,7 +73,7 @@ private static async Task Bind(string? value, bool prese var services = new ServiceCollection(); services.AddSingleton(TimeProvider.System); services.AddLogging(); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); var provider = services.BuildServiceProvider(); var metadata = provider.GetRequiredService().GetMetadataForType(typeof(SituationQuery)); @@ -98,7 +98,7 @@ private static async Task Bind(string? value, bool prese } [Fact] - public void AddWireJson_PutsTheEnumBinderBeforeTheFrameworkOnes() + public void AddWireFormat_PutsTheEnumBinderBeforeTheFrameworkOnes() { var names = Providers().Select(provider => provider.GetType().Name).ToList(); diff --git a/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs b/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs index fce8e22b..8a69c065 100644 --- a/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs @@ -18,7 +18,7 @@ private static ActionContext InvalidActionContext() } [Fact] - public void AddWireJson_ReadsTheDateValueObjectsAgainstTheClockOfTheContainer() + public void AddWireFormat_ReadsTheDateValueObjectsAgainstTheClockOfTheContainer() { var json = MvcJsonOptions(new FixedClock(new DateTimeOffset(2026, 10, 2, 12, 0, 0, TimeSpan.Zero))); @@ -32,7 +32,7 @@ public void AddWireJson_ReadsTheDateValueObjectsAgainstTheClockOfTheContainer() private sealed record DateSample(BirthDate? Born); [Fact] - public void AddWireJson_NamesEnumsInCamelCaseInTheMvcOptions() + public void AddWireFormat_NamesEnumsInCamelCaseInTheMvcOptions() { var json = MvcJsonOptions(TimeProvider.System); @@ -43,11 +43,11 @@ public void AddWireJson_NamesEnumsInCamelCaseInTheMvcOptions() } [Fact] - public void AddWireJson_NamesEnumsInTheMinimalApiOptionsThatTheOpenApiGeneratorReads() + public void AddWireFormat_NamesEnumsInTheMinimalApiOptionsThatTheOpenApiGeneratorReads() { var services = new ServiceCollection(); services.AddSingleton(TimeProvider.System); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); var json = services.BuildServiceProvider().GetRequiredService>().Value.SerializerOptions; JsonSerializer.Serialize(new EnumSample(Level.VeryHigh), json).Should().Be("{\"level\":\"veryHigh\"}"); @@ -57,7 +57,7 @@ private static JsonSerializerOptions MvcJsonOptions(TimeProvider timeProvider) { var services = new ServiceCollection(); services.AddSingleton(timeProvider); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); return services.BuildServiceProvider().GetRequiredService>().Value.JsonSerializerOptions; } diff --git a/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs b/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs index 61b41339..4d1352d4 100644 --- a/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs @@ -19,7 +19,7 @@ public sealed record ThingsQuery(string? Term = null, bool Archived = false) : P var services = new ServiceCollection(); services.AddSingleton(TimeProvider.System); services.AddLogging(); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); var provider = services.BuildServiceProvider(); var metadata = provider.GetRequiredService().GetMetadataForType(typeof(ThingsQuery)); diff --git a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs index e6f3dfba..b03778dd 100644 --- a/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/StringValueObjectModelBinderTests.cs @@ -19,7 +19,7 @@ private static IModelBinderProvider Provider() { var services = new ServiceCollection(); services.AddSingleton(TimeProvider.System); - services.AddControllers().AddWireJson(); + services.AddControllers().AddWireFormat(); var options = services.BuildServiceProvider().GetRequiredService>().Value; return options.ModelBinderProviders[0]; @@ -53,7 +53,7 @@ private static async Task Bind(string? value, bool prese } [Fact] - public void AddWireJson_PutsTheValueObjectBinderBeforeEveryDefaultOne() + public void AddWireFormat_PutsTheValueObjectBinderBeforeEveryDefaultOne() { Provider().GetType().Name.Should().Be("StringValueObjectModelBinderProvider"); } diff --git a/docs/adr/0055-shared-string-value-objects.md b/docs/adr/0055-shared-string-value-objects.md index 1fdb915c..beffb67c 100644 --- a/docs/adr/0055-shared-string-value-objects.md +++ b/docs/adr/0055-shared-string-value-objects.md @@ -39,7 +39,7 @@ type the same way. 3. **The kernel applies the contract once**, so a new value object in that project works everywhere with no per-service code: - `JsonSerializerOptions.AddValueObjectConverters(TimeProvider)` (`Admin.SharedKernel`), which MVC reaches through - `AddWireJson()` (formerly `AddValueObjectJson()`, [ADR 0058](0058-closed-set-members-are-plain-enums.md)) with the container's own `TimeProvider`: a JSON string in, any formatting the type + `AddWireFormat()` (formerly `AddWireJson()` and `AddValueObjectJson()`, ADRs [0058](0058-closed-set-members-are-plain-enums.md) and [0066](0066-enums-read-from-a-query-or-a-route-by-one-name.md)) with the container's own `TimeProvider`: a JSON string in, any formatting the type accepts; `null`, `""` and whitespace bind to `null` when the type's `BlankIsAbsent` is true; anything else throws a `JsonException` carrying the `Error` of the result — for a token that is not a string, the `Error` of `Create(null)` — and never the value (it is personal data and the message reaches the logs, so a type's errors must not echo it). A date value object reads the date the diff --git a/docs/adr/0058-closed-set-members-are-plain-enums.md b/docs/adr/0058-closed-set-members-are-plain-enums.md index edc9ac43..980698be 100644 --- a/docs/adr/0058-closed-set-members-are-plain-enums.md +++ b/docs/adr/0058-closed-set-members-are-plain-enums.md @@ -27,10 +27,10 @@ the enum. And the unknown-name rule was a second copy of what the binder already (`ClientReferenceContact.PurposeUnknown`), and keep their own copy of the set. - Commands and responses carry `IReadOnlyList`. The shared kernel names **every** enum on the wire in camelCase and refuses integers: `JsonSerializerOptions.AddEnumNameConverter()` - (`Admin.SharedKernel`), which `AddWireJson()` (`Admin.SharedKernel.AspNetCore`, formerly + (`Admin.SharedKernel`), which `AddWireFormat()` (`Admin.SharedKernel.AspNetCore`, formerly `AddWireJson()` and `AddValueObjectJson()`) registers in both the MVC and the minimal-API JSON options — the OpenAPI generator reads the latter and would otherwise publish the enum as `integer`. A service that already - calls `AddWireJson()` registers nothing per enum. An unknown name now fails at binding (gate 1), with + calls `AddWireFormat()` registers nothing per enum. An unknown name now fails at binding (gate 1), with the framework's English message and the generic code `Validation.Failed`. - The validator keeps two rules per input: the list is not null or empty (`PurposesRequired`), and each item is a member (`PurposeUnknown`, `IsInEnum`). The second exists because the converter accepts several diff --git a/docs/adr/0065-string-value-objects-in-a-query-or-route.md b/docs/adr/0065-string-value-objects-in-a-query-or-route.md index e89e0a0b..aadc76b1 100644 --- a/docs/adr/0065-string-value-objects-in-a-query-or-route.md +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -38,7 +38,8 @@ two things: - MVC and the API explorer read the type as one value, so the document says `Search`, a `string`, with no extra schema; - the kernel's model binder binds exactly the types that declare it. -**`StringValueObjectModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore` and registered first by `AddWireJson()`, so +**`StringValueObjectModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore` and registered first by `AddWireFormat()` +(formerly `AddWireJson()`, [ADR 0066](0066-enums-read-from-a-query-or-a-route-by-one-name.md)), so no service has to remember it, gives a declaring type a binder that: - binds nothing when the parameter is absent; @@ -69,8 +70,8 @@ a text; the query removes the mask with `CpfNumber.StripMask` ([ADR 0063](0063-c - The probe of the document is a test: `StringValueObjectContractTests` fails if `SearchText` becomes a complex type to MVC again, and the provider tests fail if personal data starts binding from a URL. - A date value object is not bound from a URL. This ADR covers strings. -- `AddWireJson()` is called by every service, so the provider reaches `identity-service` too; it binds nothing there, - since no parameter of that service is a declaring type. +- The provider reaches every service that calls `AddWireFormat()`. `identity-service` does not call it, and has no + parameter of a declaring type either. Verified by hand on a disposable PostgreSQL 18.3 with the real services and a real login: 97 checks on the list, including a term of 255 characters (`400`, the message of the value object, the term not echoed), a padded term of diff --git a/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md b/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md index 1dd95cbf..9e21a6de 100644 --- a/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md +++ b/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md @@ -22,7 +22,7 @@ would have to know, nothing pins it, and numbers stayed accepted. ## Decision -**`EnumNameModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore`, registered by `AddWireJson()` right after the +**`EnumNameModelBinderProvider`**, in `Admin.SharedKernel.AspNetCore`, registered by `AddWireFormat()` right after the binder of string value objects ([ADR 0065](0065-string-value-objects-in-a-query-or-route.md)) and before every binder of the framework, so no service has to remember it. It gives every enum, nullable or not, read from anything but the body (a query, a route, a header, a form field) a binder that: @@ -43,19 +43,23 @@ literal code `Client.StatusFilterInvalid`, because no value that is not a member ## Consequences -- `GET /api/v1/clients?status=3` and `?status=active,inactive` answer `400 Validation.Failed` under `status`; before, - the first was `all`, and the second a `400` in English only because of the bit values. An unknown name keeps the `400`, - now in pt-BR and without the value. +- `GET /api/v1/clients` answers `400 Validation.Failed` under `status` for any number and any join. Before, `?status=4` + (the bit value of `All`) was `all`, and `?status=active,inactive` was refused only because of the bit values. An unknown + name keeps the `400`, now in pt-BR and without the value. +- `AddWireJson()` becomes `AddWireFormat()`. With this binder and the one of + [ADR 0065](0065-string-value-objects-in-a-query-or-route.md) it registers how the wire is read from a query and a route, + not only the JSON converters. Its one caller, `services-service`, is renamed in the same change; the older ADRs name it + with a "formerly" note. - The OpenAPI document does not change: the API explorer reads the metadata of the parameter, not its binder. The generated frontend types stay the same. -- It reaches every service that calls `AddWireJson()`. `identity-service` does not call it and keeps the framework's +- It reaches every service that calls `AddWireFormat()`. `identity-service` does not call it and keeps the framework's binder. - A list of enums in a body still needs `IsInEnum` per item (ADR 0058): the JSON converter can still yield a joined value. - A `[Flags]` enum read from a URL would need several names. None exists, and ADR 0058 keeps a set of options out of `[Flags]`; a first one would amend this ADR. Verified with the kernel's tests: the provider and the binder on their own (each case above, the message, no echo), and -the MVC parameter binder built by `AddWireJson()` reading a query record, where `status=3` and `status=active,inactive` +the MVC parameter binder built by `AddWireFormat()` reading a query record, where `status=3` and `status=active,inactive` on an enum with values `1, 2, 3` answer the canonical `Validation.Failed` under `Status` with the pt-BR message. The service's OpenAPI document, served on a private port with no database, generates frontend types identical to the committed ones. The binder was not exercised through an authenticated request to the running service.