Files
MoShiro/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs
T
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +08:00

151 lines
6.5 KiB
C#

using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 对照 docs/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.sh", "/install.ps1"];
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 != "/api/admin/auth/login"))
{
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();
}