Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
6.2 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|