using System.Text.Json; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; using Shouldly; using Xunit; namespace DodoSSH.Api.Tests; /// /// The generated OpenAPI document, which ADR 0002 promises to third parties and a future CLI. /// /// /// Generated through rather than fetched from /// /openapi/v1.json: that route is mapped only in Development and sits behind the deny-by-default /// policy, so it is not reachable from this fixture. The document is the same one either way. /// [Collection(ApiCollection.Name)] public sealed class OpenApiDocumentTests(ApiFixture fixture) { [Theory] [InlineData("/api/v1/vaults/{vaultId}/sync/pull", "post", "vaultId")] [InlineData("/api/v1/vaults/{vaultId}/sync/push", "post", "vaultId")] [InlineData("/api/v1/me/devices/{deviceId}", "delete", "deviceId")] public async Task RouteParametersAreDeclared(string path, string verb, string parameterName) { // These endpoints read their route value with Route("name") rather than binding it onto the // request DTO, so nothing in the endpoint signature mentions it and the generator emits the path // template with no matching parameter — which is invalid OpenAPI and unusable by any client // generator. RouteParameterTransformer puts them back; without it this document silently rots. var document = await GenerateAsync(); var operation = document .GetProperty("paths").GetProperty(path).GetProperty(verb); var parameters = operation.GetProperty("parameters").EnumerateArray() .Where(p => string.Equals(p.GetProperty("in").GetString(), "path", StringComparison.Ordinal)) .ToArray(); var declared = parameters.SingleOrDefault( p => string.Equals(p.GetProperty("name").GetString(), parameterName, StringComparison.Ordinal)); declared.ValueKind.ShouldNotBe(JsonValueKind.Undefined, $"{verb} {path} declares no {parameterName}"); declared.GetProperty("required").GetBoolean().ShouldBeTrue(); // Recovered from the :guid route constraint, not guessed from the name. declared.GetProperty("schema").GetProperty("format").GetString().ShouldBe("uuid"); } [Fact] public async Task EveryPathTemplateExpressionHasAParameter() { // The general form of the rule above: no operation may name a template expression it does not // declare. Asserted over the whole document so a new endpoint cannot reintroduce the defect. var document = await GenerateAsync(); var offenders = new List(); foreach (var path in document.GetProperty("paths").EnumerateObject()) { var expected = path.Name.Split('/') .Where(segment => segment.StartsWith('{') && segment.EndsWith('}')) .Select(segment => segment[1..^1]) .ToArray(); if (expected.Length == 0) { continue; } foreach (var operation in path.Value.EnumerateObject()) { var declared = operation.Value.TryGetProperty("parameters", out var parameters) ? parameters.EnumerateArray() .Where(p => string.Equals(p.GetProperty("in").GetString(), "path", StringComparison.Ordinal)) .Select(p => p.GetProperty("name").GetString()) .ToArray() : []; offenders.AddRange( expected.Except(declared, StringComparer.Ordinal) .Select(missing => $"{operation.Name.ToUpperInvariant()} {path.Name} -> {missing}")); } } offenders.ShouldBeEmpty(); } private async Task GenerateAsync() { await using var scope = fixture.CreateScope(); // Keyed on the document name: AddOpenApi registers one provider per document. var provider = scope.ServiceProvider.GetRequiredKeyedService("v1"); var document = await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken); var json = await document.SerializeAsJsonAsync( OpenApiSpecVersion.OpenApi3_1, TestContext.Current.CancellationToken); return JsonDocument.Parse(json).RootElement; } }