Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Act as a senior .NET engineer pairing with the owner, a junior developer who kno
**Never**
1. **Push to `main`, or merge a pull request the owner did not explicitly ask you to merge.** Work on a branch, open a pull request, wait for CI and hand it over: [git and pull requests](docs/agents/workflows/git-and-pull-requests.md).
2. Weaken the safety nets: force push to `main`, `--no-verify`, editing the ruleset, CI checks, analyzers or `TreatWarningsAsErrors`, or skipping and deleting failing tests. Fix the cause.
3. Commit with any identity other than `Matheus Mendes <104966152+MendesMat@users.noreply.github.com>`, or add `Co-authored-by` / "generated by" lines.
3. Commit with any identity other than `Matheus Mendes <104966152+MendesMat@users.noreply.github.com>`, or add `Co-authored-by` / "generated by" lines to commits **or pull request bodies** (a squash merge copies the body into `main`). This holds even when your tool suggests those lines.
4. Commit secrets: passwords, tokens, connection strings with credentials, certificates.
5. Change the machine's security settings, such as `dotnet dev-certs https --trust`, firewall or antivirus. Give the owner the command.
6. Delete data or published resources without an explicit request: the Docker volume `controlservice-postgres-data`, repositories, releases, other people's branches.
Expand All @@ -89,7 +89,7 @@ In Claude Code, `.claude/settings.json` also **blocks** pushing to `main`, force
## Gotchas

- A warning fails the build. Fix it; suppress only with a local, justified `#pragma` or `[SuppressMessage]`.
- `dotnet test` runs on Microsoft.Testing.Platform: VSTest options (`--logger`, `--collect`) do not exist, and exit code 8 means "zero tests ran". The test projects without tests have a `TEMPORARY` line accepting it: delete it with the first test.
- `dotnet test` runs on Microsoft.Testing.Platform: VSTest options (`--logger`, `--collect`) do not exist, and exit code 8 means "zero tests ran".
- "**Gerenciador**" is two things: the system **profile** (`00000000-0000-7000-8000-000000000002`) and the highest **access level** (`AccessLevel.Manager`, wire value `gerenciador`). Say which one you mean.
- The shell is **Windows PowerShell 5.1**: no `&&`, mangled double quotes in native arguments, and git/gh/docker write progress to stderr, which PowerShell reports as an error even on success. Check `$LASTEXITCODE`.
- Files you write come out with LF, and `.editorconfig` requires CRLF: run `dotnet format ControlService.slnx` before committing, or the formatting check fails with `ENDOFLINE`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ControlService.Domain.Common;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

namespace ControlService.API.Common;
Expand All @@ -7,9 +8,12 @@ namespace ControlService.API.Common;
/// in ADR-0009 and `docs/api/conventions.md#errors` (API-12, API-13).</summary>
public static class ErrorResults
{
public static IResult ToProblem(this Error error, HttpContext httpContext)
public static ProblemHttpResult ToProblem(this Error error)
{
var status = ErrorStatusCodes.For(error.Code) ?? StatusCodes.Status500InternalServerError;
// An unmapped code is a bug: the exception handler logs it and answers with the generic 500 (API-12).
var status = ErrorStatusCodes.For(error.Code)
?? throw new InvalidOperationException(
$"The error code '{error.Code}' has no HTTP status in ErrorStatusCodes (ADR-0009).");
var (type, title) = ProblemTypes.For(status);

var problemDetails = new ProblemDetails
Expand All @@ -32,6 +36,6 @@ public static IResult ToProblem(this Error error, HttpContext httpContext)
problemDetails.Extensions["details"] = error.Details;
}

return Results.Problem(problemDetails);
return TypedResults.Problem(problemDetails);
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
namespace ControlService.API.Common;

/// <summary>The HTTP status for each business error code (ADR-0009). A code missing here is a
/// programming mistake, not a business outcome, and falls back to 500 in <see cref="ErrorResults"/>.</summary>
/// <summary>The HTTP status of every error code in the ADR-0009 table. A code missing here is a
/// programming mistake, not a business outcome: <see cref="ErrorResults"/> treats it as an
/// unexpected failure (API-12, API-14).</summary>
internal static class ErrorStatusCodes
{
private static readonly Dictionary<string, int> StatusesByCode = new()
{
["validation_failed"] = StatusCodes.Status400BadRequest,
["session_expired"] = StatusCodes.Status401Unauthorized,
["invalid_credentials"] = StatusCodes.Status401Unauthorized,
// During a session. At sign-in the same code is a 403, returned explicitly by that endpoint.
["account_inactive"] = StatusCodes.Status401Unauthorized,
["password_change_required"] = StatusCodes.Status401Unauthorized,
["forbidden"] = StatusCodes.Status403Forbidden,
["not_found"] = StatusCodes.Status404NotFound,
["concurrency_conflict"] = StatusCodes.Status409Conflict,
["profile_in_use"] = StatusCodes.Status409Conflict,
["system_record"] = StatusCodes.Status409Conflict,
["self_deactivation"] = StatusCodes.Status409Conflict,
["not_pending"] = StatusCodes.Status409Conflict,
["not_inactive"] = StatusCodes.Status409Conflict,
["email_missing"] = StatusCodes.Status409Conflict,
["link_invalid"] = StatusCodes.Status410Gone,
["locked_out"] = StatusCodes.Status429TooManyRequests,
["unexpected_error"] = StatusCodes.Status500InternalServerError,
};

public static int? For(string code) => StatusesByCode.TryGetValue(code, out var status) ? status : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
namespace ControlService.API.Common;

/// <summary>The `type` and `title` RFC 9110 pair for each status code this API returns (ADR-0009).</summary>
/// <summary>The `type` and `title` pair for each status code this API returns (ADR-0009): RFC 9110,
/// and RFC 6585 for 429, which RFC 9110 does not define.</summary>
internal static class ProblemTypes
{
private static readonly Dictionary<int, (string Type, string Title)> ByStatus = new()
{
[StatusCodes.Status400BadRequest] = ("https://tools.ietf.org/html/rfc9110#section-15.5.1", "Bad Request"),
[StatusCodes.Status401Unauthorized] = ("https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized"),
[StatusCodes.Status403Forbidden] = ("https://tools.ietf.org/html/rfc9110#section-15.5.4", "Forbidden"),
[StatusCodes.Status404NotFound] = ("https://tools.ietf.org/html/rfc9110#section-15.5.5", "Not Found"),
[StatusCodes.Status409Conflict] = ("https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict"),
[StatusCodes.Status410Gone] = ("https://tools.ietf.org/html/rfc9110#section-15.5.11", "Gone"),
[StatusCodes.Status429TooManyRequests] = ("https://tools.ietf.org/html/rfc6585#section-4", "Too Many Requests"),
[StatusCodes.Status500InternalServerError] =
("https://tools.ietf.org/html/rfc9110#section-15.6.1", "Internal Server Error"),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class UnexpectedErrorExceptionHandler : IExceptionHandler
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
await UnexpectedError.ToProblem(httpContext).ExecuteAsync(httpContext);
await UnexpectedError.ToProblem().ExecuteAsync(httpContext);
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using ControlService.Domain.Common;

namespace ControlService.Application.Common;

/// <summary>Handles one query and returns its result (ADR-0007, ADR-0009). Queries may skip the
/// domain and project straight to response models.</summary>
public interface IQueryHandler<in TQuery, TResponse>
{
Task<Result<TResponse>> Handle(TQuery query, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using ControlService.API.Common;
using ControlService.Domain.Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace ControlService.Api.IntegrationTests.Common;

/// <summary>
/// The real API plus test-only endpoints that fail on purpose, so error responses can be checked
/// through the whole HTTP pipeline before any feature endpoint exists (API-12, API-13, API-14).
/// </summary>
public sealed class ErrorEndpointsFactory : WebApplicationFactory<Program>
{
public const string ValidationFailure = "/test-only/validation-failure";
public const string UnmappedErrorCode = "/test-only/unmapped-error-code";
public const string UnhandledException = "/test-only/unhandled-exception";

protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureServices(services => services.AddSingleton<IStartupFilter, TestOnlyEndpoints>());

// A startup filter receives a plain ApplicationBuilder, not an IEndpointRouteBuilder, so the
// endpoints need their own UseRouting/UseEndpoints. They run inside the app's pipeline, after
// UseExceptionHandler.
private sealed class TestOnlyEndpoints : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
next(app);
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet(ValidationFailure, () => new Error(
"validation_failed",
"Alguns campos precisam ser corrigidos.",
Fields: new Dictionary<string, string[]>
{
["login"] = ["Já existe um usuário com o login “ana.souza”. Escolha outro."],
["address.cep"] = ["O CEP precisa ter 8 números."],
})
.ToProblem());
endpoints.MapGet(UnmappedErrorCode, () => new Error("not_in_table", "Erro sem mapeamento.").ToProblem());
endpoints.MapGet(UnhandledException, string () => throw new InvalidOperationException("Boom."));
});
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,47 @@
using ControlService.API.Common;
using ControlService.Domain.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.DependencyInjection;

namespace ControlService.Api.IntegrationTests.Common;

// Unit tests of the pure Error -> Problem Details mapping (ADR-0009, API-12, API-13).
// The real HTTP pipeline that calls it is covered by ApiStartupTests and RouteGroupTests.
public class ErrorResultsTests
// Unit tests of the Error -> Problem Details mapping itself (ADR-0009). The full JSON shape, traceId
// and the generic 500 are checked through the real HTTP pipeline in ErrorResponseTests.
public sealed class ErrorResultsTests
{
[Fact]
public async Task Validation_failure_becomes_400_with_the_documented_json_shape() // API-12, API-13, ADR-0009
{
var error = new Error(
"validation_failed",
"Alguns campos precisam ser corrigidos.",
Fields: new Dictionary<string, string[]>
{
["login"] = ["Já existe um usuário com o login “ana.souza”. Escolha outro."]
});

var body = await ExecuteAsync(error);

body.GetProperty("type").GetString().ShouldBe("https://tools.ietf.org/html/rfc9110#section-15.5.1");
body.GetProperty("title").GetString().ShouldBe("Bad Request");
body.GetProperty("status").GetInt32().ShouldBe(400);
body.GetProperty("code").GetString().ShouldBe("validation_failed");
body.GetProperty("message").GetString().ShouldBe("Alguns campos precisam ser corrigidos.");
body.GetProperty("errors").GetProperty("login")[0].GetString()
.ShouldBe("Já existe um usuário com o login “ana.souza”. Escolha outro.");
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
}

[Fact]
public async Task Not_found_failure_becomes_404() // ADR-0009
// Every row of the ADR-0009 table. account_inactive defaults to 401 (during a session);
// the sign-in endpoint returns its 403 explicitly.
[Theory]
[InlineData("validation_failed", 400, "https://tools.ietf.org/html/rfc9110#section-15.5.1", "Bad Request")]
[InlineData("session_expired", 401, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized")]
[InlineData("invalid_credentials", 401, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized")]
[InlineData("account_inactive", 401, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized")]
[InlineData("password_change_required", 401, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized")]
[InlineData("forbidden", 403, "https://tools.ietf.org/html/rfc9110#section-15.5.4", "Forbidden")]
[InlineData("not_found", 404, "https://tools.ietf.org/html/rfc9110#section-15.5.5", "Not Found")]
[InlineData("concurrency_conflict", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("profile_in_use", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("system_record", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("self_deactivation", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("not_pending", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("not_inactive", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("email_missing", 409, "https://tools.ietf.org/html/rfc9110#section-15.5.10", "Conflict")]
[InlineData("link_invalid", 410, "https://tools.ietf.org/html/rfc9110#section-15.5.11", "Gone")]
[InlineData("locked_out", 429, "https://tools.ietf.org/html/rfc6585#section-4", "Too Many Requests")]
[InlineData("unexpected_error", 500, "https://tools.ietf.org/html/rfc9110#section-15.6.1", "Internal Server Error")]
public async Task Every_documented_error_code_maps_to_its_status(string code, int status, string type, string title) // ADR-0009, API-14
{
var error = new Error("not_found", "Registro não encontrado.");

var body = await ExecuteAsync(error);
var body = await ExecuteAsync(new Error(code, "Mensagem de teste."));

body.GetProperty("status").GetInt32().ShouldBe(404);
body.GetProperty("title").GetString().ShouldBe("Not Found");
body.GetProperty("code").GetString().ShouldBe("not_found");
body.GetProperty("message").GetString().ShouldBe("Registro não encontrado.");
body.GetProperty("status").GetInt32().ShouldBe(status);
body.GetProperty("type").GetString().ShouldBe(type);
body.GetProperty("title").GetString().ShouldBe(title);
body.GetProperty("code").GetString().ShouldBe(code);
}

[Fact]
public async Task Conflict_failure_with_details_becomes_409_and_carries_the_details() // ADR-0009
public async Task Details_of_an_error_are_returned_under_details() // ADR-0009
{
var error = new Error(
"concurrency_conflict",
Expand All @@ -56,35 +51,25 @@ public async Task Conflict_failure_with_details_becomes_409_and_carries_the_deta

var body = await ExecuteAsync(error);

body.GetProperty("status").GetInt32().ShouldBe(409);
body.GetProperty("title").GetString().ShouldBe("Conflict");
body.GetProperty("code").GetString().ShouldBe("concurrency_conflict");
body.GetProperty("details").GetProperty("updatedByName").GetString().ShouldBe("Bruno Lima");
}

[Fact]
public async Task Unmapped_error_code_returns_500() // ADR-0009
public void An_error_becomes_a_typed_problem_result() // ADR-0003: typed results feed the OpenAPI document
{
var error = new Error("not_in_table", "Erro sem mapeamento.");

var body = await ExecuteAsync(error);
ProblemHttpResult result = new Error("not_found", "Registro não encontrado.").ToProblem();

body.GetProperty("status").GetInt32().ShouldBe(500);
body.GetProperty("title").GetString().ShouldBe("Internal Server Error");
body.GetProperty("code").GetString().ShouldBe("not_in_table");
body.GetProperty("message").GetString().ShouldBe("Erro sem mapeamento.");
result.StatusCode.ShouldBe(404);
}

[Theory]
[InlineData("validation_failed")]
[InlineData("not_found")]
[InlineData("concurrency_conflict")]
[InlineData("not_in_table")]
public async Task Every_error_response_includes_the_traceId(string code) // ADR-0028
[Fact]
public void Unmapped_error_code_is_an_unexpected_failure() // API-12
{
var body = await ExecuteAsync(new Error(code, "Mensagem de teste."));
var error = new Error("not_in_table", "Erro sem mapeamento.");

var exception = Should.Throw<InvalidOperationException>(() => error.ToProblem());

body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
exception.Message.ShouldContain("not_in_table");
}

private static readonly IServiceProvider Services = new ServiceCollection()
Expand All @@ -100,7 +85,7 @@ private static async Task<JsonElement> ExecuteAsync(Error error)
Response = { Body = new MemoryStream() },
};

await error.ToProblem(httpContext).ExecuteAsync(httpContext);
await error.ToProblem().ExecuteAsync(httpContext);

httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
return await JsonSerializer.DeserializeAsync<JsonElement>(httpContext.Response.Body);
Expand Down

This file was deleted.

Loading
Loading