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,43 @@
using Xunit;
using Yh.Platform.Abstractions;
namespace Yh.Platform.Abstractions.Tests;
public class ErrorCodeAndExceptionTests
{
[Fact]
public void ErrorCode_常量值与Java一比一()
{
Assert.Equal(0, ErrorCode.Success.Code);
Assert.Equal(1, ErrorCode.Error.Code);
Assert.Equal(2, ErrorCode.ParamError.Code);
Assert.Equal(3, ErrorCode.Forbidden.Code);
Assert.Equal(4, ErrorCode.Unauthorized.Code);
Assert.Equal(5, ErrorCode.Custom.Code);
Assert.Equal(6, ErrorCode.InternalServerError.Code);
Assert.Equal(405, ErrorCode.HttpMethodNotAllowed.Code);
Assert.Equal("登录已经过期,请重新登录", ErrorCode.Unauthorized.Msg);
}
[Fact]
public void DException_便捷构造_默认Custom且message为纯msg()
{
var e = new DException("业务失败");
Assert.Equal(5, e.Code!.Code);
Assert.Equal("业务失败", e.Message);
}
[Fact]
public void DException_携带code_msg_data_等价Java语义()
{
var code = new OpenApiLike(5, "组下存在子组或用户,禁止删除");
var data = new { groupId = 9 };
var e = new DException(code, code.Msg, data);
Assert.Equal(5, e.Code!.Code);
Assert.Equal("组下存在子组或用户,禁止删除", e.Message);
Assert.Same(data, e.EnvelopeData);
}
private sealed record OpenApiLike(int Code, string Msg) : IErrorCode;
}
@@ -0,0 +1,6 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using Yh.Platform.Abstractions;
@@ -0,0 +1,130 @@
using System.IO;
using System.Text.Json;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using JsonSerializer = System.Text.Json.JsonSerializer;
using Xunit;
using Yh.Platform.Abstractions;
namespace Yh.Platform.Abstractions.Tests;
public class ModelSerializationTests
{
private static JObject LoadFixtureBareData()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json");
return JObject.Parse(File.ReadAllText(path))["bareData"]!.Value<JObject>()!;
}
private static JObject LoadFixtureWrapped()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "envelope-success.json");
return JObject.Parse(File.ReadAllText(path))["wrappedData"]!.Value<JObject>()!;
}
[Fact]
public void Newtonsoft_反序列化fixture_含currentRole嵌套()
{
var user = LoadFixtureBareData().ToObject<BoaUser>(new Newtonsoft.Json.JsonSerializer
{
// net5 消费方形态:ASP.NET Core AddNewtonsoftJson 默认大小写不敏感、保留属性名
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
});
Assert.Equal(1001, user!.Id);
Assert.Equal("zhangsan", user.Account);
Assert.Equal("张三", user.Name);
Assert.Equal(2, user.School!.Id);
Assert.Equal("云海第一中学", user.School.Name);
Assert.Equal(3, user.CurrentRole!.RoleId);
Assert.Equal("教师", user.CurrentRole.RoleName);
Assert.Equal("云海第一中学", user.CurrentRole.SchName);
Assert.Equal(21, user.CurrentRole.ChildId);
Assert.Equal(2, user.Schools!.Count);
Assert.Equal(new List<int> { 3, 5 }, user.RoleIds);
Assert.Equal(3, user.Groups!.Count);
}
[Fact]
public void Newtonsoft_默认设置_大小写不敏感匹配camelCase()
{
var user = LoadFixtureBareData().ToObject<BoaUser>();
Assert.Equal("zhangsan", user!.Account);
Assert.Equal("教师", user.CurrentRole!.RoleName);
}
[Fact]
public void SystemTextJson_Web序列化_与fixture逐节点一致()
{
var user = LoadFixtureBareData().ToObject<BoaUser>();
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
// 对齐 Java 侧 NON_NULL 语义
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
var json = JsonSerializer.Serialize(user, options);
// 语义等价:键序无关、null 节点差异容忍(fixture 中显式 null 与省略 null 视为一致)
Assert.True(JToken.DeepEquals(JObject.Parse(json), StripNulls(LoadFixtureBareData())),
"STJ 序列化结果应与 fixture 语义一致\n" + json);
}
private static JObject StripNulls(JObject obj)
{
var clone = (JObject)obj.DeepClone();
var toRemove = new List<JProperty>();
CollectNullProps(clone, toRemove);
foreach (var p in toRemove)
{
p.Remove();
}
return clone;
}
private static void CollectNullProps(JToken token, List<JProperty> collected)
{
foreach (var child in token.Children())
{
if (child is JProperty p)
{
if (p.Value.Type == JTokenType.Null)
{
collected.Add(p);
continue;
}
CollectNullProps(p.Value, collected);
}
else if (child.Type is JTokenType.Object or JTokenType.Array)
{
CollectNullProps(child, collected);
}
}
}
[Fact]
public void SystemTextJson_反序列化信封包裹成功样例()
{
var wrapped = LoadFixtureWrapped();
var envelope = wrapped.ToObject<ApiEnvelope<BoaUser>>(new Newtonsoft.Json.JsonSerializer
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
});
Assert.True(envelope!.IsSuccess);
Assert.Equal(0, envelope.Code);
Assert.Equal(1001, envelope.Data!.Id);
}
[Fact]
public void BoaApp_Models_双序列化往返()
{
var app = new BoaApp { Id = 7, Name = "作业提交", Clients = (int)ClientEnum.PcBrowser | (int)ClientEnum.MobileApp, IsPublic = false, BuiltInTag = 0 };
var stj = JsonSerializer.Deserialize<BoaApp>(JsonSerializer.Serialize(app, new JsonSerializerOptions(JsonSerializerDefaults.Web)), new JsonSerializerOptions(JsonSerializerDefaults.Web));
var nf = JObject.Parse(JsonConvert.SerializeObject(app, new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() })).ToObject<BoaApp>();
Assert.Equal(app.Name, stj!.Name);
Assert.Equal(app.Clients, stj.Clients);
Assert.Equal(app.Name, nf!.Name);
Assert.Equal(false, nf.IsPublic);
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
</ItemGroup>
</Project>
@@ -0,0 +1,131 @@
using System.Linq;
using System.Text.Json;
using Xunit;
namespace Yh.Platform.AppAuth.Tests;
/// <summary>
/// AppSignHandler basePath 剥离对拍(验收 P1 修复):
/// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。
/// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。
/// </summary>
public class AppSignHandlerTests
{
private sealed class CaptureHandler : HttpMessageHandler
{
public HttpRequestMessage? Last { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Last = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private const string AppKey = "100476";
private const string Secret = "app-secret-100476-yunhai";
private static async Task<HttpRequestMessage> SendThroughHandlerAsync(AppSignHandler handler, HttpMethod method, string url)
{
using var client = new HttpClient(handler);
var request = new HttpRequestMessage(method, url);
await client.SendAsync(request);
// handler 已把三头写入同一个 request 实例;CaptureHandler 捕获的是该实例
return request;
}
/// <summary>读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。</summary>
public static IEnumerable<object[]> RequestLevelCases()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
foreach (var c in root.GetProperty("cases").EnumerateArray())
{
if (c.TryGetProperty("fullUrl", out _))
{
yield return new object[] { c.Clone() };
}
}
}
[Theory]
[MemberData(nameof(RequestLevelCases))]
public async Task Handler对fullUrl签名_与Java基线逐字节一致(JsonElement c)
{
var name = c.GetProperty("name").GetString();
var method = new HttpMethod(c.GetProperty("method").GetString()!);
var fullUrl = c.GetProperty("fullUrl").GetString()!;
var basePath = c.GetProperty("basePath").GetString()!;
var ts = c.GetProperty("ts").GetString()!;
var secret = c.GetProperty("secret").GetString()!;
var expected = c.GetProperty("expectedRequestSignBase64").GetString()!;
// 固定 ts = 向量 ts(SignUtil.NowMillis 为 handler 与 SignUtil 共用的时钟源)
var original = SignUtil.NowMillis;
SignUtil.NowMillis = () => long.Parse(ts);
try
{
var handler = new AppSignHandler(AppKey, secret, basePath, new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, method, fullUrl);
Assert.Equal(AppKey, sent.Headers.GetValues(AppSignHandler.HeaderAppKey).First());
Assert.Equal(ts, sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First());
// 同 URL、同 ts 下与 Java 客户端(AppSignInterceptor 剥 basePath 后)签名逐字节一致
Assert.Equal(expected, sent.Headers.GetValues(AppSignHandler.HeaderSignature).First());
}
finally
{
SignUtil.NowMillis = original;
}
}
[Fact]
public async Task Handler剥离basePath_并排序query后签名()
{
var handler = new AppSignHandler(AppKey, Secret, "/userbase", new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get,
"http://base.bestsch.com/userbase/oapi/user/byId?b=2&a=1");
var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First();
var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First();
// 剥离 /userbase + query 排序:canonical = /oapi/user/byId?a=1&b=2
Assert.Equal(SignUtil.SignRequest("GET", "/oapi/user/byId?a=1&b=2", ts, Secret), signature);
}
[Fact]
public async Task Handler未配置basePath_不剥离原样签名()
{
var handler = new AppSignHandler(AppKey, Secret, null, new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get,
"http://base.bestsch.com/userbase/oapi/user/byId");
var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First();
var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First();
Assert.Equal(SignUtil.SignRequest("GET", "/userbase/oapi/user/byId", ts, Secret), signature);
}
[Theory]
[InlineData("/userbase/oapi/u", "/userbase", "/oapi/u")]
[InlineData("/userbase", "/userbase", "/")]
[InlineData("/userbase/", "/userbase", "/")]
[InlineData("/userbase/oapi/u", "/userbase/", "/oapi/u")] // basePath 带尾斜杠归一化
[InlineData("/userbase2/x", "/userbase", "/userbase2/x")] // 前缀不完整匹配不剥离(对齐 Java)
[InlineData("/oapi/u", "/userbase", "/oapi/u")] // 无前缀不剥离
[InlineData("/userbase/oapi/u", null, "/userbase/oapi/u")] // basePath 空 = 挂根路径
public void StripBasePath_边界与Java一致(string path, string? basePath, string expected)
{
Assert.Equal(expected, SignCanonical.StripBasePath(path, basePath));
}
[Fact]
public void BuildTarget_带basePath_剥离并排序query()
{
Assert.Equal("/oapi/user/byId?a=1&b=2",
SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"), "/userbase"));
// 不传 basePath 保持既有行为(不剥离,query 仍排序)
Assert.Equal("/userbase/oapi/user/byId?a=1&b=2",
SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1")));
}
}
@@ -0,0 +1,10 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
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.AppAuth;
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Xunit;
using Yh.Platform.AppAuth;
namespace Yh.Platform.AppAuth.Tests;
public class SignCanonicalTests
{
[Theory]
[InlineData("b=2&a=1", "a=1&b=2")]
[InlineData("a=1", "a=1")]
[InlineData("", "")]
[InlineData("groupId=9&withSub=true", "groupId=9&withSub=true")]
public void SortRawQuery_键值整串排序(string raw, string expected)
{
Assert.Equal(expected, SignCanonical.SortRawQuery(raw));
}
[Fact]
public void SortRawQuery_URL解码后排序()
{
// %20 与 + 均解码为空格,解码后按 "k=v" 整串排序
Assert.Equal("a b=1&a b=2", SignCanonical.SortRawQuery("a%20b=1&a+b=2"));
}
[Fact]
public void BuildTarget_无query只取path()
{
Assert.Equal("/userbase/oapi/user/byId", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId")));
Assert.Equal("/userbase/oapi/user/byId?id=123", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId?id=123")));
}
[Fact]
public void BuildTarget_query乱序归一化()
{
Assert.Equal("/p?a=1&b=2", SignCanonical.BuildTarget(new Uri("http://h/p?b=2&a=1")));
}
[Fact]
public async Task MemoryReplayGuard_同三元组只放行一次()
{
var guard = new MemoryReplayGuard();
Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n1"));
Assert.False(await guard.TryBeginAsync("100476", "1700000000000", "n1"));
Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n2"));
Assert.True(await guard.TryBeginAsync("100477", "1700000000000", "n1"));
}
}
@@ -0,0 +1,98 @@
using System.IO;
using System.Linq;
using Xunit;
using Yh.Platform.AppAuth;
namespace Yh.Platform.AppAuth.Tests;
/// <summary>签名向量逐条对拍(基线由独立实现产出,见 contract/fixtures/sign-vectors.json)。</summary>
public class SignVectorTests
{
public static System.Collections.Generic.IEnumerable<object[]> VectorCases()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement;
foreach (var c in root.GetProperty("cases").EnumerateArray())
{
yield return new object[] { c.Clone() };
}
}
[Theory]
[MemberData(nameof(VectorCases))]
public void 向量对拍_逐字节一致(System.Text.Json.JsonElement c)
{
var name = c.GetProperty("name").GetString();
var ts = c.GetProperty("ts").GetString()!;
var secret = c.GetProperty("secret").GetString()!;
if (c.TryGetProperty("expectedDataSignHex", out var hex))
{
var data = c.GetProperty("data").GetString()!;
Assert.True(SignUtil.Sign(data, ts, secret) == hex.GetString(), "数据级向量不一致: " + name);
}
else
{
var method = c.GetProperty("method").GetString()!;
var target = c.GetProperty("canonicalTarget").GetString()!;
var expected = c.GetProperty("expectedRequestSignBase64").GetString()!;
Assert.True(SignUtil.SignRequest(method, target, ts, secret) == expected, "请求级向量不一致: " + name);
}
}
[Fact]
public void 数据级验签_忽略大小写()
{
var secret = "0123456789abcdef";
var sign = SignUtil.Sign("hello", "1700000000000", secret);
Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToUpperInvariant()));
Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToLowerInvariant()));
Assert.False(SignUtil.Verify("hello2", "1700000000000", secret, sign));
}
[Fact]
public void 请求级验签_精确比对()
{
var secret = "0123456789abcdef";
var sign = SignUtil.SignRequest("GET", "/p?a=1", "1700000000000", secret);
Assert.True(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign));
Assert.False(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign.ToLowerInvariant()));
Assert.False(SignUtil.VerifyRequest("POST", "/p?a=1", "1700000000000", secret, sign));
}
[Fact]
public void 密钥过短_抛参数异常()
{
Assert.Throws<ArgumentException>(() => SignUtil.Sign("x", "1700000000000", "short"));
// 16 位恰为最低边界,合法不抛
Assert.Equal(SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"),
SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"));
}
[Fact]
public void 过期时间戳_拒绝_双向偏差()
{
var secret = "0123456789abcdef";
var data = "x";
SignUtil.NowMillis = static () => 1700000000000L;
try
{
var sign = SignUtil.Sign(data, "1700000000000", secret);
Assert.True(SignUtil.VerifyWithExpire(data, "1700000000000", secret, sign)); // 恰好等于 now
Assert.False(SignUtil.VerifyWithExpire(data, "1699999699001", secret, sign)); // 5min-999ms 前
Assert.False(SignUtil.VerifyWithExpire(data, "1700000301000", secret, sign)); // 5min+1s 后
}
finally
{
SignUtil.NowMillis = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
}
[Fact]
public void 向量文件至少十组()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(root.GetProperty("cases").GetArrayLength() >= 10);
}
}
@@ -0,0 +1,22 @@
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
</ItemGroup>
</Project>
@@ -0,0 +1,161 @@
using System.Security.Claims;
using Yh.Platform.Abstractions;
using Yh.Platform.Auth;
using Xunit;
namespace Yh.Platform.Auth.Tests;
/// <summary>CAS serviceValidate 响应 XML 用户提取对拍(对齐 Java CasServiceTicketValidator)。</summary>
public class CasServiceTicketValidatorTests
{
[Fact]
public void 成功响应_提取cas_user()
{
var xml = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>zhangsan</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""";
Assert.Equal("zhangsan", CasServiceTicketValidator.ExtractUser(xml));
}
[Fact]
public void 带属性容错_提取cas_user()
{
var xml = "<cas:serviceResponse><cas:authenticationSuccess><cas:user index=\"0\">lisi</cas:user></cas:authenticationSuccess></cas:serviceResponse>";
Assert.Equal("lisi", CasServiceTicketValidator.ExtractUser(xml));
}
[Fact]
public void 失败响应_返回null()
{
var xml = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationFailure code="INVALID_TICKET">
Ticket ST-1856339 not recognized
</cas:authenticationFailure>
</cas:serviceResponse>
""";
Assert.Null(CasServiceTicketValidator.ExtractUser(xml));
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("<cas:serviceResponse></cas:serviceResponse>")]
public void 无用户节点或空输入_返回null(string? xml)
{
Assert.Null(CasServiceTicketValidator.ExtractUser(xml));
}
[Fact]
public async Task 未配置CAS地址_抛AuthException()
{
var validator = new CasServiceTicketValidator("");
var ex = await Assert.ThrowsAsync<AuthException>(() => validator.ValidateAsync("ST-1", "http://app/cb"));
Assert.Contains("cas-server-url-prefix", ex.Message);
}
}
public class BoaUserContextTests
{
[Fact]
public async Task 异步流内可读取_请求结束清理()
{
Assert.Null(BoaUserContext.Get());
Assert.Null(UserContextHolder.Get());
BoaUser? seenInChild = null;
var user = new BoaUser(1001, "zhangsan", "张三");
BoaUserContext.Set(user);
UserContextHolder.Set(new LoginUser("1001") { LoginName = "zhangsan", DisplayName = "张三" });
await Task.Run(() =>
{
// AsyncLocal 值随执行上下文流入子任务
seenInChild = BoaUserContext.Get();
});
Assert.Same(user, seenInChild);
Assert.Equal("zhangsan", UserContextHolder.Get()?.LoginName);
BoaUserContext.Clear();
UserContextHolder.Clear();
Assert.Null(BoaUserContext.Get());
Assert.Null(UserContextHolder.Get());
}
[Fact]
public void LoginUser_属性封装()
{
var login = new LoginUser("1001")
{
LoginName = "zhangsan",
DisplayName = "张三",
AppKey = "app-001",
};
Assert.Equal("1001", login.UserId);
Assert.Equal("zhangsan", login.LoginName);
Assert.Equal("张三", login.DisplayName);
Assert.Equal("app-001", login.AppKey);
}
}
public class UserContextMiddlewareTests
{
private sealed class StubProvider : IUserDetailsProvider
{
public BoaUser? User { get; set; } = new(1001, "zhangsan", "张三");
public Task<BoaUser?> GetByLoginNameAsync(string loginName, CancellationToken ct = default)
=> Task.FromResult(User);
}
private static HttpContext HttpContextWithLogin(string loginName)
{
var identity = new ClaimsIdentity("CAS");
identity.AddClaim(new Claim("loginName", loginName));
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(identity);
return context;
}
[Fact]
public async Task 认证请求_中间件写入上下文_业务管道内可读()
{
var provider = new StubProvider();
BoaUser? seen = null;
LoginUser? seenLogin = null;
var middleware = new UserContextMiddleware(_ =>
{
seen = BoaUserContext.Get();
seenLogin = UserContextHolder.Get();
return Task.CompletedTask;
});
await middleware.InvokeAsync(HttpContextWithLogin("zhangsan"), provider);
Assert.NotNull(seen);
Assert.Equal(1001, seen!.Id);
Assert.Equal("zhangsan", seenLogin?.LoginName);
Assert.Equal("张三", seenLogin?.DisplayName);
// 请求结束后必须清空,防串扰
Assert.Null(BoaUserContext.Get());
Assert.Null(UserContextHolder.Get());
}
[Fact]
public async Task 未认证请求_不写上下文直接放行()
{
var provider = new StubProvider();
var middleware = new UserContextMiddleware(_ => Task.CompletedTask);
var context = new DefaultHttpContext(); // 匿名
await middleware.InvokeAsync(context, provider);
Assert.Null(BoaUserContext.Get());
Assert.Null(UserContextHolder.Get());
}
}
@@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Security.Claims;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.AspNetCore.Http;
global using Yh.Platform.Abstractions;
global using Yh.Platform.Auth;
@@ -0,0 +1,104 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Yh.Platform.Auth;
using Xunit;
namespace Yh.Platform.Auth.Tests;
/// <summary>
/// 会话票据格式(SessionTicketDataFormat)单测:
/// 32 位 key 往返、未知 key / 过期票据失效、显式退出(Revoke)立即吊销。
/// </summary>
public class SessionTicketDataFormatTests
{
private static AuthenticationTicket NewTicket(DateTimeOffset? expiresUtc = null)
{
var identity = new ClaimsIdentity("CAS");
identity.AddClaim(new Claim(ClaimTypes.Name, "zhangsan"));
var principal = new ClaimsPrincipal(identity);
var props = new AuthenticationProperties();
if (expiresUtc.HasValue)
{
props.ExpiresUtc = expiresUtc;
}
return new AuthenticationTicket(principal, props, "YhCookie");
}
[Fact]
public void Protect_返回32位key_且可往返()
{
var format = new SessionTicketDataFormat();
var key = format.Protect(NewTicket());
Assert.Equal(32, key.Length); // Guid N 格式 = 32 个十六进制字符
var ticket = format.Unprotect(key);
Assert.NotNull(ticket);
Assert.Equal("zhangsan", ticket!.Principal.FindFirstValue(ClaimTypes.Name));
}
[Fact]
public void Unprotect_未知key_返回null()
{
var format = new SessionTicketDataFormat();
Assert.Null((AuthenticationTicket?)format.Unprotect("00000000000000000000000000000000"));
}
[Fact]
public void Unprotect_空或null_返回null()
{
var format = new SessionTicketDataFormat();
Assert.Null((AuthenticationTicket?)format.Unprotect(null));
Assert.Null((AuthenticationTicket?)format.Unprotect(""));
}
[Fact]
public void Revoke_票据立即失效()
{
var format = new SessionTicketDataFormat();
var key = format.Protect(NewTicket());
format.Revoke(key);
Assert.Null((AuthenticationTicket?)format.Unprotect(key));
}
[Fact]
public void Revoke_空key_不抛异常()
{
var format = new SessionTicketDataFormat();
format.Revoke(null);
format.Revoke("");
format.Revoke(" ");
}
[Fact]
public void Unprotect_过期票据_返回null()
{
var format = new SessionTicketDataFormat();
var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddSeconds(-1)));
Assert.Null((AuthenticationTicket?)format.Unprotect(key));
}
[Fact]
public void Unprotect_未过期票据_正常往返()
{
var format = new SessionTicketDataFormat();
var key = format.Protect(NewTicket(DateTimeOffset.UtcNow.AddMinutes(1)));
Assert.NotNull(format.Unprotect(key));
}
[Fact]
public void Revoke_不影响其他票据()
{
var format = new SessionTicketDataFormat();
var keyA = format.Protect(NewTicket());
var keyB = format.Protect(NewTicket());
format.Revoke(keyA);
Assert.Null((AuthenticationTicket?)format.Unprotect(keyA));
Assert.NotNull(format.Unprotect(keyB));
}
}
@@ -0,0 +1,22 @@
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.Platform.Auth\Yh.Platform.Auth.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -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);
}
}
@@ -0,0 +1,53 @@
// 测试工程的运行调试入口(非测试用例)。
//
// 用法:
// dotnet run --project tests/Yh.SchClass.OpenApi.Tests # 一键跑通全部 20 个端点
// dotnet run --project tests/Yh.SchClass.OpenApi.Tests -- --only=OAStu
// dotnet run --project tests/Yh.SchClass.DebugTool -- --list # 端点名清单见 DebugTool
//
// 地址优先级同 SchClassLiveDebug:环境变量 YH_SCHCLASS_DEBUG_* → appsettings.json → 内置默认。
// 在 IDE 里直接 F5 调试本项目即可进入这里(在 Main 内打断点)。
//
// 注意:csproj 已设 GenerateProgramFile=false(关掉 Test SDK 自动 Main),dotnet test 不受影响。
using System.Text;
namespace Yh.SchClass.OpenApi.Tests;
public static class DebugMain
{
public static async Task<int> Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
string? only = null;
foreach (var arg in args)
{
if (arg.StartsWith("--only=", StringComparison.OrdinalIgnoreCase))
{
only = arg[7..].Trim();
}
else if (arg.Equals("--off", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine("跳过联调:--off(同 YH_SCHCLASS_DEBUG_OFF=1)");
return 0;
}
}
if (Environment.GetEnvironmentVariable(SchClassLiveDebug.OffVar) is "1" or "true")
{
Console.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)");
return 0;
}
Console.WriteLine("== SchClass OpenAPI 测试工程调试入口 ==");
var failures = await SchClassLiveDebug.RunAllAsync(Console.WriteLine, only);
if (failures.Count == 0)
{
Console.WriteLine("全部通过 ✅");
return 0;
}
Console.WriteLine($"失败 {failures.Count} 个:{string.Join(", ", failures)}");
return 1;
}
}
@@ -0,0 +1,58 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// 伪造 <see cref="HttpMessageHandler"/>:捕获最近一次请求并回放预设响应。
/// 与 <c>Yh.Platform.OpenApi.Tests</c> 的 FakeHandler 同构,便于两个 SDK 的测试互相参照。
/// </summary>
public sealed class FakeHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, string?, HttpResponseMessage> _responder;
public FakeHandler(Func<HttpRequestMessage, string?, HttpResponseMessage> responder)
{
_responder = responder;
}
/// <summary>最近一次捕获到的请求。</summary>
public HttpRequestMessage? LastRequest { get; private set; }
/// <summary>最近一次请求体(无请求体时为 null)。</summary>
public string? LastRequestBody { get; private set; }
/// <summary>最近一次请求体的 Content-Type(无请求体时为 null)。</summary>
public string? LastContentType { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
if (request.Content == null)
{
LastRequestBody = null;
LastContentType = null;
}
else
{
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
LastContentType = request.Content.Headers.ContentType?.MediaType;
}
return _responder(request, LastRequestBody);
}
/// <summary>回放一段固定 JSON(application/json)。</summary>
public static FakeHandler Json(HttpStatusCode statusCode, string json)
=> new((_, _) => new HttpResponseMessage(statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
/// <summary>回放指定状态码且无响应体(模拟 204 / 404 空响应)。</summary>
public static FakeHandler Empty(HttpStatusCode statusCode)
=> new((_, _) => new HttpResponseMessage(statusCode));
/// <summary>回放纯文本响应(模拟非 JSON 的错误页)。</summary>
public static FakeHandler Text(HttpStatusCode statusCode, string text)
=> new((_, _) => new HttpResponseMessage(statusCode)
{
Content = new StringContent(text, Encoding.UTF8, "text/plain"),
});
}
@@ -0,0 +1,16 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net;
global using System.Net.Http;
global using System.Text;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
global using Yh.SchClass.OpenApi;
global using Yh.SchClass.OpenApi.Model;
global using Xunit;
// Java 侧 Map<String,Object> 兼容行的 .NET 承载类型(与 src 侧同名别名保持一致)
global using SchClassRow = System.Collections.Generic.Dictionary<string, System.Text.Json.JsonElement>;
@@ -0,0 +1,187 @@
using Microsoft.Extensions.Configuration;
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// 联调调试用的环境配置。地址来源优先级:
/// <para>
/// 1. 环境变量(<c>YH_SCHCLASS_DEBUG_HOST</c> / <c>YH_SCHCLASS_DEBUG_PATH</c>);
/// 2. <c>appsettings.json</c>:先独立节 <c>yh:schclass:open</c>,再共享节 <c>yh:open</c> 的
/// <c>BaseHost</c> + <c>SchClassPath</c>;
/// 3. 内置默认 <see cref="DefaultHost"/> + <see cref="DefaultPath"/>。
/// </para>
/// <para>
/// 关闭联调:置 <c>YH_SCHCLASS_DEBUG_OFF=1</c>,用例全部空跑通过(CI 用)。
/// </para>
/// </summary>
internal static class SchClassLiveDebug
{
public const string HostVar = "YH_SCHCLASS_DEBUG_HOST";
public const string PathVar = "YH_SCHCLASS_DEBUG_PATH";
public const string SchVar = "YH_SCHCLASS_DEBUG_SCH";
public const string ClassVar = "YH_SCHCLASS_DEBUG_CLASS";
public const string TeaVar = "YH_SCHCLASS_DEBUG_TEA";
public const string GroupVar = "YH_SCHCLASS_DEBUG_GROUP";
public const string OffVar = "YH_SCHCLASS_DEBUG_OFF";
/// <summary>配置与配置文件都未提供时的兜底联调主机。</summary>
public const string DefaultHost = "http://base.yunhaixingtong.com";
/// <summary>配置与配置文件都未提供时的兜底应用路径。</summary>
public const string DefaultPath = "/bschclass";
private static readonly IConfiguration? Config;
static SchClassLiveDebug()
{
var file = System.IO.Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (File.Exists(file))
{
Config = new ConfigurationBuilder().AddJsonFile(file, optional: true).Build();
}
}
/// <summary>是否开启联调(默认开启;置 <c>YH_SCHCLASS_DEBUG_OFF=1</c> 可关闭,CI 用它空跑)。</summary>
public static bool Enabled
{
get
{
var off = Environment.GetEnvironmentVariable(OffVar);
return !(off == "1" || "true".Equals(off, StringComparison.OrdinalIgnoreCase));
}
}
private static string? Env(string name)
{
var value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string? Configured(string path)
{
var value = Config?[path];
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>联调主机:环境变量 → yh:schclass:open:BaseHost → yh:open:BaseHost → 内置默认。</summary>
public static string Host =>
Env(HostVar)
?? Configured("yh:schclass:open:BaseHost")
?? Configured("yh:open:BaseHost")
?? DefaultHost;
/// <summary>联调基础路径:环境变量 → yh:schclass:open:BasePath → yh:open:SchClassPath → 内置默认。</summary>
public static string? Path =>
Env(PathVar)
?? Configured("yh:open:SchClassPath")
?? DefaultPath;
/// <summary>按当前配置构建门面。</summary>
public static SchClassOpenApi Create()
{
var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path };
return SchClassOpenApiFactory.Create(options);
}
/// <summary>联调目标地址(用于日志输出)。</summary>
public static string Target()
{
var options = new SchClassOpenApiOptions { BaseHost = Host, BasePath = Path };
return options.ResolveBaseUrl();
}
public static string? Sch => Env(SchVar);
public static string? ClassId => Env(ClassVar);
public static int? TeaSerId => int.TryParse(Env(TeaVar), out var id) ? id : null;
/// <summary>DbClass 分组号,缺省 3(对联调环境真实有效的分组)。</summary>
public static int? Group => int.TryParse(Env(GroupVar), out var g) ? g : null;
/// <summary>
/// 一键跑通全部端点(测试用例与 <c>DebugMain.Main</c> 调试入口共用)。
/// 缺省 ID 一律从真实数据推导:用捏造的 ID 会让端点恒成立 0 条,把真实问题伪装成"接口空"。
/// 返回失败端点名列表;<paramref name="log"/> 输出进度。
/// </summary>
public static async Task<List<string>> RunAllAsync(Action<string> log, string? only = null)
{
var api = Create();
var sch = Sch
?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId ?? "S001";
var group = Group ?? 3;
var classId = ClassId;
if (classId == null)
{
var cls = (await api.VClass.GetVClassListAsync(sch)).FirstOrDefault();
classId = cls != null && cls.TryGetValue("ClassID", out var cid)
? cid.ToString()
: "1001";
}
var teaId = TeaSerId;
if (teaId == null)
{
var tea = (await api.DbClass.GetSchteaListAsync(group)).FirstOrDefault();
teaId = tea != null
&& tea.TryGetValue("UserTeaSerID", out var tid)
&& int.TryParse(tid.ToString(), out var parsed)
? parsed
: 77;
}
var cases = new List<(string Name, Func<Task<int>> Call)>
{
// Group=3 对应联调环境真实存在的分组;可用 YH_SCHCLASS_DEBUG_GROUP 覆盖
("DbClass.GetSchteaList", async () => (await api.DbClass.GetSchteaListAsync(group)).Count),
("VSchClass.GetVClassList", async () => (await api.VClass.GetVClassListAsync(sch)).Count),
("VSchClass.GetSchArrVClassListAll", async () => (await api.VClass.GetSchArrVClassListAllAsync()).Count),
("VSchClass.GetSchArrVClassListPost", async () => (await api.VClass.GetSchArrVClassListPostAsync(sch)).Count),
("Schoolen.GetSchLenLevellist", async () => (await api.Schooling.GetSchLenLevelListAsync(sch)).Count),
("Schoolen.GetSchLenlist", async () => (await api.Schooling.GetSchLenListAsync(sch)).Count),
("UserInfo.GetUserList", async () => (await api.UserInfo.GetUserListAsync(sch)).Count),
("SchInfo.AllList", async () => (await api.SchInfo.AllListAsync()).Count),
("SchInfo.GetSchlist", async () => (await api.SchInfo.GetSchListAsync(null)).Count),
("OATea.GetSchTeainfo", async () => (await api.TeaInfo.GetSchTeaInfoAsync(sch, null)).Count),
("OATea.GetArrTeainfo", async () => (await api.TeaInfo.GetArrTeaInfoAsync(teaId.Value.ToString())).Count),
("OAStu.GetAllStulist", async () => (await api.StuInfo.GetAllStuListAsync()).Count),
("OAStu.GetSchStulist", async () => (await api.StuInfo.GetSchStuListAsync(sch, null)).Count),
("OAStu.GetClaStulist", async () => (await api.StuInfo.GetClaStuListAsync(classId, null)).Count),
("TeachInfo.GetSchTeachlist", async () => (await api.TeachInfo.GetSchTeachListAsync(sch, teaId)).Count),
("TeachInfo.GetSchClaTeachlist", async () => (await api.TeachInfo.GetSchClaTeachListAsync(classId)).Count),
("SubInfo.GetSchTeachSublist", async () => (await api.SubInfo.GetSchTeachSubListAsync(sch)).Count),
("SubInfo.GetSchSublist", async () => (await api.SubInfo.GetSchSubListAsync(sch, null, null)).Count),
("FamCom.GetStuUserFamIDList", async () => (await api.FamilyCompat.GetStuUserFamIdListAsync("1,2")).Count),
("FamCom.GetAllStuFamMem", async () => (await api.FamilyCompat.GetAllStuFamMemAsync("1,2")).Count),
};
if (only != null)
{
cases = cases.Where(c => c.Name.Contains(only, StringComparison.OrdinalIgnoreCase)).ToList();
if (cases.Count == 0)
{
log($"--only={only} 未匹配任何端点");
return new List<string> { "--only" };
}
}
var failures = new List<string>();
log($"联调目标:{Target()} sch={sch} classId={classId} teaId={teaId} group={group}");
foreach (var (name, call) in cases)
{
try
{
var count = await call();
log($" OK {name} -> {count} 条");
}
catch (Exception e)
{
failures.Add(name);
log($" FAIL {name} -> {e.GetType().Name}: {e.Message}");
}
}
log($"汇总:{cases.Count - failures.Count}/{cases.Count} 通过");
return failures;
}
}
@@ -0,0 +1,88 @@
using Xunit.Abstractions;
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// 联调调试用例:默认请求联调环境 <c>http://base.yunhaixingtong.com/bschclass</c>,
/// 并把每个端点的返回条数/异常打印到测试输出,便于在 IDE 调试窗口里逐个端点排查。
/// <para>
/// 用 <c>YH_SCHCLASS_DEBUG_HOST</c> / <c>YH_SCHCLASS_DEBUG_PATH</c> 覆盖目标地址,
/// 置 <c>YH_SCHCLASS_DEBUG_OFF=1</c> 可关闭联调(CI 用它空跑通过)。
/// </para>
/// </summary>
public class SchClassLiveDebugTests(ITestOutputHelper output)
{
private bool Live()
{
if (SchClassLiveDebug.Enabled)
{
return true;
}
output.WriteLine("跳过联调:已设置 " + SchClassLiveDebug.OffVar + "=1(清除该变量即可恢复联调)");
return false;
}
[Fact]
public async Task 联调_学校列表()
{
if (!Live()) return;
var api = SchClassLiveDebug.Create();
var list = await api.SchInfo.AllListAsync();
foreach (var s in list)
{
output.WriteLine($"SchInfo: {s.SchSerId} / {s.UserSchSerId} / {s.SchName}");
}
output.WriteLine($"共 {list.Count} 所学校 —— {SchClassLiveDebug.Target()}");
}
[Fact]
public async Task 联调_班级与学生()
{
if (!Live()) return;
var api = SchClassLiveDebug.Create();
var sch = SchClassLiveDebug.Sch
?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId;
if (string.IsNullOrEmpty(sch))
{
output.WriteLine("未取到学校序列号(设置 YH_SCHCLASS_DEBUG_SCH 或确认学校列表非空)");
return;
}
var classes = await api.VClass.GetVClassListAsync(sch);
output.WriteLine($"VSchClass[{sch}] 共 {classes.Count} 个班级");
var students = await api.StuInfo.GetSchStuListAsync(sch, null);
output.WriteLine($"OAStu[{sch}] 共 {students.Count} 名学生");
}
[Fact]
public async Task 联调_教师与任课()
{
if (!Live()) return;
var api = SchClassLiveDebug.Create();
var sch = SchClassLiveDebug.Sch
?? (await api.SchInfo.AllListAsync()).FirstOrDefault()?.SchSerId;
var teachers = await api.TeaInfo.GetSchTeaInfoAsync(sch, null);
output.WriteLine($"OATea[{sch}] 共 {teachers.Count} 名教师");
if (SchClassLiveDebug.TeaSerId is { } teaId)
{
var teach = await api.TeachInfo.GetSchTeachListAsync(sch, teaId);
output.WriteLine($"TeachInfo[{teaId}] 共 {teach.Count} 条任课");
}
}
[Fact]
public async Task 联调_一键跑通全部端点()
{
if (!Live()) return;
var failures = await SchClassLiveDebug.RunAllAsync(output.WriteLine);
Assert.Empty(failures);
}
}
@@ -0,0 +1,142 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// 配置读取测试:对应 appsettings.json 的真实结构 ——
/// <code>
/// "yh": { "open": {
/// "BaseHost": "http://base.yunhaixingtong.com",
/// "BasePath": "/userbase", // UserBase 用,教务不用它
/// "SchClassPath": "/bschclass" // 教务用它
/// }}
/// </code>
/// </summary>
public class SchClassOpenApiConfigTests
{
private static IConfiguration BuildConfig(Dictionary<string, string?> kv)
=> new ConfigurationBuilder().AddInMemoryCollection(kv).Build()!;
[Fact]
public void 共享节_SchClassPath映射为教务BasePath()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
["yh:open:BasePath"] = "/userbase",
["yh:open:SchClassPath"] = "/bschclass",
});
var options = new SchClassOpenApiOptions();
var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName);
options.FillFromSharedSection(key => shared[key]);
Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost);
// 关键:教务用 SchClassPath,不是共享节里的 BasePath(/userbase)
Assert.Equal("/bschclass", options.BasePath);
Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl());
}
[Fact]
public void 共享节_路径带空格与斜杠可归一化()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
["yh:open:SchClassPath"] = " /bschclass/", // 模拟配置里常见的多余空格/尾斜杠
});
var options = new SchClassOpenApiOptions();
var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName);
options.FillFromSharedSection(key => shared[key]);
Assert.Equal("/bschclass", options.BasePath);
Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl());
}
[Fact]
public void 共享节_未配SchClassPath时BasePath保持空()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
["yh:open:BasePath"] = "/userbase", // 不允许被教务误用
});
var options = new SchClassOpenApiOptions();
var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName);
options.FillFromSharedSection(key => shared[key]);
Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost);
Assert.Null(options.BasePath);
Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl());
}
[Fact]
public void 独立节优先_共享节仅回填缺失项()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:schclass:open:BasePath"] = "/sch-only", // 独立节只给了路径
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
["yh:open:SchClassPath"] = "/bschclass",
["yh:open:AppKey"] = "100002",
["yh:open:AppSecret"] = "secret",
});
var options = new SchClassOpenApiOptions();
config.GetSection(SchClassOpenApiOptions.SectionName).Bind(options);
var shared = config.GetSection(SchClassOpenApiOptions.SharedSectionName);
options.FillFromSharedSection(key => shared[key]);
Assert.Equal("http://base.yunhaixingtong.com", options.BaseHost); // 从共享节回填
Assert.Equal("/sch-only", options.BasePath); // 独立节优先,不被覆盖
Assert.Equal("100002", options.AppKey); // AppKey 回填
Assert.Equal("secret", options.AppSecret);
}
[Fact]
public void DI_注册后可解析门面且地址来自配置()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
["yh:open:SchClassPath"] = "/bschclass",
});
var services = new ServiceCollection();
services.AddSchClassOpenApi(config);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value;
Assert.Equal("http://base.yunhaixingtong.com/bschclass", options.ResolveBaseUrl());
var facade = provider.GetRequiredService<SchClassOpenApi>();
Assert.NotNull(facade.SchInfo);
Assert.NotNull(facade.StuInfo);
Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), facade.HttpBaseAddress);
}
[Fact]
public void DI_独立节配置整节生效()
{
var config = BuildConfig(new Dictionary<string, string?>
{
["yh:schclass:open:BaseHost"] = "http://sch.example.com",
["yh:schclass:open:BasePath"] = "/sch",
["yh:schclass:open:ReadTimeoutMs"] = "3000",
["yh:open:BaseHost"] = "http://base.yunhaixingtong.com",
});
var services = new ServiceCollection();
services.AddSchClassOpenApi(config);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value;
Assert.Equal("http://sch.example.com", options.BaseHost);
Assert.Equal("/sch", options.BasePath);
Assert.Equal(3000, options.ReadTimeoutMs);
}
}
@@ -0,0 +1,94 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>聚合门面 <see cref="SchClassOpenApi"/> 与工厂 <see cref="SchClassOpenApiFactory"/> 的装配行为。</summary>
public class SchClassOpenApiFacadeTests
{
[Fact]
public void 门面装配全部10个子客户端()
{
var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com/bschclass/oapi/" };
using var http = new HttpClient();
var api = new SchClassOpenApi(http, options);
Assert.NotNull(api.DbClass);
Assert.NotNull(api.VClass);
Assert.NotNull(api.Schooling);
Assert.NotNull(api.UserInfo);
Assert.NotNull(api.SchInfo);
Assert.NotNull(api.TeaInfo);
Assert.NotNull(api.StuInfo);
Assert.NotNull(api.TeachInfo);
Assert.NotNull(api.SubInfo);
Assert.NotNull(api.FamilyCompat);
Assert.Same(options, api.Options);
}
[Fact]
public void 门面构造_httpClient为空时抛()
{
Assert.Throws<ArgumentNullException>(
() => new SchClassOpenApi(null!, new SchClassOpenApiOptions()));
}
[Fact]
public void 门面构造_options为空时抛()
{
using var http = new HttpClient();
Assert.Throws<ArgumentNullException>(() => new SchClassOpenApi(http, null!));
}
[Fact]
public void 子客户端构造_参数为空时抛()
{
using var http = new HttpClient();
var options = new SchClassOpenApiOptions();
Assert.Throws<ArgumentNullException>(() => new SchClassVClassClient(null!, options));
Assert.Throws<ArgumentNullException>(() => new SchClassVClassClient(http, null!));
}
[Fact]
public void 工厂_按配置创建HttpClient()
{
var options = new SchClassOpenApiOptions
{
BaseHost = "http://base.yunhaixingtong.com/",
BasePath = "/bschclass",
ReadTimeoutMs = 3000,
};
using var http = SchClassOpenApiFactory.CreateHttpClient(options);
Assert.Equal(new Uri("http://base.yunhaixingtong.com/bschclass"), http.BaseAddress);
Assert.Equal(TimeSpan.FromMilliseconds(3000), http.Timeout);
}
[Fact]
public void 工厂_读超时非法时回落5秒()
{
var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", ReadTimeoutMs = 0 };
using var http = SchClassOpenApiFactory.CreateHttpClient(options);
Assert.Equal(TimeSpan.FromMilliseconds(5000), http.Timeout);
}
[Fact]
public void 工厂_创建完整门面()
{
var facade = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com" });
Assert.NotNull(facade);
Assert.NotNull(facade.VClass);
Assert.NotNull(facade.StuInfo);
}
[Fact]
public void 工厂_配置为空时抛()
{
Assert.Throws<ArgumentNullException>(() => SchClassOpenApiFactory.CreateHttpClient(null!));
Assert.Throws<ArgumentNullException>(() => SchClassOpenApiFactory.Create(null!));
}
}
@@ -0,0 +1,64 @@
namespace Yh.SchClass.OpenApi.Tests;
public class SchClassOpenApiOptionsTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("/base.yunhaixingtong.com", "/base.yunhaixingtong.com")]
[InlineData("/base.yunhaixingtong.com/", "/base.yunhaixingtong.com")]
[InlineData("base.yunhaixingtong.com", "/base.yunhaixingtong.com")]
[InlineData(" /base.yunhaixingtong.com/ ", "/base.yunhaixingtong.com")]
public void NormalizeBasePath_归一化规则(string? input, string? expected)
{
Assert.Equal(expected, SchClassOpenApiOptions.NormalizeBasePath(input));
}
[Fact]
public void ResolveBaseUrl_拼接host与path并去尾斜杠()
{
var options = new SchClassOpenApiOptions
{
BaseHost = "http://base.yunhaixingtong.com/",
BasePath = "/base.yunhaixingtong.com/",
};
Assert.Equal("http://base.yunhaixingtong.com/base.yunhaixingtong.com", options.ResolveBaseUrl());
}
[Fact]
public void ResolveBaseUrl_path为空时挂根路径()
{
var options = new SchClassOpenApiOptions { BaseHost = "http://base.yunhaixingtong.com", BasePath = "" };
Assert.Equal("http://base.yunhaixingtong.com", options.ResolveBaseUrl());
}
[Fact]
public void ResolveBaseUrl_host为空时返回空串()
{
Assert.Equal("", new SchClassOpenApiOptions().ResolveBaseUrl());
}
[Fact]
public void 默认值_对齐Java客户端()
{
var options = new SchClassOpenApiOptions();
Assert.True(options.Enabled);
Assert.Null(options.BaseHost);
Assert.Null(options.BasePath);
Assert.Equal(1500, options.ConnectTimeoutMs);
Assert.Equal(5000, options.ReadTimeoutMs);
Assert.Equal(5000, options.WriteTimeoutMs);
Assert.Null(options.AppKey);
Assert.Null(options.AppSecret);
}
[Fact]
public void 配置节名_独立于用户中心()
{
Assert.Equal("yh:schclass:open", SchClassOpenApiOptions.SectionName);
}
}
@@ -0,0 +1,104 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>其余客户端(学制 / 科目 / 任课 / 学校教师 / 家长兼容)的端点契约对拍。</summary>
public class SchClassOtherClientsTests
{
[Fact]
public async Task Schoolen_学制与年级_5个端点()
{
const string root = "/oapi/Schoolen/";
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.Schooling.GetSchLenLevelListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevellist", "?SchSerID=S1");
await api.Schooling.GetLevelIdListAsync(3);
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetLevelIdlist", "?Schooling=3");
await api.Schooling.GetSchLenLevelFirstAsync("11");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenLevelFirst", "?GradeId=11");
await api.Schooling.GetSchLenListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchLenlist", "?SchSerID=S1");
await api.Schooling.GetAllClassSubjectAsync(2, "S1", "2026-09-01");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetAllClassSubject", "?schId=2&schserid=S1&startTime=2026-09-01");
}
[Fact]
public async Task SubInfo_科目_4个端点()
{
const string root = "/oapi/SubInfo/";
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.SubInfo.GetSchSubListAsync("S1", 3, 11);
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchSublist", "?SchID=S1&schooling=3&gradeId=11");
await api.SubInfo.GetSchIdsSubListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchIdsSublist", "?SchIds=S1,S2");
await api.SubInfo.GetSchTeaSubListAsync("77");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeaSublist", "?UserTeaSerID=77");
await api.SubInfo.GetSchTeachSubListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachSublist", "?SchID=S1");
}
[Fact]
public async Task TeachInfo_任课_8个端点()
{
const string root = "/oapi/TeachInfo/";
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.TeachInfo.GetSchTeachListAsync("S1", 77);
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchTeachlist", "?SchID=S1&UserTeaSerID=77");
await api.TeachInfo.GetEleSchTeachListAsync("S1", 77);
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchTeachlist", "?SchID=S1&UserTeaSerID=77");
await api.TeachInfo.GetSchArrTeachListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchArrTeachlist", "?SchIDArr=S1,S2");
await api.TeachInfo.GetEleSchArrTeachListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchArrTeachList", "?SchIDArr=S1,S2");
await api.TeachInfo.GetSchClaTeachListAsync("1001");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetSchClaTeachlist", "?ClassID=1001");
await api.TeachInfo.GetEleSchClaTeachListAsync("1001");
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetEleSchClaTeachlist", "?ClassID=1001");
await api.TeachInfo.GetElectiveClassByGradeAsync(1, 2);
SchClassTestHost.Verify(handler, HttpMethod.Get, root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2");
await api.TeachInfo.GetElectiveClassByTeachersAsync("77,78");
SchClassTestHost.VerifyBody(handler, root + "GetElectiveClassByTeachers", "77,78");
}
[Fact]
public async Task DbClass_学校教师名单_1个端点()
{
var handler = SchClassTestHost.Ok();
await SchClassTestHost.Api(handler).DbClass.GetSchteaListAsync(30);
SchClassTestHost.Verify(handler, HttpMethod.Get, "/oapi/DbClass/GetSchteaList", "?Group=30");
}
[Fact]
public async Task FamCom_家长兼容_2个端点均为POST文本体()
{
const string root = "/oapi/Common/FamCom/";
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.FamilyCompat.GetStuUserFamIdListAsync("1,2");
SchClassTestHost.VerifyBody(handler, root + "GetStuUserFamIDList", "1,2");
await api.FamilyCompat.GetAllStuFamMemAsync("1,2");
SchClassTestHost.VerifyBody(handler, root + "GetAllStuFamMem", "1,2");
}
}
@@ -0,0 +1,192 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// 响应解析与状态码归一(教务 OA 兼容端点约定):
/// 成功返回裸数据;404 / 204 表示"无数据"归一为 null / 空列表;400 及其它非 2xx 抛异常。
/// </summary>
public class SchClassResponseParsingTests
{
private static readonly SchClassOpenApiOptions Options = new() { BaseHost = "http://base.yunhaixingtong.com" };
private static SchClassVClassClient VClass(FakeHandler handler) => new(new HttpClient(handler), Options);
private static SchClassTeaInfoClient Tea(FakeHandler handler) => new(new HttpClient(handler), Options);
private static SchClassSchoolingClient Schooling(FakeHandler handler) => new(new HttpClient(handler), Options);
[Fact]
public async Task 裸数组_解析为兼容行列表()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """
[{"Id":1,"Name":"高一(1)班","ClassID":1001,"SchSerID":"S001"},
{"Id":2,"Name":"高一(2)班","ClassID":1002,"SchSerID":"S001"}]
""");
var rows = await VClass(handler).GetVClassListAsync("S001");
Assert.Equal(2, rows.Count);
Assert.Equal("高一(1)班", rows[0]["Name"].GetString());
Assert.Equal(1001, rows[0]["ClassID"].GetInt32());
Assert.Equal("S001", rows[1]["SchSerID"].GetString());
}
[Fact]
public async Task 单对象_归一为单项列表()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """{"Id":9,"Name":"高三(7)班"}""");
var rows = await VClass(handler).GetVClassListAsync("S001");
Assert.Single(rows);
Assert.Equal(9, rows[0]["Id"].GetInt32());
}
[Fact]
public async Task 裸字符串数组_解析为List_string()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """["语文","数学"]""");
var subs = await new SchClassSubInfoClient(new HttpClient(handler), Options)
.GetSchTeachSubListAsync("S001");
Assert.Equal(new[] { "语文", "数学" }, subs);
}
[Fact]
public async Task HTTP404_列表归一为空列表()
{
var rows = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassListAsync("S001");
Assert.Empty(rows);
}
[Fact]
public async Task HTTP404_单对象归一为null()
{
var row = await VClass(FakeHandler.Empty(HttpStatusCode.NotFound)).GetVClassFirstAsync("1001");
Assert.Null(row);
}
[Fact]
public async Task HTTP204_归一为空列表()
{
var handler = FakeHandler.Json(HttpStatusCode.NoContent, "");
var list = await Tea(handler).GetArrTeaInfoAsync("1,2,3");
Assert.Empty(list);
}
[Fact]
public async Task HTTP400_抛异常并携带状态码与响应体()
{
var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "Required String parameter 'userIds' is not present");
var ex = await Assert.ThrowsAsync<SchClassOpenApiException>(() => Tea(handler).GetArrTeaInfoAsync(null!));
Assert.Equal(400, ex.StatusCode);
Assert.Contains("400", ex.Message);
Assert.Contains("userIds", ex.ResponseBody!);
}
[Fact]
public async Task HTTP500_抛异常()
{
var handler = FakeHandler.Text(HttpStatusCode.InternalServerError, "boom");
var ex = await Assert.ThrowsAsync<SchClassOpenApiException>(() => VClass(handler).GetVClassListAsync("S001"));
Assert.Equal(500, ex.StatusCode);
Assert.Contains("500", ex.Message);
}
[Fact]
public async Task 空响应体_返回空列表不抛()
{
var handler = new FakeHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("", Encoding.UTF8, "application/json"),
});
Assert.Empty(await VClass(handler).GetVClassListAsync("S001"));
Assert.Null(await VClass(handler).GetVClassFirstAsync("1"));
}
[Fact]
public async Task JSON字面量null_返回空列表()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, "null");
Assert.Empty(await VClass(handler).GetVClassListAsync("S001"));
}
[Fact]
public async Task 信封code0_提取data节点()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"code":0,"msg":"","data":[{"Id":1,"Name":"高一(1)班"}]}""");
var rows = await VClass(handler).GetVClassListAsync("S001");
Assert.Single(rows);
Assert.Equal("高一(1)班", rows[0]["Name"].GetString());
}
[Fact]
public async Task 信封code0且data为null_归一为空()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """{"code":0,"msg":"ok","data":null}""");
Assert.Empty(await VClass(handler).GetVClassListAsync("S001"));
}
[Fact]
public async Task 信封业务失败_抛异常并携带msg()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""{"code":3,"msg":"学校不存在","data":null}""");
var ex = await Assert.ThrowsAsync<SchClassOpenApiException>(() => VClass(handler).GetVClassListAsync("S001"));
Assert.Equal("学校不存在", ex.Message);
Assert.Equal(0, ex.StatusCode);
Assert.Contains("学校不存在", ex.ResponseBody!);
}
[Fact]
public async Task 业务行含code字段_不被误判为信封()
{
// 学制等级行自身带 code=gradeId 字段,只有 code 而没有 data/msg,必须原样返回
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""[{"Id":1,"Name":"一年级","OwnerId":2,"Levell":1,"code":11,"sx":1}]""");
var rows = await Schooling(handler).GetSchLenLevelListAsync("S001");
Assert.Single(rows);
Assert.Equal(11, rows[0]["code"].GetInt32());
Assert.Equal("一年级", rows[0]["Name"].GetString());
}
[Fact]
public async Task 非法JSON_抛解析异常()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, "<html>502 Bad Gateway</html>");
var ex = await Assert.ThrowsAsync<SchClassOpenApiException>(() => VClass(handler).GetVClassListAsync("S001"));
Assert.Contains("解析失败", ex.Message);
}
[Fact]
public async Task 兼容行键名_大小写与Java侧put顺序一致()
{
var handler = FakeHandler.Json(HttpStatusCode.OK,
"""[{"SchSerID":"S001","TeaTel":"13800000000","TeaName":"王老师","UserTeaSerID":77}]""");
var rows = await new SchClassDbClassClient(new HttpClient(handler), Options).GetSchteaListAsync(1);
Assert.Equal(new[] { "SchSerID", "TeaTel", "TeaName", "UserTeaSerID" }, rows[0].Keys);
Assert.Equal("王老师", rows[0]["TeaName"].GetString());
}
}
@@ -0,0 +1,59 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>SchInfo(学校)端点契约与 <see cref="SchInfoVo"/> 反序列化。</summary>
public class SchClassSchInfoClientTests
{
private const string Root = "/oapi/SchInfo/";
[Fact]
public async Task SchInfo_全部7个端点_路径与参数名对齐()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.SchInfo.GetSchListAsync("1,2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchlist", "?SchIDArr=1,2");
await api.SchInfo.AllListAsync();
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "AllList");
await api.SchInfo.GetSchFirstAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchFirst", "?SchID=S1");
await api.SchInfo.GetUserSchFirstAsync(9);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchFirst", "?UserSchID=9");
await api.SchInfo.GetUserSchListAsync("9,10");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserSchlist", "?UserSchIds=9,10");
await api.SchInfo.GetSchoolByGroupIdAsync(3);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolByGroupId", "?groupId=3");
await api.SchInfo.GetSchoolBySchoolenIdAsync(4);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchoolBySchoolenId", "?groupId=4");
}
[Fact]
public async Task SchInfoVO_按JsonProperty锚定的键名反序列化()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """
[{"SchSerId":"S001","UserSchSerId":9,"SchName":"第一中学"},
{"SchSerId":"S002","UserSchSerId":10,"SchName":"第二中学"}]
""");
var list = await SchClassTestHost.Api(handler).SchInfo.AllListAsync();
Assert.Equal(2, list.Count);
Assert.Equal("S001", list[0].SchSerId);
Assert.Equal(9, list[0].UserSchSerId);
Assert.Equal("第二中学", list[1].SchName);
}
[Fact]
public async Task GetSchFirst_未找到时返回null()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, "null");
Assert.Null(await SchClassTestHost.Api(handler).SchInfo.GetSchFirstAsync("nope"));
}
}
@@ -0,0 +1,109 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// OAStu(学生)端点的请求契约对拍。注意本控制器参数名<b>几乎全小写</b>
/// (<c>schserid</c> / <c>schgroupid</c> / <c>stuids</c> / <c>schserids</c>),
/// 与 VSchClass 的 <c>SchSerID</c> 形成对照,不可混用。
/// </summary>
public class SchClassStuInfoClientTests
{
private const string Root = "/oapi/OAStu/";
[Fact]
public async Task OAStu_全部19个端点_路径与参数名对齐()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.StuInfo.GetAllStuListAsync();
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetAllStulist");
await api.StuInfo.GetEleAllStuListAsync();
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleAllStulist");
await api.StuInfo.GetSchStuListAsync("S1", "10");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchStulist", "?schserid=S1&schgroupid=10");
await api.StuInfo.GetEleSchStuListAsync("S1", "10");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchStulist", "?schserid=S1&schgroupid=10");
await api.StuInfo.GetClaStuListAsync("1001", "20");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaStulist", "?ClassID=1001&clagroupid=20");
await api.StuInfo.GetEleClaStuListAsync("1001", "20");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaStulist", "?ClassID=1001&clagroupid=20");
await api.StuInfo.GetClaArrStuListAsync("1001,1002", "20,21");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21");
await api.StuInfo.GetEleClaArrStuListAsync("1001,1002", "20,21");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleClaArrStulist", "?ClassIDs=1001,1002&clagroupids=20,21");
await api.StuInfo.GetStuIdsListAsync("1,2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuIdslist", "?stuids=1,2");
await api.StuInfo.GetEleStuIdsListAsync("1,2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleStuIdslist", "?stuids=1,2");
await api.StuInfo.GetStuUserFamIdListAsync("1,2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuUserFamIDList", "?stuids=1,2");
await api.StuInfo.GetFamStuListAsync("IC1,IC2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFamStulist", "?invitecodes=IC1,IC2");
await api.StuInfo.GetStuFamMemAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStuFamMem", "?SchSerID=S1");
await api.StuInfo.GetSchBySchIdsAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchBySchIds", "?schserids=S1,S2");
await api.StuInfo.GetEleSchIdsStuListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchIdsStulist", "?schserids=S1,S2");
await api.StuInfo.GetEleSchBySchIdsAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchBySchIds", "?schserids=S1,S2");
await api.StuInfo.GetStudentsByElectiveIdAsync(5);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetStudentsByElectiveId", "?electiveIds=5");
await api.StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryStudentsBySchoolId", "?schoolId=1&year=2026");
await api.StuInfo.GetHistoryClassAsync(1, 2026);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getHistoryClass", "?schoolId=1&year=2026");
}
[Fact]
public async Task 学生行字段_按键名原样读取()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """
[{"Account":"2026001","UserStuSerID":11,"StuName":"小明","StuIDCard":"110101","StuSex":"男",
"StuStatus":1,"ClassID":1001,"ClassStuID":2001,"GroupID":30,"StuPhoto":null,"CardID":"C1",
"StuCurrentTel":"138","invitecode":"IC1","SchSerID":"S1","Graduated":0,"isjiedu":0}]
""");
var rows = await SchClassTestHost.Api(handler).StuInfo.GetAllStuListAsync();
var row = Assert.Single(rows);
Assert.Equal("小明", row["StuName"].GetString());
Assert.Equal(1001, row["ClassID"].GetInt32());
Assert.Equal("S1", row["SchSerID"].GetString());
Assert.Equal(JsonValueKind.Null, row["StuPhoto"].ValueKind);
}
[Fact]
public async Task 历史学生行_使用小写字段名()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """
[{"stuId":11,"stuName":"小明","classId":1001,"className":"高一(1)班",
"grade":1,"name":"第一中学","school_system_id":3,"system_grade":10}]
""");
var rows = await SchClassTestHost.Api(handler).StuInfo.GetHistoryStudentsBySchoolIdAsync(1, 2026);
var row = Assert.Single(rows);
Assert.Equal("小明", row["stuName"].GetString());
Assert.Equal("高一(1)班", row["className"].GetString());
Assert.Equal(3, row["school_system_id"].GetInt32());
}
}
@@ -0,0 +1,70 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>OATea(教师)端点契约与 <see cref="TeaInfoVo"/> 反序列化。</summary>
public class SchClassTeaInfoClientTests
{
private const string Root = "/oapi/OATea/";
[Fact]
public async Task OATea_全部4个端点_路径与参数名对齐()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.TeaInfo.GetTeaInfoAsync(77);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeainfo", "?uid=77");
await api.TeaInfo.GetArrTeaInfoAsync("77,78");
SchClassTestHost.VerifyBody(handler, Root + "GetArrTeainfo", "77,78");
await api.TeaInfo.GetSchTeaInfoAsync("S1", 2);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeainfo", "?SchSerID=S1&schId=2");
await api.TeaInfo.GetSchTeaListAsync("S1", 2);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchTeaList", "?SchSerID=S1&schId=2");
}
[Fact]
public async Task TeaInfoVO_字段按Java原名反序列化()
{
var handler = FakeHandler.Json(HttpStatusCode.OK, """
{"SchSerID":"S001","Teapolitical":"党员","TeaWorkStart":"2010-09-01","Account":"wang",
"SchSerIDs":"S001","role":3,"JiaoYuJu":"市教育局","TeaSex":"男","IsLeadingMiddle":0,
"TeaOriginPlace":"北京","Photo":null,"TeaIDCard":"110101","Name":"王老师","GroupID":30,
"SchName":"第一中学","TeaTeachStart":"2010-09-01","TeaSerID":"77","UserID":1001,
"TeaNation":"汉","Tel":"13800000000","TeaWorkType":"专任","TeaNo":7,"TeaBirth":"1985-01-01"}
""");
var tea = await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(77);
Assert.NotNull(tea);
Assert.Equal("王老师", tea!.Name);
Assert.Equal("S001", tea.SchSerId);
// 注意:查询参数 uid 传的是教师 teaId(77),而 VO.UserID 是用户中心 ID(1001),两者不是一回事
Assert.Equal("77", tea.TeaSerId);
Assert.Equal(1001, tea.UserId);
Assert.Equal(3, tea.Role);
Assert.Equal(30, tea.GroupId);
Assert.Equal("汉", tea.TeaNation);
Assert.Null(tea.Photo);
}
[Fact]
public async Task 教师不存在_服务端404时返回null()
{
var handler = FakeHandler.Empty(HttpStatusCode.NotFound);
Assert.Null(await SchClassTestHost.Api(handler).TeaInfo.GetTeaInfoAsync(999999));
}
[Fact]
public async Task GetSchTeainfo_缺参数时服务端400抛异常()
{
var handler = FakeHandler.Text(HttpStatusCode.BadRequest, "schId 与 SchSerID 不能同时为空");
var ex = await Assert.ThrowsAsync<SchClassOpenApiException>(
() => SchClassTestHost.Api(handler).TeaInfo.GetSchTeaInfoAsync());
Assert.Equal(400, ex.StatusCode);
}
}
@@ -0,0 +1,37 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>测试宿主:提供共享配置、门面实例与端点契约断言辅助。</summary>
internal static class SchClassTestHost
{
/// <summary>测试用配置(不设 BasePath,端点直接挂在根路径下)。</summary>
public static SchClassOpenApiOptions Options()
=> new() { BaseHost = "http://base.yunhaixingtong.com" };
/// <summary>用伪造 handler 装配门面。</summary>
public static SchClassOpenApi Api(FakeHandler handler)
=> new(new HttpClient(handler), Options());
/// <summary>回放一段无害响应(JSON 字面量 null),使任意端点都能安全走完解析流程。</summary>
public static FakeHandler Ok() => FakeHandler.Json(HttpStatusCode.OK, "null");
/// <summary>断言最近一次请求的 method / 路径 / query。</summary>
public static void Verify(FakeHandler handler, HttpMethod method, string path, string query = "")
{
var request = handler.LastRequest;
Assert.NotNull(request);
Assert.Equal(method, request!.Method);
Assert.Equal(path, request.RequestUri!.AbsolutePath);
Assert.Equal(query, Uri.UnescapeDataString(request.RequestUri.Query));
}
/// <summary>断言最近一次请求的方法、路径与请求体(用于 POST <c>@RequestBody String</c> 端点)。</summary>
public static void VerifyBody(FakeHandler handler, string path, string? body, string contentType = "text/plain")
{
var request = handler.LastRequest;
Assert.NotNull(request);
Assert.Equal(HttpMethod.Post, request!.Method);
Assert.Equal(path, request.RequestUri!.AbsolutePath);
Assert.Equal(body, handler.LastRequestBody);
Assert.Equal(contentType, handler.LastContentType);
}
}
@@ -0,0 +1,58 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>UserInfo(师生统一用户)端点契约与 <see cref="UserInfoVo"/> 反序列化。</summary>
public class SchClassUserInfoClientTests
{
private const string Root = "/oapi/UserInfo/";
[Fact]
public async Task UserInfo_全部2个端点_路径与参数名对齐()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.UserInfo.GetUserListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetUserList", "?SchSerID=S1");
await api.UserInfo.GetUserIdListAsync(1001);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "getUserIdList", "?userid=1001");
}
[Fact]
public async Task UserInfoVO_大小写不敏感匹配Jackson推导键名()
{
// Jackson 从 getter 推导的键名大小写与 Java 字段不完全一致(如 schSerID / idcard),
// Web 默认大小写不敏感,故两种形态都能命中。
var handler = FakeHandler.Json(HttpStatusCode.OK, """
[{"schSerID":"S001","no":1,"idcard":"110101","userID":1001,"sex":1,"usertype":1,
"serID":null,"photo":null,"tel":"13800000000","uids":"wang","groupID":30,"name":"王老师"}]
""");
var list = await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S001");
var user = Assert.Single(list);
Assert.Equal("S001", user.SchSerId);
Assert.Equal("110101", user.IdCard);
Assert.Equal(1001, user.UserId);
Assert.Equal(30, user.GroupId);
Assert.Equal("王老师", user.Name);
Assert.Null(user.SerId);
Assert.Null(user.Photo);
}
[Fact]
public async Task GetUserList_服务端204无内容时返回空列表()
{
var handler = FakeHandler.Empty(HttpStatusCode.NoContent);
Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserListAsync("S1"));
}
[Fact]
public async Task getUserIdList_未找到时服务端404返回空列表()
{
var handler = FakeHandler.Empty(HttpStatusCode.NotFound);
Assert.Empty(await SchClassTestHost.Api(handler).UserInfo.GetUserIdListAsync(-1));
}
}
@@ -0,0 +1,104 @@
namespace Yh.SchClass.OpenApi.Tests;
/// <summary>
/// VSchClass(班级)端点的请求契约对拍:路径、HTTP 方法、query 参数名必须与 Java
/// <c>VClassController</c> 逐字一致(参数名大小写敏感)。
/// </summary>
public class SchClassVClassClientTests
{
private const string Root = "/oapi/VSchClass/";
[Fact]
public async Task VSchClass_全部17个端点_路径与参数名对齐()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.VClass.GetVClassListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassList", "?SchSerID=S1");
await api.VClass.GetEleVClassListAsync("S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassList", "?SchSerID=S1");
await api.VClass.GetSchArrVClassListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassList", "?SchIDArr=S1,S2");
await api.VClass.GetEleSchArrVClassListAsync("S1,S2");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassList", "?SchIDArr=S1,S2");
await api.VClass.GetFunTimeSchClassAsync("2026-09-01", "S1");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetFunTimeSchClass", "?time=2026-09-01&SchSerID=S1");
await api.VClass.GetVClassFirstAsync("1001");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassFirst", "?ClassID=1001");
await api.VClass.GetEleVClassFirstAsync("1001");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassFirst", "?ClassID=1001");
await api.VClass.GetVClassIdsListAsync("1001,1002");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetVClassIdsList", "?ClassIds=1001,1002");
await api.VClass.GetEleVClassIdsListAsync("1001,1002");
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleVClassIdsList", "?ClassIds=1001,1002");
await api.VClass.GetGradeClassListAsync("S1", 3, 11);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetGradeClassList", "?SchSerID=S1&Schooling=3&GradeId=11");
await api.VClass.GetTeachClassArrAsync(77);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetTeachClassArr", "?UserTeaSerID=77");
await api.VClass.GetSchArrVClassListAllAsync();
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetSchArrVClassListAll");
await api.VClass.GetEleSchArrVClassListAllAsync();
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetEleSchArrVClassListAll");
// POST @RequestBody String:按 text/plain 原样提交,不加引号
await api.VClass.GetSchArrVClassListPostAsync("S1,S2");
SchClassTestHost.VerifyBody(handler, Root + "GetSchArrVClassListPost", "S1,S2");
await api.VClass.GetElectiveClassByIdAsync(5);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassById", "?electiveId=5");
await api.VClass.GetElectiveClassByGradeAsync(1, 2);
SchClassTestHost.Verify(handler, HttpMethod.Get, Root + "GetElectiveClassByGrade", "?schoolId=1&gradeId=2");
await api.VClass.GetElectiveClassByTeachersAsync("7,8");
SchClassTestHost.VerifyBody(handler, Root + "GetElectiveClassByTeachers", "7,8");
}
[Fact]
public async Task null参数_从query中丢弃()
{
var handler = SchClassTestHost.Ok();
var api = SchClassTestHost.Api(handler);
await api.VClass.GetGradeClassListAsync("S1", null, null);
Assert.Equal("?SchSerID=S1", Uri.UnescapeDataString(handler.LastRequest!.RequestUri!.Query));
}
[Fact]
public async Task 完整URL_由baseHost与oapi前缀拼成()
{
var handler = SchClassTestHost.Ok();
await SchClassTestHost.Api(handler).VClass.GetVClassListAsync("S1");
Assert.Equal("http://base.yunhaixingtong.com/oapi/VSchClass/GetVClassList?SchSerID=S1",
handler.LastRequest!.RequestUri!.ToString());
}
[Fact]
public async Task 配置了BasePath时_路径前缀正确拼接()
{
var handler = SchClassTestHost.Ok();
var api = new SchClassOpenApi(new HttpClient(handler),
new SchClassOpenApiOptions { BaseHost = "http://gw", BasePath = "/base.yunhaixingtong.com" });
await api.VClass.GetSchArrVClassListAllAsync();
Assert.Equal("/base.yunhaixingtong.com/oapi/VSchClass/GetSchArrVClassListAll",
handler.LastRequest!.RequestUri!.AbsolutePath);
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<!-- 调试入口:让测试工程可直接 dotnet run / F5,进入 DebugMain.Main 联调;
GenerateProgramFile=false 关闭 Test SDK 自动生成的空 Main,避免 CS0017。
dotnet test / vstest 不受影响,照常发现全部用例。 -->
<OutputType>Exe</OutputType>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.SchClass.OpenApi\Yh.SchClass.OpenApi.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"yh": {
"open": {
"Enabled": true,
"BaseHost": "http://base.yunhaixingtong.com",
"BasePath": "/userbase",
"SchClassPath": "/bschclass",
"AppKey": "100002",
"AppSecret": "2314a3b158b24a838b2b9b69fd3db118"
}
}
}