This commit is contained in:
wanghu
2026-09-02 09:38:45 +08:00
commit 8774b6cc44
128 changed files with 9382 additions and 0 deletions
@@ -0,0 +1,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"
}
}
}