bag
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user