using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using RadicadeServer.Middleware; using RadicadeServer.RecNet.Responses; using Xunit; namespace RadicadeServer.Tests; /// /// The failure envelope, pinned end to end: a bodyless 4xx or 5xx must leave the pipeline /// carrying `{ "success": false, "error": "<why>" }` under its own status. /// /// This is the wire contract the client's error path reads. The 2023-09-06 client takes the /// body's `error` as a string and asserts on it (`ArgumentException: Error message cannot be /// null`), and it raises that inside the caller's task continuation - so an empty body does not /// surface as a visible failure, it silently cancels whatever the caller was doing. Player.log /// shows the crash behind every bodyless 404 this server used to serve: `/clubs/club/home/me` /// and `/econ/api/subscriptionseasons/v1/seasons/current` (both deliberate 404s) and the 404 a /// private dorm answers when the caller is not its owner (docs/2023-0831-CLIENT-SWITCH.md, /// invite section). /// public class FailureEnvelopeTests { private static async Task<(int Status, string ContentType, string Body)> Run(RequestDelegate next) { var context = new DefaultHttpContext { // WriteAsJsonAsync resolves its serializer options from here; a bare DefaultHttpContext // has no service provider. RequestServices = new ServiceCollection().BuildServiceProvider(), }; context.Response.Body = new MemoryStream(); await new FailureEnvelopeMiddleware(next).InvokeAsync(context); context.Response.Body.Position = 0; using var reader = new StreamReader(context.Response.Body, Encoding.UTF8); return (context.Response.StatusCode, context.Response.ContentType ?? string.Empty, await reader.ReadToEndAsync()); } private static RequestDelegate BareStatus(int status) => context => { context.Response.StatusCode = status; return Task.CompletedTask; }; [Fact] public async Task ABareNotFoundAnswersTheFailureEnvelope() { var (status, contentType, body) = await Run(BareStatus(StatusCodes.Status404NotFound)); Assert.Equal(StatusCodes.Status404NotFound, status); Assert.StartsWith("application/json", contentType); var json = JsonSerializer.Deserialize(body); Assert.False(json.GetProperty("success").GetBoolean()); Assert.Equal("not found", json.GetProperty("error").GetString()); } [Theory] [InlineData(400, "bad request")] [InlineData(401, "unauthorized")] [InlineData(403, "forbidden")] [InlineData(405, "method not allowed")] [InlineData(413, "request too large")] [InlineData(500, "internal server error")] [InlineData(503, "service unavailable")] public async Task EveryBodylessFailureStatusIsSpelled(int statusCode, string expected) { var (status, _, body) = await Run(BareStatus(statusCode)); Assert.Equal(statusCode, status); var json = JsonSerializer.Deserialize(body); Assert.False(json.GetProperty("success").GetBoolean()); Assert.Equal(expected, json.GetProperty("error").GetString()); } [Fact] public async Task AnUnknownFailureStatusStillCarriesANonNullError() { // 418 and friends: the wording is nobody's contract, but a NULL error is, and the client // asserts on it whatever the status was. var (_, _, body) = await Run(BareStatus(418)); var json = JsonSerializer.Deserialize(body); Assert.False(json.GetProperty("success").GetBoolean()); Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("error").GetString())); } [Fact] public async Task AnAnswerThatAlreadyHasABodyIsLeftAlone() { var (status, _, body) = await Run(async context => { context.Response.StatusCode = StatusCodes.Status404NotFound; await context.Response.WriteAsJsonAsync(new Failure("No such room")); }); Assert.Equal(StatusCodes.Status404NotFound, status); Assert.Equal("No such room", JsonSerializer.Deserialize(body).GetProperty("error").GetString()); } [Fact] public async Task SuccessfulAndEmptyAnswersAreLeftAlone() { var (ok, okContentType, okBody) = await Run(BareStatus(StatusCodes.Status200OK)); Assert.Equal(StatusCodes.Status200OK, ok); Assert.Empty(okBody); Assert.Empty(okContentType); var (noContent, _, noContentBody) = await Run(BareStatus(StatusCodes.Status204NoContent)); Assert.Equal(StatusCodes.Status204NoContent, noContent); Assert.Empty(noContentBody); } [Fact] public async Task UpgradesAndRedirectsAreLeftAlone() { // 101 is a WebSocket taking over the response: writing into it would corrupt the socket, // and the hub is on this pipeline. var (upgrade, _, upgradeBody) = await Run(BareStatus(101)); Assert.Equal(101, upgrade); Assert.Empty(upgradeBody); var (redirect, _, redirectBody) = await Run(BareStatus(StatusCodes.Status302Found)); Assert.Equal(StatusCodes.Status302Found, redirect); Assert.Empty(redirectBody); } [Fact] public void TheFailureEnvelopeNeverSpellsAnEmptyError() { // `error` is what the client turns into an exception message; blank is as unusable as null. var json = JsonSerializer.Serialize(new Failure("")); Assert.Equal("request failed", JsonSerializer.Deserialize(json).GetProperty("error").GetString()); Assert.Equal("not found", FailureEnvelopeMiddleware.Why(StatusCodes.Status404NotFound)); } }