proposal-system/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs

154 lines
7.4 KiB
C#
Raw Permalink Normal View History

feat(contracts): shared api-contracts adoption, zod schemas, ProblemDetails codes (#222) * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * 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. * fix(web): install shared api-contracts deps via postinstall Web Frontend Check failed on PR #222: tsc compiles shared/api-contracts/src/schemas.ts through the tsconfig path alias, and module resolution for its zod import walks up from shared/, never reaching web/node_modules. CI only ran npm ci in web/, so the shared package's deps were absent. A postinstall hook installs them wherever web's deps are installed (CI typecheck, web-test, deploy bundling). Passed locally only because a stray repo-root node_modules/zod satisfied the lookup.
2026-07-13 19:28:33 -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");
}
// ── 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.
private static ProposalSystem.Application.DTOs.ProposalResponse SampleState(Guid id) => new(
id, "P-001", "WO-1", null, "Customer", "Addr", "Scope", null,
ProposalSystem.Domain.Entities.ServiceCategory.HVAC,
ProposalSystem.Domain.Entities.Priority.Standard,
ProposalSystem.Domain.Entities.ProposalStatus.InReview,
100m, null, "winner", Guid.NewGuid(), "Submitter", DateTime.UtcNow,
null, null, null, null, 1, null, DateTime.UtcNow, DateTime.UtcNow,
"AAAAAAAAAAI=");
[Fact(DisplayName = "ProposalConcurrencyException maps to 409 { message, currentState } (SHOC envelope)")]
public async Task ConcurrencyException_Maps409WithCurrentState()
{
var id = Guid.NewGuid();
var (status, body) = await InvokeWith(new ProposalConcurrencyException(SampleState(id)));
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(id.ToString());
body.GetProperty("currentState").GetProperty("notes").GetString().Should().Be("winner");
body.GetProperty("currentState").GetProperty("rowVersion").GetString().Should().Be("AAAAAAAAAAI=");
// Enums must serialize as strings (matching the MVC pipeline), or client
// schema validation of currentState fails and the state is discarded.
body.GetProperty("currentState").GetProperty("serviceCategory").GetString().Should().Be("HVAC");
body.GetProperty("currentState").GetProperty("status").GetString().Should().Be("InReview");
// 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): shared api-contracts adoption, zod schemas, ProblemDetails codes (#222) * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * 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. * fix(web): install shared api-contracts deps via postinstall Web Frontend Check failed on PR #222: tsc compiles shared/api-contracts/src/schemas.ts through the tsconfig path alias, and module resolution for its zod import walks up from shared/, never reaching web/node_modules. CI only ran npm ci in web/, so the shared package's deps were absent. A postinstall hook installs them wherever web's deps are installed (CI typecheck, web-test, deploy bundling). Passed locally only because a stray repo-root node_modules/zod satisfied the lookup.
2026-07-13 19:28:33 -04:00
}