bag
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 契约快照对拍(双端同步机制的核心防线):
|
||||
/// 基线 = UserBase springdoc 导出的权威快照 contract/openapi-oapi.exported.json
|
||||
/// (GET /userbase/v3/api-docs/oapi,UserBase 契约变更后重新导出更新该文件即可激活本测试发现漂移)。
|
||||
/// <list type="bullet">
|
||||
/// <item>端点对拍:SDK 契约路由表 ⊆ 快照(method+path 逐项),多出的快照端点以未覆盖清单输出;</item>
|
||||
/// <item>模型对拍:SDK POCO 属性集(camelCase)⇔ 快照 schema properties 键集,双向差额为零。</item>
|
||||
/// </list>
|
||||
/// 更新基线流程:UserBase 起服务 → GET /v3/api-docs/oapi 覆盖保存 exported.json → 跑本测试,红了就是契约漂移。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>PascalCase → camelCase(首字母小写,覆盖 Id→id、Idcard→idcard 等既有键形)。</summary>
|
||||
private static string ToCamel(string name)
|
||||
=> string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name.Substring(1);
|
||||
|
||||
private static HashSet<string> PocoPropertyNames(Type type)
|
||||
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => ToCamel(p.Name))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
private static HashSet<string> 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);
|
||||
}
|
||||
|
||||
// ───────────────────────── 端点对拍 ─────────────────────────
|
||||
|
||||
/// <summary>SDK 覆盖的契约路由表(method + path;来源:三个 Client 实现,加端点必须同步登记于此)。</summary>
|
||||
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<string>(StringComparer.Ordinal)
|
||||
{
|
||||
// 当前无未覆盖端点;UserBase 新增 /oapi 端点后,此清单须显式登记并排期跟进,防止无声失联
|
||||
};
|
||||
|
||||
var unacknowledged = uncovered.Where(u => !acknowledged.Contains(u)).ToList();
|
||||
Assert.True(unacknowledged.Count == 0,
|
||||
$"快照存在 SDK 未覆盖且未登记白名单的端点(评估跟进或显式放弃):\n{string.Join('\n', unacknowledged)}");
|
||||
}
|
||||
|
||||
// ───────────────────────── 模型对拍 ─────────────────────────
|
||||
|
||||
/// <summary>SDK 模型 ↔ 快照 schema 映射表(新模型对齐必须登记于此)。</summary>
|
||||
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<object[]> ModelCases()
|
||||
=> ModelMappings.Select(m => new object[] { m.Type, m.Schema });
|
||||
}
|
||||
Reference in New Issue
Block a user