Files
2026-09-02 09:38:45 +08:00

188 lines
8.2 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}