Files
ersatztv/ErsatzTV.Tests/Controllers/OpenApiContractHonestyTests.cs
T

155 lines
6.2 KiB
C#

using ErsatzTV.Serialization;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Pins the #287 "contract honesty by construction" transformers against the document the app would
/// actually serve (generated in-process; see <see cref="GeneratedOpenApiDocument" />): the ApiKey
/// security scheme + per-operation security/401, a stable operationId on every operation, and the
/// DayOfWeek string enum. If a transformer regresses (or is dropped from the "v1" registration) these
/// go red without needing the committed v1.json to be regenerated first.
/// </summary>
[TestFixture]
public class OpenApiContractHonestyTests
{
private static OpenApiDocument _document = null!;
[OneTimeSetUp]
public async Task BuildDocument() => _document = await GeneratedOpenApiDocument.BuildV1Async();
[Test]
public void Should_Declare_ApiKey_Security_Scheme_In_Header()
{
_document.Components.ShouldNotBeNull();
_document.Components!.SecuritySchemes.ShouldNotBeNull();
_document.Components!.SecuritySchemes!.TryGetValue("ApiKey", out IOpenApiSecurityScheme? scheme)
.ShouldBeTrue();
var concrete = scheme.ShouldBeOfType<OpenApiSecurityScheme>();
concrete.Type.ShouldBe(SecuritySchemeType.ApiKey);
concrete.In.ShouldBe(ParameterLocation.Header);
concrete.Name.ShouldBe("X-Api-Key");
}
[Test]
public void Every_Operation_Should_Have_A_NonEmpty_OperationId()
{
List<string> missing = EnumerateOperations()
.Where(op => string.IsNullOrEmpty(op.Operation.OperationId))
.Select(op => $"{op.Method} {op.Path}")
.ToList();
missing.ShouldBeEmpty();
}
[Test]
public void Operation_Ids_Should_Be_Unique()
{
List<string> ids = EnumerateOperations()
.Select(op => op.Operation.OperationId!)
.ToList();
ids.Count.ShouldBeGreaterThan(150);
ids.Distinct(StringComparer.Ordinal).Count().ShouldBe(ids.Count);
}
[Test]
public void Gated_Operations_Should_Declare_Security_And_401()
{
// Under the default posture (Api:RequireKeyForReads=true) every documented operation requires the
// key, so each must carry the ApiKey security requirement AND a documented 401.
foreach ((string method, string path, OpenApiOperation operation) in EnumerateOperations())
{
operation.Security.ShouldNotBeNull($"{method} {path} should declare security");
operation.Security!.Count.ShouldBeGreaterThan(0, $"{method} {path} should declare security");
operation.Responses.ShouldNotBeNull();
operation.Responses!.ContainsKey("401").ShouldBeTrue($"{method} {path} should document 401");
}
}
[Test]
public void Troubleshoot_Playback_Actions_Should_Carry_Stable_Explicit_OperationIds()
{
// #301 POST-ified these three side-effecting actions (each now a single POST with an explicit
// Name=), which removed the former HEAD/GET collision entirely — so the synthesized verb-suffix
// disambiguation (#197 Bundle C) no longer applies to them. Pin the explicit ids instead, and
// assert the old synthesized/colliding forms are gone.
var ids = EnumerateOperations().Select(op => op.Operation.OperationId!).ToHashSet(StringComparer.Ordinal);
foreach (string expected in new[]
{
"StartTroubleshootingPlayback",
"DownloadTroubleshootingArchive",
"DownloadTroubleshootingMediaSample"
})
{
ids.ShouldContain(expected);
}
foreach (string stale in new[]
{
"TroubleshootTroubleshootPlayback",
"TroubleshootTroubleshootPlaybackGet",
"TroubleshootTroubleshootPlaybackHead",
"TroubleshootTroubleshootPlaybackGET",
"TroubleshootTroubleshootPlaybackArchive",
"TroubleshootTroubleshootPlaybackArchiveGet",
"TroubleshootTroubleshootPlaybackArchiveHead",
"TroubleshootTroubleshootPlaybackArchiveGET",
"TroubleshootTroubleshootPlaybackSample",
"TroubleshootTroubleshootPlaybackSampleGet",
"TroubleshootTroubleshootPlaybackSampleHead",
"TroubleshootTroubleshootPlaybackSampleGET"
})
{
ids.ShouldNotContain(stale);
}
}
[Test]
public void ValidationProblemDetails_Schema_And_400_Should_Be_Documented_For_Binding_Operations()
{
_document.Components!.Schemas!.ContainsKey(
ApiSecuritySchemeDocumentTransformer.ValidationProblemDetailsSchemaId).ShouldBeTrue();
// Any operation that binds a body or parameters must document a 400 (model-binding / validation).
foreach ((string method, string path, OpenApiOperation operation) in EnumerateOperations())
{
bool binds = operation.RequestBody is not null || operation.Parameters is { Count: > 0 };
if (binds)
{
operation.Responses!.ContainsKey("400").ShouldBeTrue($"{method} {path} should document 400");
}
}
}
[Test]
public void DayOfWeek_Schema_Should_Be_A_String_Enum()
{
_document.Components!.Schemas!.TryGetValue("DayOfWeek", out IOpenApiSchema? schema).ShouldBeTrue();
schema!.Type.ShouldBe(JsonSchemaType.String);
schema.Enum.ShouldNotBeNull();
schema.Enum!.Select(n => n!.GetValue<string>()).ShouldContain("Sunday");
}
private static IEnumerable<(string Method, string Path, OpenApiOperation Operation)> EnumerateOperations()
{
foreach (KeyValuePair<string, IOpenApiPathItem> path in _document.Paths)
{
if (path.Value.Operations is null)
{
continue;
}
foreach (KeyValuePair<HttpMethod, OpenApiOperation> operation in path.Value.Operations)
{
yield return (operation.Key.Method, path.Key, operation.Value);
}
}
}
}