using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text.Json; using Yh.Platform.Abstractions; using Xunit; namespace Yh.Platform.OpenApi.Tests; /// /// 契约快照对拍(双端同步机制的核心防线): /// 基线 = UserBase springdoc 导出的权威快照 contract/openapi-oapi.exported.json /// (GET /userbase/v3/api-docs/oapi,UserBase 契约变更后重新导出更新该文件即可激活本测试发现漂移)。 /// /// 端点对拍:SDK 契约路由表 ⊆ 快照(method+path 逐项),多出的快照端点以未覆盖清单输出; /// 模型对拍:SDK POCO 属性集(camelCase)⇔ 快照 schema properties 键集,双向差额为零。 /// /// 更新基线流程:UserBase 起服务 → GET /v3/api-docs/oapi 覆盖保存 exported.json → 跑本测试,红了就是契约漂移。 /// public class ContractSnapshotTests { private static readonly string SnapshotPath = Path.Combine(AppContext.BaseDirectory, "contract", "openapi-oapi.exported.json"); private static JsonDocument LoadSnapshot() { Assert.True(File.Exists(SnapshotPath), $"权威契约快照缺失:{SnapshotPath}(从 UserBase GET /v3/api-docs/oapi 导出)"); return JsonDocument.Parse(File.ReadAllText(SnapshotPath)); } /// PascalCase → camelCase(首字母小写,覆盖 Id→id、Idcard→idcard 等既有键形)。 private static string ToCamel(string name) => string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name.Substring(1); private static HashSet PocoPropertyNames(Type type) => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => ToCamel(p.Name)) .ToHashSet(StringComparer.Ordinal); private static HashSet SchemaPropertyNames(JsonElement schemas, string schemaName) { Assert.True(schemas.TryGetProperty(schemaName, out var schema), $"快照缺少 schema:{schemaName}(UserBase 侧可能已改名/删除)"); return schema.GetProperty("properties").EnumerateObject() .Select(p => p.Name) .ToHashSet(StringComparer.Ordinal); } // ───────────────────────── 端点对拍 ───────────────────────── /// SDK 覆盖的契约路由表(method + path;来源:三个 Client 实现,加端点必须同步登记于此)。 public static readonly (string Method, string Path)[] SdkRoutes = { // user 域 ("GET", "/oapi/user/byId"), ("GET", "/oapi/user/byLoginName"), ("GET", "/oapi/user/byPhone"), ("GET", "/oapi/user/modules"), ("GET", "/oapi/user/roles"), ("GET", "/oapi/user/groupState"), ("POST", "/oapi/user/add"), ("POST", "/oapi/user/edit/{id}"), ("POST", "/oapi/user/delete/{id}/{groupId}/{schoolId}"), ("POST", "/oapi/user/deleteUsers"), ("POST", "/oapi/user/batchAdd"), ("POST", "/oapi/user/moveToGroup"), ("POST", "/oapi/user/password"), ("POST", "/oapi/user/groupState"), ("POST", "/oapi/user/transferToSchool"), ("POST", "/oapi/user/changeStatus"), ("POST", "/oapi/user/batchChangeStatus"), // identity 域 ("GET", "/oapi/identity/current"), ("GET", "/oapi/identity/available"), ("POST", "/oapi/identity/switch"), // group 域 ("GET", "/oapi/group/tree"), ("GET", "/oapi/group/byId"), ("GET", "/oapi/group/list"), ("POST", "/oapi/group/add"), ("POST", "/oapi/group/edit/{id}"), ("POST", "/oapi/group/delete/{id}/{schoolId}"), ("POST", "/oapi/group/batchAdd"), ("POST", "/oapi/group/move/{schoolId}"), // app 域 ("GET", "/oapi/app/{appId}"), ("GET", "/oapi/app/list"), }; private static HashSet<(string, string)> SnapshotRoutes(JsonDocument snapshot) { var routes = new HashSet<(string, string)>(); foreach (var path in snapshot.RootElement.GetProperty("paths").EnumerateObject()) { foreach (var op in path.Value.EnumerateObject()) { if (op.Name is "get" or "post" or "put" or "delete" or "patch") { routes.Add((op.Name.ToUpperInvariant(), path.Name)); } } } return routes; } [Fact] public void SDK端点必须在快照中存在() { var snapshot = LoadSnapshot(); var snapshotRoutes = SnapshotRoutes(snapshot); var missing = SdkRoutes .Where(r => !snapshotRoutes.Contains((r.Method, r.Path))) .Select(r => $"{r.Method} {r.Path}") .ToList(); Assert.True(missing.Count == 0, $"以下 SDK 端点在权威快照中不存在(UserBase 侧已删除或改路径,须同步修正 SDK):\n{string.Join('\n', missing)}"); } [Fact] public void 快照端点若SDK未覆盖须显式登记() { var snapshot = LoadSnapshot(); var snapshotRoutes = SnapshotRoutes(snapshot); var sdk = SdkRoutes.ToHashSet(); var uncovered = snapshotRoutes .Where(r => !sdk.Contains(r)) .Select(r => $"{r.Item1} {r.Item2}") .OrderBy(x => x) .ToList(); // 不直接失败(UserBase 新增端点不应阻塞 SDK CI),但必须显式白名单化——未登记的未覆盖端点视为漂移信号 var acknowledged = new HashSet(StringComparer.Ordinal) { // 当前无未覆盖端点;UserBase 新增 /oapi 端点后,此清单须显式登记并排期跟进,防止无声失联 }; var unacknowledged = uncovered.Where(u => !acknowledged.Contains(u)).ToList(); Assert.True(unacknowledged.Count == 0, $"快照存在 SDK 未覆盖且未登记白名单的端点(评估跟进或显式放弃):\n{string.Join('\n', unacknowledged)}"); } // ───────────────────────── 模型对拍 ───────────────────────── /// SDK 模型 ↔ 快照 schema 映射表(新模型对齐必须登记于此)。 public static readonly (Type Type, string Schema)[] ModelMappings = { (typeof(BoaUser), "BoaUser"), (typeof(BoaRoleInfo), "BoaRoleInfo"), (typeof(BoaGroup), "BoaGroup"), (typeof(BoaModule), "BoaModule"), (typeof(BoaApp), "BoaApp"), (typeof(BoaAppType), "BoaAppType"), (typeof(SysUserRequest), "SysUserRequest"), (typeof(SysUserGroupDeleteItem), "SysUserGroupDeleteItem"), (typeof(SysUserMoveRequest), "SysUserMoveRequest"), (typeof(SysUserGroupStateRequest), "SysUserGroupStateRequest"), (typeof(SysUserTransferSchoolRequest), "SysUserTransferSchoolRequest"), (typeof(SysGroupRequest), "SysGroupRequest"), (typeof(SysGroupMoveRequest), "SysGroupMoveRequest"), }; [Theory] [MemberData(nameof(ModelCases))] public void 模型字段集与快照双向一致(Type type, string schemaName) { var snapshot = LoadSnapshot(); var schemas = snapshot.RootElement.GetProperty("components").GetProperty("schemas"); var poco = PocoPropertyNames(type); var server = SchemaPropertyNames(schemas, schemaName); var sdkOnly = poco.Except(server).ToList(); var serverOnly = server.Except(poco).ToList(); Assert.True(sdkOnly.Count == 0, $"{type.Name} 存在快照没有的属性(服务端收不到,序列化键漂移):[{string.Join(", ", sdkOnly)}]"); Assert.True(serverOnly.Count == 0, $"{type.Name} 缺少快照属性(服务端字段被丢弃):[{string.Join(", ", serverOnly)}]"); } public static IEnumerable ModelCases() => ModelMappings.Select(m => new object[] { m.Type, m.Schema }); }