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/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index cf9efc3d..7a2c1ca5 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(page, pageSize, filters)` | `(IReadOnlyList Items, int TotalCount)` | a paged 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)` 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 — 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, @@ -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`. @@ -85,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/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index 196b0a10..a1568f4e 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -11,12 +11,16 @@ 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 - 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). + 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 + 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 carries its filters as optional members and `Page`/`PageSize` with defaults. +- 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 + `ListFilter` (a `PageRequest` with the list's filters), and the validator writes the three `Paging.*` + rules (ADR 0064). ## 2. The validator — simple and clean @@ -108,12 +112,14 @@ 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 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/.claude/skills/agenza-backend-slice/references/value-objects.md b/.claude/skills/agenza-backend-slice/references/value-objects.md index 16e4d4ae..5c25398d 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 @@ -26,11 +28,13 @@ 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)`. 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`). - 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, @@ -41,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). @@ -54,8 +58,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/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..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 @@ -459,7 +459,91 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: { + parameters: { + query?: { + Search?: string; + Status?: components['schemas']['ClientStatusFilter']; + Page?: number | string; + PageSize?: number | 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: { @@ -953,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: { @@ -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: components['schemas']['ClientStatus']; + }; ClientResponse: { /** Format: uuid */ id: string; @@ -1857,6 +1960,10 @@ 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'; CreateCategoryCommand: { name: string; @@ -1915,6 +2022,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..783b9a55 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.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` | `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`, `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 | | `Admin.Logging` | the Serilog pipeline: console format, default levels, OTLP export, one line per request | `ServiceDefaults`, `AppHost` — never a layer | @@ -141,12 +141,13 @@ 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 -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 @@ -211,7 +212,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 @@ -278,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. @@ -333,17 +339,21 @@ 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 -go through `ListPagedAsync`. 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`, 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, -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)). @@ -377,13 +387,20 @@ 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 - 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)). + 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 + 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. -- A paged list takes `Page`/`PageSize` with defaults and a validator bounding them, and returns - `PagedResult`; an unpaged list returns `IReadOnlyList`. +- 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 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. + 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 [`agenza-api-contract`](../../.claude/skills/agenza-api-contract/SKILL.md). A change to a shape that @@ -454,7 +471,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 `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 | 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.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/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index bd89bbb6..19b6abc0 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -1,9 +1,14 @@ +using Admin.SharedKernel; +using ServicesService.Application.Clients; + namespace ServicesService.Application.Abstractions; public interface IClientRepository { Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken); + Task> ListAsync(ClientListFilter filter, 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/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/Clients/ClientListFilter.cs b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs new file mode 100644 index 00000000..f6075194 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ClientListFilter.cs @@ -0,0 +1,10 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients; + +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/ClientListItemResponse.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ClientListItemResponse.cs new file mode 100644 index 00000000..86f41671 --- /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, + ClientStatus 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); + } +} 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..0cf4cab4 --- /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 = 3, +} 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..12f6cc44 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs @@ -0,0 +1,48 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients.ListClients; + +public sealed record ListClientsQuery( + SearchText? Search = null, + ClientStatusFilter? Status = ClientStatusFilter.Active) : PagedQuery, IQuery> +{ + public ClientListFilter ToFilter() + { + var text = Search?.Value; + var cpfDigits = CpfDigitsOf(text); + var status = ToClientStatus(Status); + + return new ClientListFilter(Page, PageSize, text, cpfDigits, status); + } + + private static string? CpfDigitsOf(string? text) + { + if (text is null) + { + return null; + } + + var digits = CpfNumber.StripMask(text); + 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) + { + 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 new file mode 100644 index 00000000..1f211fcb --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryHandler.cs @@ -0,0 +1,15 @@ +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 clientFilter = query.ToFilter(); + var clients = await clientRepository.ListAsync(clientFilter, cancellationToken); + + return Result.Success(clients.Map(ClientListItemResponse.FromClient)); + } +} 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..3b9b5b78 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQueryValidator.cs @@ -0,0 +1,24 @@ +using Admin.SharedKernel; +using FluentValidation; + +namespace ServicesService.Application.Clients.ListClients; + +public sealed class ListClientsQueryValidator : AbstractValidator +{ + public ListClientsQueryValidator() + { + RuleFor(query => query.Page) + .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, 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/ListServices/ListServicesQuery.cs b/backend/services/services-service/ServicesService.Application/Services/ListServices/ListServicesQuery.cs index 13a6647a..98c2efc5 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,12 @@ namespace ServicesService.Application.Services.ListServices; public sealed record ListServicesQuery( - int Page = 1, - int PageSize = 20, - string? Search = null, + SearchText? Search = null, Guid? CategoryId = null, - Guid? TagId = null) : IQuery>; + Guid? TagId = null) : PagedQuery, IQuery> +{ + public ServiceListFilter ToFilter() + { + return new ServiceListFilter(Page, PageSize, Search?.Value, CategoryId, 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 037b6e24..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,36 +18,39 @@ 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 services = await _serviceRepository.ListAsync(query.ToFilter(), 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..d6bb6db7 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Services/ServiceListFilter.cs @@ -0,0 +1,10 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Services; + +public sealed record ServiceListFilter( + int Page, + int PageSize, + string? Text, + Guid? CategoryId, + Guid? TagId) : PageRequest(Page, PageSize); 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..ff88cc71 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -1,6 +1,8 @@ +using Admin.SharedKernel; using Admin.SharedKernel.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients; using ServicesService.Infrastructure.Persistence; namespace ServicesService.Infrastructure.Repositories; @@ -14,10 +16,51 @@ public ClientRepository(ServicesDataContext dbContext) public Task GetByIdAsync(Guid clientId, CancellationToken cancellationToken) { - return Set + return WithContacts(Set).FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); + } + + public Task> ListAsync(ClientListFilter filter, CancellationToken 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)) - .FirstOrDefaultAsync(client => client.Id == clientId, cancellationToken); + .Include(client => client.ReferenceContacts.OrderBy(contact => contact.Name).ThenBy(contact => contact.Id)); + } + + private static IOrderedQueryable FilterAndOrder(IQueryable query, ClientListFilter filter) + { + if (filter.Status is { } wantedStatus) + { + query = query.Where(client => client.Status == wantedStatus); + } + + if (filter.Text is { } text) + { + query = Search(query, text, filter.CpfDigits); + } + + return query + .OrderBy(client => EF.Property(client, "NameNormalized")) + .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) diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ServiceRepository.cs index 5a9589b6..37165e7c 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 IOrderedQueryable FilterAndOrder(IQueryable query, ServiceListFilter filter) + { + query = query.Include(service => service.Tags); + + if (filter.Text is { } text) + { + query = query.Where(service => EF.Property(service, "NameNormalized").Contains(text)); + } + + 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/ClientListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs new file mode 100644 index 00000000..7d727de7 --- /dev/null +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientListPersistenceTests.cs @@ -0,0 +1,445 @@ +using Admin.SharedKernel; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +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; + +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, + IReadOnlyCollection? guardians = null, + IReadOnlyCollection? referenceContacts = 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, + 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); + 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> List( + string databaseName, + Guid tenantId, + int page = 1, + int pageSize = 20, + string? search = null, + ClientStatus? status = null) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + 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) + { + 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, 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); + 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(new ClientListFilter(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, search: "ana"); + var byCpf = await List(databaseName, tenantId, search: 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); + 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)); + 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, search: "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, 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"); + Names(byNameOfAClientWithoutEmail).Should().Equal("Carla Souza"); + } + + [Fact] + public async Task List_SearchesTheCpfDigitsAnywhere_WithOrWithoutTheMask() + { + 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, 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"); + } + + [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, 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"); + activeSilvaSecondPage.TotalCount.Should().Be(3); + Names(inactiveSilva).Should().Equal("Bia Silva"); + anySilva.TotalCount.Should().Be(4); + } + + [Fact] + public async Task List_ReturnsEachClientWithItsContacts_WithoutChangingThePageOrTheTotal() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + 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, 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); + + 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] + 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.PersistenceTests/ServiceListPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs new file mode 100644 index 00000000..a950f317 --- /dev/null +++ b/backend/services/services-service/ServicesService.PersistenceTests/ServiceListPersistenceTests.cs @@ -0,0 +1,278 @@ +using Admin.SharedKernel; +using Microsoft.EntityFrameworkCore; +using ServicesService.Application.Services; +using ServicesService.Application.Services.ListServices; +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 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); + foreach (var service in services) + { + Add(context, 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)); + Add(context, 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, + string? search = null) + { + await using var context = PersistenceContext.Create(databaseName, tenantId); + 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); + } + + 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_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() + { + 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(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 new file mode 100644 index 00000000..cbe95731 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientListFilterTests.cs @@ -0,0 +1,57 @@ +using Admin.SharedKernel; +using ServicesService.Application.Clients; + +namespace ServicesService.Tests.Clients; + +public class ClientListFilterTests +{ + [Fact] + public void ThePageIsKeptAndItsOffsetIsExposed() + { + var filter = new ClientListFilter(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 APageNoRepositoryCouldServe_Throws(int page, int pageSize) + { + var act = () => new ClientListFilter(page, pageSize, null, null, null); + + act.Should().Throw(); + } + + [Fact] + public void TheTextTheCpfDigitsAndTheStatus_AreKeptExactlyAsGiven() + { + var filter = new ClientListFilter(1, 20, " Maria ", "1.2-3", ClientStatus.Inactive); + + filter.Text.Should().Be(" Maria "); + filter.CpfDigits.Should().Be("1.2-3"); + filter.Status.Should().Be(ClientStatus.Inactive); + } + + [Fact] + public void TwoFiltersWithTheSameValues_AreEqual() + { + var filter = new ClientListFilter(2, 20, "maria", null, ClientStatus.Active); + + 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 AFilterIsNeverEqualToAPlainRequestOfTheSamePage() + { + new ClientListFilter(2, 20, null, null, null).Should().NotBe(new PageRequest(2, 20)); + } +} 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..dc50ec69 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs @@ -0,0 +1,210 @@ +using Admin.SharedKernel; +using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients; +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()) + .Returns(callInfo => + { + var filter = callInfo.Arg(); + return Task.FromResult(new PagedResult(clients, totalCount, filter.Page, filter.PageSize)); + }); + } + + 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 SearchText SearchTextOf(string raw) + { + return SearchText.Create(raw).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( + 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] + 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(ClientStatus.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: ClientStatusFilter.All), CancellationToken.None); + + result.Value.Items.Should().ContainSingle().Which.Status.Should().Be(ClientStatus.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(" Maria SILVA ", "maria silva")] + [InlineData("MARIA@Example.COM", "maria@example.com")] + [InlineData("josé", "josé")] + [InlineData("50%_off", "50%_off")] + public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, string expectedText) + { + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); + + await AssertListed(expectedText, null, ClientStatus.Active); + } + + [Theory] + [InlineData("529.982.247-25", "529.982.247-25", "52998224725")] + [InlineData("123", "123", "123")] + public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(string search, string expectedText, string expectedDigits) + { + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); + + await AssertListed(expectedText, expectedDigits, ClientStatus.Active); + } + + [Theory] + [InlineData("maria")] + [InlineData("ana-lima")] + public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(string search) + { + await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None); + + await AssertListed(search, null, ClientStatus.Active); + } + + [Fact] + public async Task Handle_CombinesTheSearchWithTheStatusFilter() + { + 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); + } + + [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/ListClientsQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs new file mode 100644 index 00000000..51352e0d --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs @@ -0,0 +1,123 @@ +using ServicesService.Application.Clients; +using ServicesService.Application.Clients.ListClients; + +namespace ServicesService.Tests.Clients.ListClients; + +public class ListClientsQueryTests +{ + 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)] + [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(new ClientListFilter(1, 20, null, null, ClientStatus.Active)); + } + + [Fact] + public void ToFilter_CarriesThePageTheSizeTheSearchAndTheCpfDigitsOfTheQuery() + { + var query = new ListClientsQuery(Search: SearchTextOf(" 529.982.247-25 "), Status: ClientStatusFilter.Inactive) + { + Page = 3, + PageSize = 50, + }; + + var filter = query.ToFilter(); + + filter.Should().Be(new ClientListFilter(3, 50, "529.982.247-25", "52998224725", ClientStatus.Inactive)); + filter.Page.Should().Be(3); + filter.PageSize.Should().Be(50); + } + + [Fact] + public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() + { + var act = () => new ListClientsQuery { Page = 0 }.ToFilter(); + + act.Should().Throw(); + } + + [Fact] + public void ToFilter_WithoutSearch_HasNoTextAndNoCpf() + { + var filter = new ListClientsQuery().ToFilter(); + + 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("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) + { + 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); + + filter.CpfDigits.Should().BeNull(); + filter.Text.Should().Be(raw); + } +} 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..31acb49b --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryValidatorTests.cs @@ -0,0 +1,96 @@ +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", "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" }, + }; + } + + [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(); + } + + [Theory] + [InlineData(ClientStatusFilter.Active)] + [InlineData(ClientStatusFilter.Inactive)] + [InlineData(ClientStatusFilter.All)] + [InlineData(null)] + public void Validate_WithAMemberOfTheEnumOrNoStatus_Passes(ClientStatusFilter? status) + { + var result = _validator.Validate(new ListClientsQuery(Status: status)); + + 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("Paging.PageInvalid"); + } + + [Fact] + public void Validate_WithSeveralInvalidMembers_ReportsEachOnItsOwnProperty() + { + 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"), + ]); + } + + [Fact] + public void Validate_MessagesAreWrittenInPortugueseAndNameTheField() + { + 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.", + ]); + } +} 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..a9a94f39 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,58 @@ 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_PassesTheFiltersAndThePageToTheRepositoryAsOneFilter() { 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 search = SearchText.Create(" Cut ").Value; var result = await handler.Handle( - new ListServicesQuery(Search: "cut", CategoryId: categoryId, TagId: tagId), + new ListServicesQuery(Search: search, 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(new ServiceListFilter(2, 5, "cut", categoryId, 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(new ServiceListFilter(1, 20, null, null, 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/ListServicesQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs new file mode 100644 index 00000000..1e1ec915 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Services/ListServices/ListServicesQueryTests.cs @@ -0,0 +1,49 @@ +using ServicesService.Application.Services; +using ServicesService.Application.Services.ListServices; + +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() + { + new ListServicesQuery().ToFilter().Should().Be(new ServiceListFilter(1, 20, null, null, null)); + } + + [Fact] + public void ToFilter_CarriesThePageTheSizeAndEveryFilterOfTheQuery() + { + var categoryId = Guid.NewGuid(); + var tagId = Guid.NewGuid(); + var query = new ListServicesQuery(Search: SearchTextOf(" Cut "), CategoryId: categoryId, TagId: tagId) + { + Page = 2, + PageSize = 5, + }; + + query.ToFilter().Should().Be(new ServiceListFilter(2, 5, "cut", categoryId, tagId)); + } + + [Theory] + [InlineData(" Hair CUT ", "hair cut")] + [InlineData("100%", "100%")] + [InlineData("a_b", "a_b")] + public void ToFilter_KeepsTheNormalizedTextOfTheSearch(string raw, string expected) + { + new ListServicesQuery(Search: SearchTextOf(raw)).ToFilter().Text.Should().Be(expected); + } + + [Fact] + public void ToFilter_WithAPageNoRepositoryCouldServe_Throws() + { + var act = () => new ListServicesQuery { PageSize = 0 }.ToFilter(); + + act.Should().Throw(); + } +} 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..817acb6b --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Services/ServiceListFilterTests.cs @@ -0,0 +1,54 @@ +using Admin.SharedKernel; +using ServicesService.Application.Services; + +namespace ServicesService.Tests.Services; + +public class ServiceListFilterTests +{ + [Fact] + public void ThePageIsKeptAndItsOffsetIsExposed() + { + var filter = new ServiceListFilter(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 APageNoRepositoryCouldServe_Throws(int page, int pageSize) + { + var act = () => new ServiceListFilter(page, pageSize, null, null, null); + + act.Should().Throw(); + } + + [Fact] + public void TheTextTheCategoryAndTheTag_AreKeptExactlyAsGiven() + { + var categoryId = Guid.NewGuid(); + var tagId = Guid.NewGuid(); + + var filter = new ServiceListFilter(1, 20, " Cut ", categoryId, tagId); + + filter.Text.Should().Be(" Cut "); + filter.CategoryId.Should().Be(categoryId); + filter.TagId.Should().Be(tagId); + } + + [Fact] + public void TwoFiltersWithTheSameValues_AreEqual() + { + var categoryId = Guid.NewGuid(); + var filter = new ServiceListFilter(2, 20, "cut", categoryId, 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/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 5cff25bd..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) => @@ -16,6 +16,12 @@ public static IMvcBuilder AddWireJson(this IMvcBuilder builder) builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddEnumNameConverter()); + builder.AddMvcOptions(options => + { + options.ModelBinderProviders.Insert(0, new StringValueObjectModelBinderProvider()); + options.ModelBinderProviders.Insert(1, new EnumNameModelBinderProvider()); + }); + 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.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..b98450d7 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs @@ -48,21 +48,17 @@ protected async Task> ListAsync( return await query.ToListAsync(cancellationToken); } - protected async Task<(IReadOnlyList Items, int TotalCount)> ListPagedAsync( - Func, IQueryable>? order, - int page, - int pageSize, + protected async Task> ListPagedAsync( + PageRequest page, + Func, IOrderedQueryable> filterAndOrder, CancellationToken cancellationToken) { - IQueryable query = Set.AsNoTracking(); - if (order is not null) - { - query = order(query); - } + var query = filterAndOrder(Set.AsNoTracking()); var totalCount = await query.CountAsync(cancellationToken); - var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); - return (items, totalCount); + var items = await query.Skip(page.Offset).Take(page.PageSize).ToListAsync(cancellationToken); + + return new PagedResult(items, totalCount, page.Page, page.PageSize); } protected Task AnyAsync(Expression> predicate, CancellationToken cancellationToken) => 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.Tests/EnumNameModelBinderTests.cs b/backend/shared/Admin.SharedKernel.Tests/EnumNameModelBinderTests.cs new file mode 100644 index 00000000..43fcd4a4 --- /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().AddWireFormat(); + + 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().AddWireFormat(); + 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 AddWireFormat_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/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/PageRequestTests.cs b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs new file mode 100644 index 00000000..6ed8b0ee --- /dev/null +++ b/backend/shared/Admin.SharedKernel.Tests/PageRequestTests.cs @@ -0,0 +1,126 @@ +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() + { + 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 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() + { + 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/PagedQueryTests.cs b/backend/shared/Admin.SharedKernel.Tests/PagedQueryTests.cs new file mode 100644 index 00000000..4d1352d4 --- /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().AddWireFormat(); + 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.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.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..b01a016d --- /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_FailsLikeEveryOtherValueObject(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..b03778dd --- /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().AddWireFormat(); + 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 AddWireFormat_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/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/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs b/backend/shared/Admin.SharedKernel.ValueObjects/SearchText.cs new file mode 100644 index 00000000..adea048a --- /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 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; } + + private SearchText(string value) + { + Value = value; + } + + 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); + } + + 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/backend/shared/Admin.SharedKernel/PageRequest.cs b/backend/shared/Admin.SharedKernel/PageRequest.cs new file mode 100644 index 00000000..49282667 --- /dev/null +++ b/backend/shared/Admin.SharedKernel/PageRequest.cs @@ -0,0 +1,44 @@ +namespace Admin.SharedKernel; + +public 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/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/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 1bdb4572..88382aee 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 `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)): + +```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): ``` @@ -171,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: @@ -182,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 @@ -251,14 +269,19 @@ 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`) | | 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, 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`) | +| 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/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/0055-shared-string-value-objects.md b/docs/adr/0055-shared-string-value-objects.md index a906d745..beffb67c 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 @@ -37,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/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md new file mode 100644 index 00000000..8c40f9da --- /dev/null +++ b/docs/adr/0063-clients-list-and-search-contract.md @@ -0,0 +1,195 @@ +# 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=&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 `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 +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), `search` and `status`. It +answers `200` with `ApiResponse>`: `items`, `totalCount`, `page`, `pageSize`. +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. 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 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 +("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 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 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 +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 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. 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. + +**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 `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. + +**Validation.** Gate 2, with the codes below and pt-BR messages naming the field: + +| Rule | Code | +| --- | --- | +| `page` at least 1 | `Paging.PageInvalid` | +| `(page - 1) * pageSize` fits an `int` | `Paging.PageTooLarge` | +| `pageSize` from 1 to 100 | `Paging.PageSizeInvalid` | + +`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)). +`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`. + +**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 page statement also reads the guardians and reference contacts of its clients, in one query like + `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. +- 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, 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, `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, +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 + +- **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 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 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 + 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. +- **`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` 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. +- **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. +- **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). +- **`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)` 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 new file mode 100644 index 00000000..7931c5b8 --- /dev/null +++ b/docs/adr/0064-paged-reads-page-request-and-paged-result.md @@ -0,0 +1,162 @@ +# 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` 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. + +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. 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, 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)`. + +**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 +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 +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: + +| 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. + +**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 query turns itself into its filter +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: +`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. 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 + +- `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. +- 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 + 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; +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`. + +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 + reopens [ADR 0061](0061-validators-write-their-own-rules.md) and adds an interface to the kernel for three + one-line rules. +- **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. 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`** (`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. +- **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? + 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`** (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. +- **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. 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..aadc76b1 --- /dev/null +++ b/docs/adr/0065-string-value-objects-in-a-query-or-route.md @@ -0,0 +1,99 @@ +# 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 (`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), +[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. `?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. + +## 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. The 254 is the longest thing a +list searches (an e-mail). + +**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 +`[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 `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 `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; +- 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.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 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 + +- 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. +- 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. +- 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 +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 + +- **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 `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. +- **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. 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..9e21a6de --- /dev/null +++ b/docs/adr/0066-enums-read-from-a-query-or-a-route-by-one-name.md @@ -0,0 +1,77 @@ +# 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 `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: + +- 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` 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 `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 `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. + +## 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 14707e10..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 | +| 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 | @@ -85,4 +85,8 @@ 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 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 · +0066 enums read from a query or a route by one name.