This commit is contained in:
wanghu
2026-09-02 09:38:45 +08:00
commit 8774b6cc44
128 changed files with 9382 additions and 0 deletions
@@ -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 });
}
@@ -0,0 +1,185 @@
using System.Net;
using System.Text;
using Yh.Platform.Abstractions;
using Yh.Platform.OpenApi;
using Xunit;
namespace Yh.Platform.OpenApi.Tests;
/// <summary>伪造 HttpMessageHandler:捕获最近一次请求并回放预设响应。</summary>
public sealed class FakeHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, string?, HttpResponseMessage> _responder;
public FakeHandler(Func<HttpRequestMessage, string?, HttpResponseMessage> responder)
{
_responder = responder;
}
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastRequestBody = request.Content == null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return _responder(request, LastRequestBody);
}
public static FakeHandler Json(HttpStatusCode statusCode, string json)
=> new((_, _) => new HttpResponseMessage(statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
}
/// <summary>
/// 信封感知解析对拍(对齐 contract/fixtures/envelope-*.json 约定):
/// 成功为裸数据;业务失败为 HTTP 200 + {code≠0,msg,data},客户端必须抛 DException。
/// </summary>
public class EnvelopeParsingTests
{
private static OpenApiOptions Options() => new() { BaseHost = "http://base.bestsch.com", BasePath = "/userbase" };
private static OpenApiUserClient Client(FakeHandler handler) => new(new HttpClient(handler), Options());
[Fact]
public async Task 裸数据成功响应_解析为BoaUser()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"id":1001,"account":"zhangsan","name":"张三","currentRole":{"roleId":3,"roleName":"教师","schId":2}}""");
var user = await Client(handler).GetUserByIdAsync("1001");
Assert.NotNull(user);
Assert.Equal(1001, user!.Id);
Assert.Equal("zhangsan", user.Account);
Assert.Equal("张三", user.Name);
Assert.NotNull(user.CurrentRole);
Assert.Equal(3, user.CurrentRole!.RoleId);
Assert.Equal("教师", user.CurrentRole.RoleName);
}
[Fact]
public async Task 包裹信封code0_提取data节点()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"code":0,"msg":"","data":{"id":1001,"account":"zhangsan","name":"张三"}}""");
var user = await Client(handler).GetUserByIdAsync("1001");
Assert.NotNull(user);
Assert.Equal("zhangsan", user!.Account);
}
[Fact]
public async Task 业务失败信封_抛DException并携带code与data()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"code":5,"msg":"组下存在子组或用户,禁止删除","data":{"groupId":9,"users":35}}""");
var ex = await Assert.ThrowsAsync<DException>(() => new OpenApiGroupClient(new HttpClient(handler), Options()).GetGroupByIdAsync(9));
var ec = Assert.IsType<OpenApiErrorCode>(ex.Code);
Assert.Equal(5, ec.Code);
Assert.Equal("组下存在子组或用户,禁止删除", ec.Msg);
Assert.Contains("禁止删除", ex.Message);
Assert.NotNull(ex.Data);
}
[Fact]
public async Task 业务失败信封data为null_EnvelopeData为null()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"code":4,"msg":"登录已经过期,请重新登录","data":null}""");
var ex = await Assert.ThrowsAsync<DException>(() => Client(handler).GetUserByIdAsync("1"));
Assert.Equal(4, Assert.IsType<OpenApiErrorCode>(ex.Code).Code);
Assert.Null(ex.EnvelopeData);
}
[Fact]
public async Task 非成功HTTP状态_抛OpenApiException()
{
var handler = FakeHandler.Json(HttpStatusCode.InternalServerError, "boom");
var ex = await Assert.ThrowsAsync<OpenApiException>(() => Client(handler).GetUserByIdAsync("1"));
Assert.Contains("500", ex.Message);
}
[Fact]
public async Task 列表解析_裸数组与单对象两种形态()
{
var arrayHandler = FakeHandler.Json(HttpStatusCode.OK,
"""[{"roleId":3,"roleName":"教师","schId":2},{"roleId":5,"roleName":"班主任","schId":2}]""");
var roles = await Client(arrayHandler).GetUserRoleListAsync(1001);
Assert.Equal(2, roles.Count);
Assert.Equal("教师", roles[0].RoleName);
Assert.Equal(5, roles[1].RoleId);
var singleHandler = FakeHandler.Json(HttpStatusCode.OK, """{"roleId":3,"roleName":"教师","schId":2}""");
var single = await Client(singleHandler).GetUserRoleListAsync(1001);
Assert.Single(single);
}
[Fact]
public async Task GET请求_URL与query构造正确()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, "null");
await Client(handler).GetUserModuleAsync(1001, 2);
var url = handler.LastRequest!.RequestUri!;
Assert.Equal("http://base.bestsch.com/userbase/oapi/user/modules", $"{url.Scheme}://{url.Host}{url.AbsolutePath}");
Assert.Equal("?userId=1001&schId=2", url.Query);
Assert.Equal(HttpMethod.Get, handler.LastRequest.Method);
}
[Fact]
public async Task query中的null值参数被丢弃()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, "[]");
await Client(handler).GetUserModuleAsync(1001, null);
Assert.DoesNotContain("schId", handler.LastRequest!.RequestUri!.Query);
Assert.Equal("?userId=1001", handler.LastRequest.RequestUri.Query);
}
[Fact]
public async Task POST请求_编辑用户时路径id回写请求体()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """{"id":7,"account":"lisi","name":"李四"}""");
var request = new SysUserRequest { Account = "lisi", RealName = "李四" };
await Client(handler).EditUserAsync(7, request);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.EndsWith("/userbase/oapi/user/edit/7", handler.LastRequest.RequestUri!.AbsolutePath);
Assert.NotNull(handler.LastRequestBody);
Assert.Contains("\"id\":7", handler.LastRequestBody);
}
[Fact]
public async Task void解析_业务失败仍抛异常_成功不抛()
{
var ok = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"","data":null}""");
await Client(ok).DeleteUserAsync(1, 2, 3);
var fail = FakeHandler.Json(HttpStatusCode.OK, """{"code":5,"msg":"无权限","data":null}""");
await Assert.ThrowsAsync<DException>(() => Client(fail).DeleteUserAsync(1, 2, 3));
}
[Fact]
public async Task 空响应体_返回default不抛()
{
var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("", Encoding.UTF8, "application/json"),
});
var user = await Client(handler).GetUserByIdAsync("1");
Assert.Null(user);
}
}
@@ -0,0 +1,9 @@
global using System;
global using System.Collections.Generic;
global using System.Net;
global using System.Net.Http;
global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using Yh.Platform.Abstractions;
global using Yh.Platform.OpenApi;
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.Platform.OpenApi\Yh.Platform.OpenApi.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
<!-- UserBase springdoc /v3/api-docs/oapi 导出的权威契约快照(双端同步对拍基线) -->
<Content Include="..\..\contract\openapi-oapi.exported.json" CopyToOutputDirectory="PreserveNewest" Link="contract\openapi-oapi.exported.json" />
</ItemGroup>
</Project>
@@ -0,0 +1,117 @@
using Yh.Platform.Abstractions;
using Yh.Platform.OpenApi;
using Xunit;
namespace Yh.Platform.OpenApi.Tests;
public class OpenApiOptionsTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("/userbase", "/userbase")]
[InlineData("/userbase/", "/userbase")]
[InlineData("userbase", "/userbase")]
public void NormalizeBasePath_归一化规则(string? input, string? expected)
{
Assert.Equal(expected, OpenApiOptions.NormalizeBasePath(input));
}
[Fact]
public void ResolveBaseUrl_拼接host与path并去尾斜杠()
{
var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com/", BasePath = "/userbase/" };
Assert.Equal("http://base.bestsch.com/userbase", options.ResolveBaseUrl());
}
[Fact]
public void ResolveBaseUrl_path为空时挂根路径()
{
var options = new OpenApiOptions { BaseHost = "http://base.bestsch.com", BasePath = "" };
Assert.Equal("http://base.bestsch.com", options.ResolveBaseUrl());
}
[Fact]
public void 默认值_对齐Java客户端()
{
var options = new OpenApiOptions();
Assert.True(options.Enabled);
Assert.Equal("/userbase", options.BasePath);
Assert.Equal(1500, options.ConnectTimeoutMs);
Assert.Equal(5000, options.ReadTimeoutMs);
Assert.Equal(5000, options.WriteTimeoutMs);
}
}
/// <summary>静态门面 <see cref="YschBaseOpenApi"/> 的初始化准入与委托行为。</summary>
public class YschBaseOpenApiTests : IDisposable
{
private sealed class StubUserClient : IOpenApiUserClient
{
public int Calls;
public Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default)
{
Calls++;
return Task.FromResult<BoaUser?>(new BoaUser(userId, "stub", "桩用户"));
}
public Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default) => throw new NotImplementedException();
public Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> AddUserAsync(SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteUsersAsync(IReadOnlyList<SysUserGroupDeleteItem> items, CancellationToken ct = default) => throw new NotImplementedException();
public Task<List<BoaUser>> BatchAddUsersAsync(IReadOnlyList<SysUserRequest> requests, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default) => throw new NotImplementedException();
public Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<List<BoaRoleInfo>> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaRoleInfo?> SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException();
public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default) => throw new NotImplementedException();
public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default) => throw new NotImplementedException();
public Task<int?> GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<BoaUser?> TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default) => throw new NotImplementedException();
public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default) => throw new NotImplementedException();
public Task BatchChangeUserStatusAsync(IReadOnlyList<int> userIds, bool disabled, CancellationToken ct = default) => throw new NotImplementedException();
}
public YschBaseOpenApiTests()
{
YschBaseOpenApi.SetUserClient(null);
YschBaseOpenApi.SetGroupClient(null);
YschBaseOpenApi.SetAppClient(null);
}
public void Dispose()
{
YschBaseOpenApi.SetUserClient(null);
YschBaseOpenApi.SetGroupClient(null);
YschBaseOpenApi.SetAppClient(null);
}
[Fact]
public async Task 未初始化时抛InvalidOperationException并给出引导信息()
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => YschBaseOpenApi.GetUserInfoAsync(1));
Assert.Contains("AddYhOpenApi", ex.Message);
Assert.Contains("SetUserClient", ex.Message);
}
[Fact]
public async Task 初始化后门面委托到注入的client()
{
var stub = new StubUserClient();
YschBaseOpenApi.SetUserClient(stub);
var user = await YschBaseOpenApi.GetUserInfoAsync(1001);
Assert.Equal(1, stub.Calls);
Assert.NotNull(user);
Assert.Equal(1001, user!.Id);
Assert.Equal("stub", user.Account);
}
}