using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace MoShiro.Backend.Tests; /// /// 对照 docs/api-guidelines.md 检查运行时生成的 OpenAPI 文档:operationId、路径风格、错误响应格式、字段命名。 /// 新增端点违反规范时,这里会直接失败(等价于文档里的 eng/check-openapi)。 /// [Collection("backend")] public sealed partial class OpenApiConformanceTests { private readonly BackendFixture _backend; public OpenApiConformanceTests(BackendFixture backend) { _backend = backend; } private async Task 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(); foreach ((string path, string method, JsonObject operation) in Operations(doc)) { string id = operation["operationId"]?.GetValue() ?? ""; 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()), $"{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", "/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()); } } 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(); }