feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes
Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):
- shared/api-contracts: rewritten as the authoritative superset of the
.NET DTOs (ProposalListItem/ProposalDetail with poNumber and
submittedByName, line item requests, customers, pricing library,
dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
a compile error); separate entrypoint so type-only consumers (mobile)
never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
types so page imports stay stable; enum unions tightened
(PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
API silently dropped (contract is addresses: string[], CustomerDtos.cs)
- addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
`code` (SHOC error-code vocabulary): ValidationFailed,
InvalidStateTransition, NotFound, Unauthorized, InternalError; new
BusinessRuleException(code, message) maps to 422 with its code;
GlobalExceptionHandlerTests cover the full mapping (wire contract)
Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.
Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.
2026-07-13 17:19:42 -04:00
|
|
|
using System.Text.Json;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using FluentValidation;
|
|
|
|
|
using FluentValidation.Results;
|
|
|
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using NSubstitute;
|
|
|
|
|
using ProposalSystem.Api.Middleware;
|
|
|
|
|
using ProposalSystem.Application.Common;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace ProposalSystem.Tests.Middleware;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails
|
|
|
|
|
/// mapping and the machine-readable "code" extension (SHOC error-code
|
|
|
|
|
/// vocabulary convention). Clients branch on code, so the code values are
|
|
|
|
|
/// wire contract: changing one is a breaking API change.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class GlobalExceptionHandlerTests
|
|
|
|
|
{
|
|
|
|
|
private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception)
|
|
|
|
|
{
|
|
|
|
|
var logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
|
|
|
|
|
var handler = new GlobalExceptionHandler(logger);
|
|
|
|
|
var context = new DefaultHttpContext();
|
|
|
|
|
context.Response.Body = new MemoryStream();
|
|
|
|
|
|
|
|
|
|
await handler.InvokeAsync(context, _ => throw exception);
|
|
|
|
|
|
|
|
|
|
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
|
|
|
|
using var reader = new StreamReader(context.Response.Body);
|
|
|
|
|
var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone();
|
|
|
|
|
return (context.Response.StatusCode, body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")]
|
|
|
|
|
public async Task BusinessRuleException_Maps422WithCode()
|
|
|
|
|
{
|
|
|
|
|
var (status, body) = await InvokeWith(
|
|
|
|
|
new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled"));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(422);
|
|
|
|
|
body.GetProperty("code").GetString().Should().Be("CancelNotAllowed");
|
|
|
|
|
body.GetProperty("title").GetString().Should().Be("Business Rule Violation");
|
|
|
|
|
body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")]
|
|
|
|
|
public async Task InvalidOperationException_Maps400InvalidStateTransition()
|
|
|
|
|
{
|
|
|
|
|
var (status, body) = await InvokeWith(new InvalidOperationException("bad transition"));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(400);
|
|
|
|
|
body.GetProperty("code").GetString().Should().Be("InvalidStateTransition");
|
|
|
|
|
// Detail must stay generic — no internal exception text on the wire.
|
|
|
|
|
body.GetProperty("detail").GetString().Should().NotContain("bad transition");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")]
|
|
|
|
|
public async Task ValidationException_Maps400ValidationFailed()
|
|
|
|
|
{
|
|
|
|
|
var failures = new[] { new ValidationFailure("Name", "Name is required") };
|
|
|
|
|
var (status, body) = await InvokeWith(new ValidationException(failures));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(400);
|
|
|
|
|
body.GetProperty("code").GetString().Should().Be("ValidationFailed");
|
|
|
|
|
body.GetProperty("detail").GetString().Should().Contain("Name is required");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Theory(DisplayName = "Standard exceptions map to their status and code")]
|
|
|
|
|
[InlineData(typeof(KeyNotFoundException), 404, "NotFound")]
|
|
|
|
|
[InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")]
|
|
|
|
|
[InlineData(typeof(ApplicationException), 500, "InternalError")]
|
|
|
|
|
public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode)
|
|
|
|
|
{
|
|
|
|
|
var exception = (Exception)Activator.CreateInstance(exceptionType)!;
|
|
|
|
|
var (status, body) = await InvokeWith(exception);
|
|
|
|
|
|
|
|
|
|
status.Should().Be(expectedStatus);
|
|
|
|
|
body.GetProperty("code").GetString().Should().Be(expectedCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "Responses use application/problem+json")]
|
|
|
|
|
public async Task Responses_UseProblemJsonContentType()
|
|
|
|
|
{
|
|
|
|
|
var logger = Substitute.For<ILogger<GlobalExceptionHandler>>();
|
|
|
|
|
var handler = new GlobalExceptionHandler(logger);
|
|
|
|
|
var context = new DefaultHttpContext();
|
|
|
|
|
context.Response.Body = new MemoryStream();
|
|
|
|
|
|
|
|
|
|
await handler.InvokeAsync(context, _ => throw new KeyNotFoundException());
|
|
|
|
|
|
|
|
|
|
context.Response.ContentType.Should().Be("application/problem+json");
|
|
|
|
|
}
|
test(api): concurrency, codec, 409 wire-shape, and audit-atomicity coverage
- ProposalConcurrencyTests (shared-connection SQLite, two competing
writers): stale-token pre-check with embedded currentState, DB-level
lost race reloading the winner's values, 422 token codes, version
bump on success, bulk-update proposal-token guard, line-item create
bumping the aggregate, audit rows never persisted on a lost race.
- GlobalExceptionHandlerTests: pin both SHOC 409 envelopes verbatim
(guarded { message, currentState } incl. null state; fallback
{ status, message, code }) and assert shape exclusivity.
- RowVersionCodecTests: round-trip, SHOC-style token literal,
malformed/wrong-length rejection.
- Existing suites threaded with live version tokens (Ver helper);
audit assertions moved from LogAsync to Stage.
187 xUnit green (was 166).
2026-07-13 20:48:28 -04:00
|
|
|
|
|
|
|
|
// ── Concurrency 409 envelopes (ADR 0004) ─────────────────────────────────
|
|
|
|
|
// These are SHOC's shapes verbatim, NOT ProblemDetails. Both bodies are wire
|
|
|
|
|
// contract: web/mobile branch on message + currentState / status + code.
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "ProposalConcurrencyException maps to 409 { message, currentState } (SHOC envelope)")]
|
|
|
|
|
public async Task ConcurrencyException_Maps409WithCurrentState()
|
|
|
|
|
{
|
|
|
|
|
var (status, body) = await InvokeWith(
|
|
|
|
|
new ProposalConcurrencyException(new { Id = "p1", Notes = "winner" }));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(409);
|
|
|
|
|
body.GetProperty("message").GetString()
|
|
|
|
|
.Should().Be("The record was modified by another user. Refresh and retry.");
|
|
|
|
|
body.GetProperty("currentState").GetProperty("id").GetString().Should().Be("p1");
|
|
|
|
|
body.GetProperty("currentState").GetProperty("notes").GetString().Should().Be("winner");
|
|
|
|
|
// The guarded envelope is NOT the fallback shape.
|
|
|
|
|
body.TryGetProperty("status", out _).Should().BeFalse();
|
|
|
|
|
body.TryGetProperty("code", out _).Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "ProposalConcurrencyException with no reloadable state carries currentState: null")]
|
|
|
|
|
public async Task ConcurrencyException_NullState_SerializesNull()
|
|
|
|
|
{
|
|
|
|
|
var (status, body) = await InvokeWith(new ProposalConcurrencyException(null));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(409);
|
|
|
|
|
body.GetProperty("currentState").ValueKind.Should().Be(JsonValueKind.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact(DisplayName = "Bare DbUpdateConcurrencyException maps to the 409 fallback { status, message, code }")]
|
|
|
|
|
public async Task DbUpdateConcurrencyException_Maps409Fallback()
|
|
|
|
|
{
|
|
|
|
|
var (status, body) = await InvokeWith(
|
|
|
|
|
new Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException("boom"));
|
|
|
|
|
|
|
|
|
|
status.Should().Be(409);
|
|
|
|
|
body.GetProperty("status").GetString().Should().Be("Conflict");
|
|
|
|
|
body.GetProperty("message").GetString()
|
|
|
|
|
.Should().Be("The record was modified by another user. Refresh and retry.");
|
|
|
|
|
body.GetProperty("code").GetInt32().Should().Be(409);
|
|
|
|
|
body.TryGetProperty("currentState", out _).Should().BeFalse();
|
|
|
|
|
}
|
feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes
Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):
- shared/api-contracts: rewritten as the authoritative superset of the
.NET DTOs (ProposalListItem/ProposalDetail with poNumber and
submittedByName, line item requests, customers, pricing library,
dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
a compile error); separate entrypoint so type-only consumers (mobile)
never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
types so page imports stay stable; enum unions tightened
(PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
API silently dropped (contract is addresses: string[], CustomerDtos.cs)
- addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
`code` (SHOC error-code vocabulary): ValidationFailed,
InvalidStateTransition, NotFound, Unauthorized, InternalError; new
BusinessRuleException(code, message) maps to 422 with its code;
GlobalExceptionHandlerTests cover the full mapping (wire contract)
Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.
Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.
2026-07-13 17:19:42 -04:00
|
|
|
}
|