mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
142 lines
5.3 KiB
C#
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": "<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).
|
|
/// </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));
|
|
}
|
|
}
|