From 49cd001c2446d8e6f0b8208dc935f04e0502bd4a Mon Sep 17 00:00:00 2001 From: "Steven T. Cramer" Date: Thu, 2 Jul 2026 18:21:48 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(083):=20web-contracts-tests=20?= =?UTF-8?q?=E2=80=94=20host-free=20serialization=20round-trips=20(Decision?= =?UTF-8?q?=203=20build-out)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated Fixie+Shouldly project referencing web-contracts ONLY, so contract serialization is checkable in the contract-first window (before any server exists — the BFF workflow the skill teaches). dev test discovers it via the tests/ glob automatically. 7 tests targeting shapes where serialization can actually diverge (per the resolved RFC Decision 3 prioritization; trivial POCO round-trips deliberately skipped and the skip documented): - ctor+Guard Responses: camelCase JSON binds to constructor parameters, and Guard invariants run DURING deserialization (Guid.Empty roleId throws rather than materializing an invalid Response) - GetRoles.Response: ListResponse envelope (base get-only props via derived ctor) - GetRole.Query: source-generated [ApiRoute] route property round-trips - SharedProblemDetails: validation errors + traceId survive the Extensions extension-data catch-all in both directions Inference-removal candidate (standing directive): canonical JsonSerializerOptions now exists in three places by convention (web-spa program.cs, web-spa-integration-tests, this project) — hoist one declaration into foundation-contracts. Co-Authored-By: Claude Fable 5 --- ...-project-with-serialization-round-trips.md | 51 ++++++++ ...-project-with-serialization-round-trips.md | 40 ------- .../contract-serialization.cs | 32 +++++ .../role-contracts-serialization-tests.cs | 109 ++++++++++++++++++ .../web/web-contracts-tests/global-usings.cs | 7 ++ .../web-contracts-tests/testing-convention.cs | 7 ++ ...red-problem-details-serialization-tests.cs | 48 ++++++++ .../web-contracts-tests.csproj | 16 +++ timewarp-architecture.slnx | 1 + 9 files changed, 271 insertions(+), 40 deletions(-) create mode 100644 kanban/done/083-create-web-contracts-tests-project-with-serialization-round-trips.md delete mode 100644 kanban/to-do/083-create-web-contracts-tests-project-with-serialization-round-trips.md create mode 100644 tests/container-apps/web/web-contracts-tests/contract-serialization.cs create mode 100644 tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs create mode 100644 tests/container-apps/web/web-contracts-tests/global-usings.cs create mode 100644 tests/container-apps/web/web-contracts-tests/testing-convention.cs create mode 100644 tests/container-apps/web/web-contracts-tests/types/shared-problem-details-serialization-tests.cs create mode 100644 tests/container-apps/web/web-contracts-tests/web-contracts-tests.csproj diff --git a/kanban/done/083-create-web-contracts-tests-project-with-serialization-round-trips.md b/kanban/done/083-create-web-contracts-tests-project-with-serialization-round-trips.md new file mode 100644 index 00000000..e3149045 --- /dev/null +++ b/kanban/done/083-create-web-contracts-tests-project-with-serialization-round-trips.md @@ -0,0 +1,51 @@ +# Create web-contracts-tests project with serialization round-trips + +## Description + +Build-out of **RFC Decision 3 (maintainer-resolved)**: a dedicated, host-free contracts test +project so serialization is checkable in the contract-first window (contracts are authored before +the server exists in the BFF workflow — maintainer testimony from copic: the contract tests were +the only seam check until backend integration tests arrived later). + +Create `tests/container-apps/web/web-contracts-tests/` (Fixie + Shouldly + TimeWarp.Fixie — +mirror `timewarp-architecture-analyzers-tests` wiring) with `SerializeAndDeserialize` round-trips +using camelCase `JsonSerializerOptions`. + +**Prioritize by GLM's trigger list** (per the resolved decision — this is what to test, not a gate): +contracts using `required`/`init` members, non-default constructors (all the ctor+`Guard` +Responses!), custom converters, `OneOf`/`SharedProblemDetails` envelopes, `ListResponse`. +Plain auto-property POCOs are low-priority. + +## Checklist + +- [x] Project scaffold — host-free: references **web-contracts only** (no server, no host); added + to `.slnx` `#if (web)` region. `dev test` discovery confirmed: it globs `tests/**/*.csproj`, + so no wiring needed (and the glob survives template feature-flag exclusion). +- [x] Roles round-trips: `CreateRole.Command`/`Response`, `GetRole.Response`, `GetRoles.Response` + (`ListResponse` envelope with ctor+Guard DTOs). +- [x] Edge shapes: `SharedProblemDetails` losslessness (validation `errors` + `traceId` survive the + Extensions catch-all in both directions); `GetRole.Query` round-trips its **source-generated** + `RoleId` route property. **Bonus test:** Guard invariants run *during deserialization* — a + `Guid.Empty` `roleId` payload throws rather than materializing an invalid `Response`. +- [x] Trivial-POCO skip documented in `contract-serialization.cs` Design region (single authority + for options + RoundTrip helper). +- [x] No validator tests (skill rule). + +## Results + +**7 tests, all passing in ~0.07s** — genuinely host-free; runs in the contract-first window. + +**Inference-removal candidate surfaced (per standing directive):** the canonical +`JsonSerializerOptions` (CamelCase) now exists in **three places by convention** — web-spa +`program.cs` DI config, the old web-spa-integration-tests serialization test, and +`contract-serialization.cs` here. Candidate: hoist one canonical options declaration into +foundation-contracts and reference it everywhere. Recorded in the test file's Design region. + +## Notes + +- Skill spec: `skills/web-api-contracts/SKILL.md` §"Contract serialization tests" + Tier 4 in + `references/examples.md` (Shouldly assertions). +- Rationale + testimony recorded in [[contract-conventions-rfc]] Decision 3. +- Copic's `Web.Contracts.Tests` (23 test files, FluentAssertions) is the read-only reference shape. +- Consider whether the existing round-trips in `web-spa-integration-tests/Serialization/` move + here or stay (they run under the Docker-dependent Aspire host; moving them makes them host-free). diff --git a/kanban/to-do/083-create-web-contracts-tests-project-with-serialization-round-trips.md b/kanban/to-do/083-create-web-contracts-tests-project-with-serialization-round-trips.md deleted file mode 100644 index a543a655..00000000 --- a/kanban/to-do/083-create-web-contracts-tests-project-with-serialization-round-trips.md +++ /dev/null @@ -1,40 +0,0 @@ -# Create web-contracts-tests project with serialization round-trips - -## Description - -Build-out of **RFC Decision 3 (maintainer-resolved)**: a dedicated, host-free contracts test -project so serialization is checkable in the contract-first window (contracts are authored before -the server exists in the BFF workflow — maintainer testimony from copic: the contract tests were -the only seam check until backend integration tests arrived later). - -Create `tests/container-apps/web/web-contracts-tests/` (Fixie + Shouldly + TimeWarp.Fixie — -mirror `timewarp-architecture-analyzers-tests` wiring) with `SerializeAndDeserialize` round-trips -using camelCase `JsonSerializerOptions`. - -**Prioritize by GLM's trigger list** (per the resolved decision — this is what to test, not a gate): -contracts using `required`/`init` members, non-default constructors (all the ctor+`Guard` -Responses!), custom converters, `OneOf`/`SharedProblemDetails` envelopes, `ListResponse`. -Plain auto-property POCOs are low-priority. - -## Checklist - -- [ ] Project scaffold (`web-contracts-tests.csproj`, `testing-convention.cs`, `global-usings.cs`); - add to `timewarp-architecture.slnx` (inside the `#if (web)` region) and verify `dev test` - picks it up. -- [ ] Round-trips for the roles feature (the clean anchor): `CreateRole.Command`/`Response` - (ctor+Guard), `GetRole.Response` (`IRoleDetails` + ctor), `GetRoles.Response` - (`ListResponse` with ctor DTO — the highest-value target). -- [ ] Round-trips for envelope/edge shapes: a `SharedProblemDetails` payload, a query with - generated route properties (`[ApiRoute]` params deserialization). -- [ ] Deliberately **skip** trivial POCO round-trips (document the skip in the test project README - or convention comment) — Decision 3's prioritization, not laziness. -- [ ] Do NOT test validators in isolation here (skill rule; FluentValidation is integration-tested). - -## Notes - -- Skill spec: `skills/web-api-contracts/SKILL.md` §"Contract serialization tests" + Tier 4 in - `references/examples.md` (Shouldly assertions). -- Rationale + testimony recorded in [[contract-conventions-rfc]] Decision 3. -- Copic's `Web.Contracts.Tests` (23 test files, FluentAssertions) is the read-only reference shape. -- Consider whether the existing round-trips in `web-spa-integration-tests/Serialization/` move - here or stay (they run under the Docker-dependent Aspire host; moving them makes them host-free). diff --git a/tests/container-apps/web/web-contracts-tests/contract-serialization.cs b/tests/container-apps/web/web-contracts-tests/contract-serialization.cs new file mode 100644 index 00000000..97086e31 --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/contract-serialization.cs @@ -0,0 +1,32 @@ +#region Purpose +// Single authority for the serializer options and round-trip helper used by every contract test. +#endregion + +#region Design +// Options mirror the SPA client's DI configuration (web-spa program.cs: CamelCase naming) — the +// seam these tests guard is "what the client writes is what the client reads back". +// Candidate improvement: hoist the canonical JsonSerializerOptions into foundation-contracts so +// the client, mocks, and these tests share one declaration instead of agreeing by convention. +// Trivial auto-property POCO round-trips are deliberately NOT written here: they cannot fail +// under default System.Text.Json. Tests target shapes where serialization can actually diverge — +// parameterized ctors with Guard clauses, ListResponse envelopes, source-generated route +// properties, and SharedProblemDetails extension-data losslessness. +#endregion + +namespace TimeWarp.Architecture.Web.Contracts.Tests; + +internal static class ContractSerialization +{ + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static T RoundTrip(T value) where T : class + { + string json = JsonSerializer.Serialize(value, Options); + T? parsed = JsonSerializer.Deserialize(json, Options); + parsed.ShouldNotBeNull(); + return parsed; + } +} diff --git a/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs b/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs new file mode 100644 index 00000000..136151f4 --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs @@ -0,0 +1,109 @@ +#region Purpose +// Round-trip tests for the admin/roles contracts — the shapes where serialization can diverge. +#endregion + +#region Design +// Each test targets a specific deserialization mechanism, not a POCO copy: +// - ctor+Guard Responses prove System.Text.Json binds camelCase JSON to constructor parameters +// AND that Guard invariants run during deserialization (a Guid.Empty payload must throw, not +// materialize an invalid Response). +// - GetRoles.Response proves the ListResponse envelope (base get-only props via derived ctor). +// - GetRole.Query proves source-generated route properties ([ApiRoute] emits RoleId) serialize. +#endregion + +// ReSharper disable InconsistentNaming +namespace RoleContracts_; + +using TimeWarp.Architecture.Features.Admin.Roles; +using TimeWarp.Architecture.Features.Authorization; +using TimeWarp.Architecture.Web.Contracts.Tests; + +public class CreateRole_Command_Should +{ + public static void SerializeAndDeserialize() + { + CreateRole.Command command = new() + { + UserId = Guid.NewGuid(), + Name = "Auditor", + Description = "Read-only access to financial modules." + }; + + CreateRole.Command parsed = ContractSerialization.RoundTrip(command); + + parsed.UserId.ShouldBe(command.UserId); + parsed.Name.ShouldBe(command.Name); + parsed.Description.ShouldBe(command.Description); + } +} + +public class CreateRole_Response_Should +{ + public static void SerializeAndDeserialize_Via_Constructor() + { + CreateRole.Response response = new(RoleIds.Administrator); + + CreateRole.Response parsed = ContractSerialization.RoundTrip(response); + + parsed.RoleId.ShouldBe(RoleIds.Administrator); + } + + public static void Reject_EmptyGuid_During_Deserialization() + { + // The ctor Guard is the Response's invariant gate; deserialization must not bypass it. + string json = """{"roleId":"00000000-0000-0000-0000-000000000000"}"""; + + Should.Throw(() => + JsonSerializer.Deserialize(json, ContractSerialization.Options)); + } +} + +public class GetRole_Query_Should +{ + public static void SerializeAndDeserialize_Including_Generated_RouteProperty() + { + // RoleId is not declared in get-role.cs — [ApiRoute("api/Roles/{RoleId:min(1)}")] generates it. + GetRole.Query query = new() { RoleId = 42, UserId = Guid.NewGuid() }; + + GetRole.Query parsed = ContractSerialization.RoundTrip(query); + + parsed.RoleId.ShouldBe(42); + parsed.UserId.ShouldBe(query.UserId); + } +} + +public class GetRole_Response_Should +{ + public static void SerializeAndDeserialize_Via_Constructor() + { + GetRole.Response response = new(RoleIds.Accountant, "Accountant", "Accounting module access."); + + GetRole.Response parsed = ContractSerialization.RoundTrip(response); + + parsed.RoleId.ShouldBe(RoleIds.Accountant); + parsed.Name.ShouldBe("Accountant"); + parsed.Description.ShouldBe("Accounting module access."); + } +} + +public class GetRoles_Response_Should +{ + public static void SerializeAndDeserialize_ListResponse_Envelope() + { + GetRoles.RoleDto[] items = + [ + new(RoleIds.Administrator, "Administrator", "All modules."), + new(RoleIds.Accountant, "Accountant", "Accounting module.") + ]; + GetRoles.Response response = new(totalCount: 7, items); + + GetRoles.Response parsed = ContractSerialization.RoundTrip(response); + + parsed.TotalCount.ShouldBe(7); + parsed.Items.Length.ShouldBe(2); + parsed.Items[0].RoleId.ShouldBe(RoleIds.Administrator); + parsed.Items[0].Name.ShouldBe("Administrator"); + parsed.Items[1].RoleId.ShouldBe(RoleIds.Accountant); + parsed.Items[1].Description.ShouldBe("Accounting module."); + } +} diff --git a/tests/container-apps/web/web-contracts-tests/global-usings.cs b/tests/container-apps/web/web-contracts-tests/global-usings.cs new file mode 100644 index 00000000..6d1a3120 --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/global-usings.cs @@ -0,0 +1,7 @@ +#region Purpose +// Project-wide using directives so individual files omit repeated imports. +#endregion + +global using System.Text.Json; +global using Shouldly; +global using TimeWarp.Foundation.Features; diff --git a/tests/container-apps/web/web-contracts-tests/testing-convention.cs b/tests/container-apps/web/web-contracts-tests/testing-convention.cs new file mode 100644 index 00000000..6a739e3f --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/testing-convention.cs @@ -0,0 +1,7 @@ +#region Purpose +// Wires this project into the TimeWarp.Fixie testing convention (discovery + lifecycle). +#endregion + +namespace TimeWarp.Architecture.Web.Contracts.Tests; + +class TestingConvention : TimeWarp.Fixie.TestingConvention { } diff --git a/tests/container-apps/web/web-contracts-tests/types/shared-problem-details-serialization-tests.cs b/tests/container-apps/web/web-contracts-tests/types/shared-problem-details-serialization-tests.cs new file mode 100644 index 00000000..a58047dd --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/types/shared-problem-details-serialization-tests.cs @@ -0,0 +1,48 @@ +#region Purpose +// Proves SharedProblemDetails round-trips server ProblemDetails payloads losslessly. +#endregion + +#region Design +// The type's contract (see its Design region) is lossless interchange with +// Microsoft.AspNetCore.Mvc.ProblemDetails — the validation "errors" dictionary must survive via +// the Extensions extension-data catch-all, since that is what the SPA renders for 400 responses. +#endregion + +// ReSharper disable InconsistentNaming +namespace SharedProblemDetails_; + +using TimeWarp.Architecture.Web.Contracts.Tests; +using TimeWarp.Foundation.Types; + +public class SharedProblemDetails_Should +{ + public static void SerializeAndDeserialize_Preserving_Extensions() + { + string serverPayload = + """ + { + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "detail": "See errors.", + "instance": "/api/Roles", + "errors": { "Name": ["'Name' must not be empty."] }, + "traceId": "00-abc-def-00" + } + """; + + SharedProblemDetails? parsed = + JsonSerializer.Deserialize(serverPayload, ContractSerialization.Options); + + parsed.ShouldNotBeNull(); + parsed.Status.ShouldBe(400); + parsed.Title.ShouldBe("One or more validation errors occurred."); + parsed.Extensions.ShouldContainKey("errors"); + parsed.Extensions.ShouldContainKey("traceId"); + + // And back out: what the client re-serializes must still carry the extension data. + string reserialized = JsonSerializer.Serialize(parsed, ContractSerialization.Options); + reserialized.ShouldContain("errors"); + reserialized.ShouldContain("traceId"); + } +} diff --git a/tests/container-apps/web/web-contracts-tests/web-contracts-tests.csproj b/tests/container-apps/web/web-contracts-tests/web-contracts-tests.csproj new file mode 100644 index 00000000..1534edc4 --- /dev/null +++ b/tests/container-apps/web/web-contracts-tests/web-contracts-tests.csproj @@ -0,0 +1,16 @@ + + + enable + enable + + + + + + + + + + + diff --git a/timewarp-architecture.slnx b/timewarp-architecture.slnx index a6bace5d..27bb3bbf 100644 --- a/timewarp-architecture.slnx +++ b/timewarp-architecture.slnx @@ -58,6 +58,7 @@ + From 502b939d16481a8d0b87b0c947445673618e36a3 Mon Sep 17 00:00:00 2001 From: "Steven T. Cramer" Date: Thu, 2 Jul 2026 18:30:30 +0700 Subject: [PATCH 2/2] feat(079): server-side CreateRole endpoint + backend validation; fix all roles route warts Completes the RoleForm round-trip: POST api/Roles now has a real endpoint (BaseEndpoint MVC shim, mirroring the web-server slice pattern) and handler (deliberate in-memory stub, documented). Backend validation costs zero new code: FluentValidationBehavior runs the contract's composed Validator (shared RoleDetailsValidator + AuthApiRequestValidator) server-side. Prerequisite fix: BaseEndpoint/BaseFastEndpoint constrained TResponse : BaseResponse, which would force Responses back into the shape RFC Decision 5 rejected -> loosened to class (Send uses no BaseResponse member; both bases kept aligned). Roles route warts fixed (unblocks 078 Edit/View): - get-role: {RoleId:min(1)} -> {RoleId:guid} - update-role: api/Role/{RoleId:int} -> api/Roles/{RoleId:guid}; removed stray `Guid Guid` property - delete-role: RPC-style api/DeleteRole -> api/Roles/{RoleId:guid}; removed hand-declared `required int RoleId` (generator emits it from the route) The 083 serialization tests caught the generated-property type change (RoleId int -> Guid) exactly as the tests-first sequencing intended. Integration tests (web-server, real host): 200 + non-empty RoleId on valid command; 400 problem-details naming the property for empty Name and empty UserId. Suite 11 -> 18 passed. dev build 0/0; contracts 7/7; analyzer 21/21; sourcegen 14/14. New task 085: collected analyzer/source-gen inference-removal candidates (endpoint<->contract verb agreement, missing-endpoint detection, canonical JsonSerializerOptions, generated mock-factory registry, Aspire resource names, base-endpoint alignment). Co-Authored-By: Claude Fable 5 --- ...-idetails-binding-and-shared-validation.md | 4 +- ...oint--backend-validation-roles-contract.md | 67 +++++++++++++++++++ ...oint--backend-validation-roles-contract.md | 42 ------------ ...o-remove-inference-collected-candidates.md | 51 ++++++++++++++ .../analysis/contract-conventions-rfc.md | 9 ++- .../admin/roles/create-role-handler.cs | 33 +++++++++ .../admin/roles/commands/delete-role.cs | 8 +-- .../admin/roles/commands/update-role.cs | 5 +- .../features/admin/roles/queries/get-role.cs | 4 +- .../admin/roles/create-role-endpoint.cs | 28 ++++++++ .../admin/roles/feature-annotations.cs | 10 +++ .../foundation-server/base/base-endpoint.cs | 2 +- .../base/base-fast-endpoint.cs | 2 +- .../role-contracts-serialization-tests.cs | 7 +- .../CreateRole/CreateRole_Endpoint_Tests.cs | 55 +++++++++++++++ .../CreateRole/CreateRole_Handler_Tests.cs | 38 +++++++++++ .../CreateRole/CreateRole_Validator_Tests.cs | 44 ++++++++++++ 17 files changed, 349 insertions(+), 60 deletions(-) create mode 100644 kanban/done/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md delete mode 100644 kanban/to-do/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md create mode 100644 kanban/to-do/085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates.md create mode 100644 source/container-apps/web/web-application/features/admin/roles/create-role-handler.cs create mode 100644 source/container-apps/web/web-server/features/admin/roles/create-role-endpoint.cs create mode 100644 source/container-apps/web/web-server/features/admin/roles/feature-annotations.cs create mode 100644 tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Endpoint_Tests.cs create mode 100644 tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Handler_Tests.cs create mode 100644 tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Validator_Tests.cs diff --git a/kanban/done/078-add-roleform-demonstrating-idetails-binding-and-shared-validation.md b/kanban/done/078-add-roleform-demonstrating-idetails-binding-and-shared-validation.md index d617139b..fc68bff9 100644 --- a/kanban/done/078-add-roleform-demonstrating-idetails-binding-and-shared-validation.md +++ b/kanban/done/078-add-roleform-demonstrating-idetails-binding-and-shared-validation.md @@ -48,7 +48,9 @@ the template a living reference for "one shape, shared validation, no separate v - [ ] **Edit/View modes** — deferred: `GetRole`/`UpdateRole` routes have a `RoleId` `int`-vs-`Guid` wart (`update-role.cs` uses `api/Role/{RoleId:int}`) that belongs to the contracts cleanup. New mode fully demonstrates the binding+validation pattern; Edit/View is follow-up. -- [ ] **Save round-trip** — deferred to +- [x] **Save round-trip** — RESOLVED by task 079 (2026-07-02): POST api/Roles now has a real + server endpoint + handler with backend validation; integration tests prove 200 + RoleId and + 400 problem-details on invalid input. Originally deferred to [[079-implement-server-side-createrole-endpoint--backend-validation-roles-contract]]. On Save the valid `POST api/Roles` reaches the **real** web-server, which has no `CreateRole` endpoint yet → **405** → generic error toast (confirmed via Aspire console logs). Expected: this task owns the diff --git a/kanban/done/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md b/kanban/done/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md new file mode 100644 index 00000000..c0e083a1 --- /dev/null +++ b/kanban/done/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md @@ -0,0 +1,67 @@ +# Implement server-side CreateRole endpoint + backend validation (roles contract) + +## Description + +The `admin/roles` contract is the **clean, exemplary** contract (`IRoleDetails` + +`RoleDetailsValidator`, `CreateRole`/`UpdateRole`/`GetRole` commands) but has **contracts only — +no server-side implementation**. Task 078 built the client half (`RoleForm` → dispatches a valid +`CreateRole.Command`); on Save the request reaches the **real web-server**, which has no +`POST api/Roles` handler, so it returns **405 Method Not Allowed** and the client surfaces the +generic error toast. This is expected and correct until the server half exists. + +Build the **server-side `CreateRole` slice** so the whole contract use case works end-to-end, +including **backend validation** (the same `RoleDetailsValidator` / `AbstractValidator` +shape enforced server-side, not just in the browser — never trust the client). + +## Evidence / current state +- Client dispatches `POST api/Roles` (`create-role.cs`: `[RouteMixin("api/Roles", HttpVerb.Post)]`). +- Aspire console log (web-server) confirms: `POST .../api/Roles - 405` — route answers GET only, no POST. +- No server-side role files exist under the web-server / web-application (contracts-only feature). +- `CreateRole.GetMockResponseFactory()` + mock registration already exist (078) — the client works + under `MOCK_WEB_API`; this task makes it work against the **real** server. + +## Checklist +- [x] Server `CreateRole` Endpoint + Handler — **note:** web-server uses the hand-written + MVC-style `BaseEndpoint` pattern (mirrored `TrackEventEndpoint`), not + FastEndpoints (that's api-server's path). Files: + `web-server/features/admin/roles/{feature-annotations,create-role-endpoint}.cs`, + `web-application/features/admin/roles/create-role-handler.cs`. No mapper needed — the + Command *is* the pipeline message. +- [x] Backend validation: **zero new code** — `FluentValidationBehavior` (already in the mediator + pipeline) + `AddValidatorsFromAssemblyContaining` run the + contract's composed Validator (shared `RoleDetailsValidator` + `AuthApiRequestValidator`) + server-side. Integration tests prove both rejection paths (empty Name, empty UserId → 400 + problem details naming the property). +- [x] Persistence: **deliberate in-memory stub, documented** in the handler's Design region + (static `ConcurrentDictionary`) — roles are a template demo feature; swap for a repository + when real, contract/endpoint unchanged. +- [x] End-to-end verification via **web-server integration tests** (real host, real pipeline): + `POST api/Roles` → 200 + non-empty `RoleId`; 18 passed (was 11). Manual RoleForm-in-browser + check pending healthy local Docker (Aspire) — the integration tests cover the same seam. +- [x] 078's Save-round-trip checkbox flipped (kanban/done/078, with resolution note). + +## Results — beyond the checklist + +- **Unblocked by prerequisite fix:** `BaseEndpoint`/`BaseFastEndpoint` constrained + `TResponse : BaseResponse`, which would have forced `CreateRole.Response` back into the + `BaseResponse` shape that RFC Decision 5 rejected. Loosened to `where TResponse : class` + (`Send` uses no `BaseResponse` member; both bases kept aligned). +- **All roles route warts fixed** (was: blocks 078 Edit/View): `get-role.cs` + `{RoleId:min(1)}`→`{RoleId:guid}`; `update-role.cs` `api/Role/{RoleId:int}`→ + `api/Roles/{RoleId:guid}` **+ removed stray `Guid Guid` property**; `delete-role.cs` RPC-style + `api/DeleteRole`→`api/Roles/{RoleId:guid}` **+ removed hand-declared `required int RoleId`** + (generator emits the Guid from the route). Serialization test updated (RoleId now `Guid`) — + the 083 tests caught the contract change, as intended by the tests-first sequencing. +- Verified: `dev build` 0/0; web-server integration 18 passed; contracts round-trips 7/7; + analyzer 21/21; sourcegen 14/14. + +## Notes +- **Contract warts to resolve first (or alongside):** `update-role.cs` /`get-role.cs` route uses + `api/Role/{RoleId:int}` — `RoleId` typed **`int`** in the route but **`Guid`** everywhere else, and + **singular** `api/Role` vs plural `api/Roles`. This blocks 078's Edit/View modes and should be fixed + as part of the contracts cleanup. See [[contract-conventions-rfc]] / + [[077-contracts-compliance-01-nullability-validator-agreement]]. +- Ties into the open FastEndpoints source-gen rename work (task 053-002: `[RouteMixin]` →`[Route]`); + keep the new endpoint consistent with whatever attribute naming lands. +- Follow-up to [[078-add-roleform-demonstrating-idetails-binding-and-shared-validation]] — that task + proved the client binding + validation; this one completes the round-trip. diff --git a/kanban/to-do/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md b/kanban/to-do/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md deleted file mode 100644 index 09e75c70..00000000 --- a/kanban/to-do/079-implement-server-side-createrole-endpoint--backend-validation-roles-contract.md +++ /dev/null @@ -1,42 +0,0 @@ -# Implement server-side CreateRole endpoint + backend validation (roles contract) - -## Description - -The `admin/roles` contract is the **clean, exemplary** contract (`IRoleDetails` + -`RoleDetailsValidator`, `CreateRole`/`UpdateRole`/`GetRole` commands) but has **contracts only — -no server-side implementation**. Task 078 built the client half (`RoleForm` → dispatches a valid -`CreateRole.Command`); on Save the request reaches the **real web-server**, which has no -`POST api/Roles` handler, so it returns **405 Method Not Allowed** and the client surfaces the -generic error toast. This is expected and correct until the server half exists. - -Build the **server-side `CreateRole` slice** so the whole contract use case works end-to-end, -including **backend validation** (the same `RoleDetailsValidator` / `AbstractValidator` -shape enforced server-side, not just in the browser — never trust the client). - -## Evidence / current state -- Client dispatches `POST api/Roles` (`create-role.cs`: `[RouteMixin("api/Roles", HttpVerb.Post)]`). -- Aspire console log (web-server) confirms: `POST .../api/Roles - 405` — route answers GET only, no POST. -- No server-side role files exist under the web-server / web-application (contracts-only feature). -- `CreateRole.GetMockResponseFactory()` + mock registration already exist (078) — the client works - under `MOCK_WEB_API`; this task makes it work against the **real** server. - -## Checklist -- [ ] Server `CreateRole` Endpoint + Handler + Mapper (FastEndpoint pattern; mirror an existing - web-server command slice, e.g. weather-forecast/todo-item write paths). -- [ ] Backend validation wired: enforce the shared `IRoleDetails` rules server-side (do not - re-declare — reuse `RoleDetailsValidator`). -- [ ] Persistence/store for roles (or a deliberate in-memory stub if roles aren't a real feature yet - — decide and document which). -- [ ] Verify end-to-end from `RoleForm` Save: `POST api/Roles` → 200 + `Response(RoleId)`, no toast. -- [ ] Then flip 078's manual-verification checkbox for the real (non-mock) path. - -## Notes -- **Contract warts to resolve first (or alongside):** `update-role.cs` /`get-role.cs` route uses - `api/Role/{RoleId:int}` — `RoleId` typed **`int`** in the route but **`Guid`** everywhere else, and - **singular** `api/Role` vs plural `api/Roles`. This blocks 078's Edit/View modes and should be fixed - as part of the contracts cleanup. See [[contract-conventions-rfc]] / - [[077-contracts-compliance-01-nullability-validator-agreement]]. -- Ties into the open FastEndpoints source-gen rename work (task 053-002: `[RouteMixin]` →`[Route]`); - keep the new endpoint consistent with whatever attribute naming lands. -- Follow-up to [[078-add-roleform-demonstrating-idetails-binding-and-shared-validation]] — that task - proved the client binding + validation; this one completes the round-trip. diff --git a/kanban/to-do/085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates.md b/kanban/to-do/085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates.md new file mode 100644 index 00000000..d828b5a6 --- /dev/null +++ b/kanban/to-do/085-analyzer-and-source-gen-opportunities-to-remove-inference-collected-candidates.md @@ -0,0 +1,51 @@ +# Analyzer and source-gen opportunities to remove inference (collected candidates) + +## Description + +Running collection per the maintainer directive: **spot places where correctness depends on two +things agreeing by memory/convention, and replace the inference with an analyzer or generator.** +Each candidate below names the agreement, how it breaks, and the proposed mechanism. Pick +individually — each accepted candidate becomes its own task (080-recipe: ship + reconcile in one +PR). Infrastructure exists: `timewarp-architecture-convention-analyzers` (wired repo-wide) for +checks; the generator assemblies for emission. + +## Candidates (ranked by observed pain) + +1. **Endpoint HTTP verb ↔ contract `[ApiRoute]` verb** — `CreateRoleEndpoint` says `[HttpPost]`, + the contract says `HttpVerb.Post`; nothing checks they match. Drift = the exact 405/mismatch + bug class hit in task 078. *Analyzer* (in server projects): for each `BaseEndpoint`, + compare the `[Http*]` attribute against `TCommand`'s `ApiRoute` verb (readable from referenced + contract metadata). **Highest value — this bug class already bit us.** +2. **Contract has `[ApiRoute]` but no server endpoint** — the 405 itself: roles contracts existed + for months with no `POST api/Roles`. *Analyzer* (server projects): enumerate referenced contract + types carrying `RouteTemplate` and warn when no endpoint class covers them. Needs an opt-out for + deliberately client-only/mock-only contracts (attribute or config list). +3. **Canonical `JsonSerializerOptions` declared 3× by convention** — web-spa `program.cs` DI + config, web-spa-integration-tests, and web-contracts-tests (`contract-serialization.cs` Design + region documents this). *Refactor, not analyzer*: hoist one canonical options declaration into + foundation-contracts; everyone references it. Silent-drift risk today: client and tests could + diverge without any signal. +4. **Mock factory registration ↔ `GetMockResponseFactory()`** — a contract can define a factory + that is never registered in `MockWebApiService.Factories` (or vice versa); task 078 had to + hand-add the registration. *Source generator* (preferred): generate the `Factories` dictionary + by scanning referenced contract types for `GetMockResponseFactory()` — the registration step + disappears entirely. (*Analyzer* fallback: warn on unregistered factories.) +5. **Aspire resource names ↔ `ServiceNames` constants** — documented trap (memory: + `aspire-resource-names-must-match-servicenames`): mismatch → server-side `BaseAddress` resolves + null, only under Auto render mode. *Analyzer* (app-host project): literals passed to + `AddProject(...)` resource names must appear in `ServiceNames`. +6. **`BaseEndpoint` ↔ `BaseFastEndpoint` "keep semantics aligned"** — the agreement lives in a + Design-region comment only (and 079 just edited both constraints in lockstep by hand). Weakest + candidate: consider extracting the shared `Match`/problem-mapping logic instead of checking + alignment; an analyzer here is probably over-engineering. + +## Checklist + +- [ ] Maintainer triage: accept/reject each candidate; spawn tasks for accepted ones. +- [ ] Keep appending future candidates here (standing directive) until triaged. + +## Notes + +- Directive + seed list recorded in memory (`prefer-analyzers-sourcegen-over-inference`). +- Candidates 1+2 could ship together as one "endpoint coverage" analyzer in the convention + assembly (both walk server compilations against referenced contract metadata). diff --git a/skills/web-api-contracts/analysis/contract-conventions-rfc.md b/skills/web-api-contracts/analysis/contract-conventions-rfc.md index ff5f4813..4bd1e5c0 100644 --- a/skills/web-api-contracts/analysis/contract-conventions-rfc.md +++ b/skills/web-api-contracts/analysis/contract-conventions-rfc.md @@ -474,9 +474,12 @@ propose a third option — with reasoning.** stubs; `Response : BaseResponse`. - ~~`features/hello/hello.cs` — `string?` + `NotEmpty()`~~ ✅ (`Name` → `string` + `= null!`). - ~~`features/analytics/track-event.cs` — `string?` + `NotEmpty()`~~ ✅ (`EventName` → `string` + `= null!`). -- `features/admin/roles` — conforms, **except** the `update-role.cs`/`get-role.cs` route wart found - by 078: `api/Role/{RoleId:int}` (singular route + `int` route param vs `Guid` everywhere else). - Tracked with the server-side slice in kanban task 079. +- ~~`features/admin/roles` — conforms, **except** the `update-role.cs`/`get-role.cs` route wart found + by 078: `api/Role/{RoleId:int}` (singular route + `int` route param vs `Guid` everywhere else)~~ + ✅ **fixed by task 079 (2026-07-02)**: all roles routes are plural resource routes with + `{RoleId:guid}`; also removed `update-role.cs`'s stray `Guid Guid` property and `delete-role.cs`'s + RPC-style `api/DeleteRole` route + hand-declared `int RoleId`. Server-side CreateRole endpoint + + handler + backend validation landed with integration tests. - Everything else (`profile`, `auth`, `authentication`, `chat`) already conforms. **Open sub-question:** is `todo-items` a real feature or a template placeholder? Its two empty query diff --git a/source/container-apps/web/web-application/features/admin/roles/create-role-handler.cs b/source/container-apps/web/web-application/features/admin/roles/create-role-handler.cs new file mode 100644 index 00000000..ad736642 --- /dev/null +++ b/source/container-apps/web/web-application/features/admin/roles/create-role-handler.cs @@ -0,0 +1,33 @@ +#region Purpose +// Server-side handler for the CreateRole command. +#endregion + +#region Design +// Storage is a deliberate in-memory stub: roles are a template demonstration feature with no +// domain persistence yet, and the handler's job here is to complete the contract round-trip +// (validated Command in, Response with the new id out). Replace the store with a repository +// when roles become a real feature; the contract and endpoint do not change. +// Input validation does NOT belong here — FluentValidationBehavior already ran the contract's +// Validator (shared RoleDetailsValidator + AuthApiRequestValidator) before this executes. +#endregion + +namespace TimeWarp.Architecture.Features.Admin.Roles.Application; + +using System.Collections.Concurrent; +using static TimeWarp.Architecture.Features.Admin.Roles.CreateRole; + +public sealed partial class CreateRole +{ + public class Handler : IRequestHandler> + { + private static readonly ConcurrentDictionary Store = new(); + + public Task> Handle(Command command, CancellationToken cancellationToken) + { + var roleId = Guid.NewGuid(); + Store.TryAdd(roleId, (command.Name, command.Description)); + + return Task.FromResult((OneOf)new Response(roleId)); + } + } +} diff --git a/source/container-apps/web/web-contracts/features/admin/roles/commands/delete-role.cs b/source/container-apps/web/web-contracts/features/admin/roles/commands/delete-role.cs index 123c0fb0..ab714da0 100644 --- a/source/container-apps/web/web-contracts/features/admin/roles/commands/delete-role.cs +++ b/source/container-apps/web/web-contracts/features/admin/roles/commands/delete-role.cs @@ -3,7 +3,8 @@ #endregion #region Design -// [ApiRoute] drives source generation of the FastEndpoint and route members (hence partial). +// [ApiRoute] drives source generation of the route members (hence partial); RoleId comes from +// the {RoleId:guid} route segment, not a hand-declared property. // The empty Response exists to keep the uniform OneOf // pipeline even though a delete has no payload — callers still get success/problem typing. // GetMockResponseFactory lets the SPA's MockWebApiService serve this endpoint offline. @@ -13,18 +14,17 @@ namespace TimeWarp.Architecture.Features.Admin.Roles; public static partial class DeleteRole { - [ApiRoute("api/DeleteRole", HttpVerb.Delete)] + [ApiRoute("api/Roles/{RoleId:guid}", HttpVerb.Delete)] public sealed partial class Command : IAuthApiRequest, IRequest> { public Guid UserId { get; set; } - public required int RoleId { get; init; } } public sealed class Validator : AbstractValidator { public Validator() { - RuleFor(x => x.RoleId).NotEmpty().GreaterThan(0); + RuleFor(x => x.RoleId).NotEmpty(); RuleFor(x => x).SetValidator(new AuthApiRequestValidator()); } } diff --git a/source/container-apps/web/web-contracts/features/admin/roles/commands/update-role.cs b/source/container-apps/web/web-contracts/features/admin/roles/commands/update-role.cs index ed2bb784..5f2b5b51 100644 --- a/source/container-apps/web/web-contracts/features/admin/roles/commands/update-role.cs +++ b/source/container-apps/web/web-contracts/features/admin/roles/commands/update-role.cs @@ -3,7 +3,7 @@ #endregion #region Design -// The RoleId the Validator references is not declared here: the {RoleId:int} segment in +// The RoleId the Validator references is not declared here: the {RoleId:guid} segment in // [ApiRoute] makes the source generator emit it on the partial Command, keeping route and // body in one type. IRoleDetails lets the Validator compose the shared RoleDetailsValidator // so update enforces the same field rules as create. GetMockResponseFactory lets the SPA's @@ -14,11 +14,10 @@ namespace TimeWarp.Architecture.Features.Admin.Roles; public static partial class UpdateRole { - [ApiRoute("api/Role/{RoleId:int}", HttpVerb.Put)] + [ApiRoute("api/Roles/{RoleId:guid}", HttpVerb.Put)] public sealed partial class Command : IAuthApiRequest, IRoleDetails, IRequest> { public Guid UserId { get; set; } - public Guid Guid { get; init; } public string Name { get; set; } = null!; public string Description { get; set; } = null!; } diff --git a/source/container-apps/web/web-contracts/features/admin/roles/queries/get-role.cs b/source/container-apps/web/web-contracts/features/admin/roles/queries/get-role.cs index 28f8a564..5287af10 100644 --- a/source/container-apps/web/web-contracts/features/admin/roles/queries/get-role.cs +++ b/source/container-apps/web/web-contracts/features/admin/roles/queries/get-role.cs @@ -3,7 +3,7 @@ #endregion #region Design -// RoleId is not declared on the Query: the {RoleId:min(1)} segment in [ApiRoute] makes the +// RoleId is not declared on the Query: the {RoleId:guid} segment in [ApiRoute] makes the // source generator emit it on the partial, and the min(1) route constraint replaces a // FluentValidation rule for it. Response implements IRoleDetails so the edit form binds the // same shape it submits via UpdateRole. GetMockResponseFactory lets the SPA's @@ -19,7 +19,7 @@ namespace TimeWarp.Architecture.Features.Admin.Roles; /// public static partial class GetRole { - [ApiRoute("api/Roles/{RoleId:min(1)}", HttpVerb.Get)] + [ApiRoute("api/Roles/{RoleId:guid}", HttpVerb.Get)] public sealed partial class Query : IAuthApiRequest, IRequest> { public Guid UserId { get; set; } diff --git a/source/container-apps/web/web-server/features/admin/roles/create-role-endpoint.cs b/source/container-apps/web/web-server/features/admin/roles/create-role-endpoint.cs new file mode 100644 index 00000000..2e931873 --- /dev/null +++ b/source/container-apps/web/web-server/features/admin/roles/create-role-endpoint.cs @@ -0,0 +1,28 @@ +#region Purpose +// HTTP surface for the CreateRole command; delegates all behavior to the mediator pipeline. +#endregion + +#region Design +// Endpoint-centric pattern: a one-line shim so validation (FluentValidationBehavior runs the +// contract's Validator — RoleDetailsValidator + AuthApiRequestValidator — server-side) and +// handling live in the mediator pipeline. `using static CreateRole` binds the contract's nested +// Command/Response, and Command.RouteTemplate keeps the URL owned by the contract so client and +// server cannot drift. +#endregion + +namespace TimeWarp.Architecture.Features.Admin.Roles; + +using static CreateRole; + +public class CreateRoleEndpoint : BaseEndpoint +{ + /// + /// Create a new role + /// + /// + /// carrying the new role's id + [HttpPost(Command.RouteTemplate)] + [ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ProblemDetails), (int)HttpStatusCode.BadRequest)] + public Task Process([FromBody] Command command) => Send(command); +} diff --git a/source/container-apps/web/web-server/features/admin/roles/feature-annotations.cs b/source/container-apps/web/web-server/features/admin/roles/feature-annotations.cs new file mode 100644 index 00000000..21edcac0 --- /dev/null +++ b/source/container-apps/web/web-server/features/admin/roles/feature-annotations.cs @@ -0,0 +1,10 @@ +#region Purpose +// Names the Roles feature group as a constant for tagging its endpoints in API metadata. +#endregion + +namespace TimeWarp.Architecture.Features.Admin.Roles; + +public static class FeatureAnnotations +{ + public const string FeatureGroup = "Roles"; +} diff --git a/source/foundation/foundation-server/base/base-endpoint.cs b/source/foundation/foundation-server/base/base-endpoint.cs index bfa6ff55..f419a358 100644 --- a/source/foundation/foundation-server/base/base-endpoint.cs +++ b/source/foundation/foundation-server/base/base-endpoint.cs @@ -18,7 +18,7 @@ namespace TimeWarp.Foundation.Features; [Produces("application/json")] public class BaseEndpoint : ControllerBase where TRequest : IRequest> - where TResponse : BaseResponse + where TResponse : class { private ISender Sender => HttpContext?.RequestServices.GetRequiredService() ?? throw new InvalidOperationException("ISender is not available."); diff --git a/source/foundation/foundation-server/base/base-fast-endpoint.cs b/source/foundation/foundation-server/base/base-fast-endpoint.cs index e91b5b9b..84862902 100644 --- a/source/foundation/foundation-server/base/base-fast-endpoint.cs +++ b/source/foundation/foundation-server/base/base-fast-endpoint.cs @@ -16,7 +16,7 @@ namespace TimeWarp.Foundation.Features; public abstract class BaseFastEndpoint : Endpoint> where TRequest : IRequest> - where TResponse : BaseResponse + where TResponse : class { private ISender Sender => HttpContext?.RequestServices.GetRequiredService() ?? throw new InvalidOperationException("ISender is not available."); diff --git a/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs b/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs index 136151f4..be557ed7 100644 --- a/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs +++ b/tests/container-apps/web/web-contracts-tests/features/admin/roles/role-contracts-serialization-tests.cs @@ -62,12 +62,13 @@ public class GetRole_Query_Should { public static void SerializeAndDeserialize_Including_Generated_RouteProperty() { - // RoleId is not declared in get-role.cs — [ApiRoute("api/Roles/{RoleId:min(1)}")] generates it. - GetRole.Query query = new() { RoleId = 42, UserId = Guid.NewGuid() }; + // RoleId is not declared in get-role.cs — [ApiRoute("api/Roles/{RoleId:guid}")] generates it. + Guid roleId = Guid.NewGuid(); + GetRole.Query query = new() { RoleId = roleId, UserId = Guid.NewGuid() }; GetRole.Query parsed = ContractSerialization.RoundTrip(query); - parsed.RoleId.ShouldBe(42); + parsed.RoleId.ShouldBe(roleId); parsed.UserId.ShouldBe(query.UserId); } } diff --git a/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Endpoint_Tests.cs b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Endpoint_Tests.cs new file mode 100644 index 00000000..649ac41c --- /dev/null +++ b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Endpoint_Tests.cs @@ -0,0 +1,55 @@ +#region Purpose +// End-to-end tests for POST api/Roles: real host, real mediator pipeline, real backend validation. +#endregion + +namespace CreateRoleEndpoint_; + +using static TimeWarp.Architecture.Features.Admin.Roles.CreateRole; + +public class Returns_ +{ + private readonly Command Command; + private readonly WebTestServerApplication WebTestServerApplication; + + public Returns_ + ( + WebTestServerApplication webTestServerApplication + ) + { + Command = new Command + { + UserId = Guid.NewGuid(), + Name = "Auditor", + Description = "Read-only access to financial modules." + }; + WebTestServerApplication = webTestServerApplication; + } + + public async Task Ok_With_RoleId_Given_Valid_Command() + { + OneOf result = await WebTestServerApplication.Send(Command); + + result.Switch + ( + response => response.RoleId.ShouldNotBe(Guid.Empty), + problemDetails => problemDetails.ShouldBeNull("CreateRole returned SharedProblemDetails for a valid command.") + ); + } + + public async Task ValidationError_Given_Empty_Name() + { + // Backend validation: FluentValidationBehavior runs the contract's Validator + // (shared RoleDetailsValidator) server-side — the same rules the Blazor form enforced. + Command.Name = ""; + + await WebTestServerApplication.ConfirmEndpointValidationError(Command, nameof(Command.Name)); + } + + public async Task ValidationError_Given_Empty_UserId() + { + // AuthApiRequestValidator composes into the same server-side validation pass. + Command.UserId = Guid.Empty; + + await WebTestServerApplication.ConfirmEndpointValidationError(Command, nameof(Command.UserId)); + } +} diff --git a/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Handler_Tests.cs b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Handler_Tests.cs new file mode 100644 index 00000000..4111d39d --- /dev/null +++ b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Handler_Tests.cs @@ -0,0 +1,38 @@ +#region Purpose +// Handler-level test: a valid CreateRole command produces a Response with a real role id. +#endregion + +namespace CreateRoleHandler_; + +using static TimeWarp.Architecture.Features.Admin.Roles.CreateRole; + +public class Handle_Returns +{ + private readonly Command Command; + private readonly WebTestServerApplication WebTestServerApplication; + + public Handle_Returns + ( + WebTestServerApplication webTestServerApplication + ) + { + Command = new Command + { + UserId = Guid.NewGuid(), + Name = "Dispatcher", + Description = "Schedules and routes work." + }; + WebTestServerApplication = webTestServerApplication; + } + + public async Task Response_With_NonEmpty_RoleId() + { + OneOf result = await WebTestServerApplication.Send(Command); + + result.Switch + ( + response => response.RoleId.ShouldNotBe(Guid.Empty), + problemDetails => problemDetails.ShouldBeNull("CreateRole handler returned SharedProblemDetails for a valid command.") + ); + } +} diff --git a/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Validator_Tests.cs b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Validator_Tests.cs new file mode 100644 index 00000000..7ddecfdf --- /dev/null +++ b/tests/container-apps/web/web-server-integration-tests/Features/Admin/Roles/CreateRole/CreateRole_Validator_Tests.cs @@ -0,0 +1,44 @@ +#region Purpose +// Validator tests: the contract's composed rules (shared RoleDetailsValidator + auth) hold. +#endregion + +namespace CreateRoleRequestValidator_; + +using static TimeWarp.Architecture.Features.Admin.Roles.CreateRole; + +public class Validate_Should +{ + private Validator Validator = new(); + + public void Be_Valid_Given_Complete_Command() + { + var command = new Command + { + UserId = Guid.NewGuid(), + Name = "Auditor", + Description = "Read-only access." + }; + + ValidationResult validationResult = Validator.TestValidate(command); + + validationResult.IsValid.ShouldBeTrue(); + } + + public void Have_Error_When_Name_Is_Empty() + { + TestValidationResult result = + Validator.TestValidate(new Command { UserId = Guid.NewGuid(), Name = "", Description = "x" }); + + result.ShouldHaveValidationErrorFor(command => command.Name); + } + + public void Have_Error_When_UserId_Is_Empty() + { + TestValidationResult result = + Validator.TestValidate(new Command { Name = "Auditor", Description = "x" }); + + result.ShouldHaveValidationErrorFor(command => command.UserId); + } + + public void Setup() => Validator = new Validator(); +}