Files
MoShiro/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs
T
2026-10-09 01:33:22 +08:00

151 lines
6.7 KiB
C#

using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 对照 docs/api/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。
/// 新增端点违反规范时,这里会直接失败(等价于文档里的 <c>eng/check-openapi</c>)。
/// </summary>
[Collection("backend")]
public sealed partial class OpenApiConformanceTests
{
private readonly BackendFixture _backend;
public OpenApiConformanceTests(BackendFixture backend)
{
_backend = backend;
}
private async Task<JsonObject> DocumentAsync() =>
(await _backend.GetJsonAsync(_backend.Anonymous, "/openapi/v1.json")).AsObject();
private static IEnumerable<(string Path, string Method, JsonObject Operation)> Operations(JsonObject doc)
{
foreach ((string path, JsonNode? item) in doc["paths"]!.AsObject())
{
foreach ((string method, JsonNode? operation) in item!.AsObject())
{
yield return (path, method, operation!.AsObject());
}
}
}
[Fact]
public async Task EveryOperation_HasAUniqueSnakeCaseOperationId_ASummaryAndATag()
{
JsonObject doc = await DocumentAsync();
var ids = new HashSet<string>();
foreach ((string path, string method, JsonObject operation) in Operations(doc))
{
string id = operation["operationId"]?.GetValue<string>() ?? "";
Assert.True(OperationIdPattern().IsMatch(id), $"{method} {path}: operationId '{id}' must be snake_case (resource_action)");
Assert.True(ids.Add(id), $"duplicate operationId {id}");
Assert.False(string.IsNullOrWhiteSpace(operation["summary"]?.GetValue<string>()), $"{method} {path}: summary");
Assert.NotEmpty(operation["tags"]!.AsArray());
}
}
[Fact]
public async Task Paths_AreLowercaseKebabCase_UnderTheDocumentedPrefixes()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, _) in doc["paths"]!.AsObject())
{
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/install/quadlet/{file}", "/api/release/offer"];
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
Assert.True(segment.StartsWith('{') || PathSegmentPattern().IsMatch(segment), $"{path}: '{segment}' must be lowercase kebab-case");
}
}
}
[Fact]
public async Task ErrorResponses_AreProblemJsonWithTheSharedSchema()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc))
{
foreach ((string status, JsonNode? response) in operation["responses"]!.AsObject())
{
if (!status.StartsWith('4') && !status.StartsWith('5'))
{
continue;
}
JsonObject? content = response?["content"]?.AsObject();
if (content is null)
{
continue; // 没有声明正文的框架错误
}
Assert.True(content.ContainsKey("application/problem+json"), $"{method} {path} {status} must use application/problem+json");
Assert.Equal("#/components/schemas/ProblemDto", content["application/problem+json"]!["schema"]!["$ref"]!.GetValue<string>());
}
}
JsonObject problem = doc["components"]!["schemas"]!["ProblemDto"]!.AsObject();
foreach (string required in new[] { "type", "title", "status", "code", "trace_id" })
{
Assert.NotNull(problem["properties"]![required]);
}
}
[Fact]
public async Task SchemaProperties_AreSnakeCase_AndResourcesAreStronglyTyped()
{
JsonObject doc = await DocumentAsync();
foreach ((string name, JsonNode? schema) in doc["components"]!["schemas"]!.AsObject())
{
JsonObject? properties = schema?["properties"]?.AsObject();
if (properties is null || name is "ProblemDetails" or "HttpValidationProblemDetails")
{
continue;
}
foreach ((string property, _) in properties)
{
Assert.True(PropertyPattern().IsMatch(property), $"{name}.{property} must be snake_case");
}
}
// 指标值是字典(metrics: { id: number }),新增指标不需要改 OpenAPI 和前端类型
JsonObject summary = doc["components"]!["schemas"]!["SummaryDto"]!["properties"]!["metrics"]!.AsObject();
Assert.True(summary.ContainsKey("additionalProperties"));
JsonObject series = doc["components"]!["schemas"]!["SeriesDto"]!["properties"]!["values"]!.AsObject();
Assert.True(series.ContainsKey("additionalProperties"));
}
[Fact]
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
{
JsonObject responses = operation["responses"]!.AsObject();
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
}
}
[Fact]
public async Task CreatedResources_DeclareA201_AndAcceptedOnesA202()
{
JsonObject doc = await DocumentAsync();
Assert.True(doc["paths"]!["/api/admin/tasks"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
Assert.True(doc["paths"]!["/api/admin/join-tokens"]!["post"]!["responses"]!.AsObject().ContainsKey("201"));
Assert.True(doc["paths"]!["/api/admin/lines/{line_id}/tests"]!["post"]!["responses"]!.AsObject().ContainsKey("202"));
Assert.True(doc["paths"]!["/api/admin/tasks/{id}"]!["delete"]!["responses"]!.AsObject().ContainsKey("204"));
}
[GeneratedRegex("^[a-z0-9]+(_[a-z0-9]+)+$")]
private static partial Regex OperationIdPattern();
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*(\\.[a-z0-9]+)?$")]
private static partial Regex PathSegmentPattern();
[GeneratedRegex("^[a-z0-9_]+$")]
private static partial Regex PropertyPattern();
}