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