Files
Radicade/ServerTests/FailureEnvelopeTests.cs
T
2026-10-16 23:33:13 -07:00

142 lines
5.3 KiB
C#

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;
/// <summary>
/// The failure envelope, pinned end to end: a bodyless 4xx or 5xx must leave the pipeline
/// carrying `{ "success": false, "error": "&lt;why&gt;" }` 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).
/// </summary>
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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(json).GetProperty("error").GetString());
Assert.Equal("not found", FailureEnvelopeMiddleware.Why(StatusCodes.Status404NotFound));
}
}