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;
}
}