集成测试体系建设&依赖升级 - 统一升级XCode、Redis、Stardust、Cube等依赖至2026.403/404,提升兼容性。 - 新增XUnitTest.Samples端到端集成测试项目,覆盖ZeroServer/IoTZero全链路流程,支持Kestrel真实启动与多项目隔离。 - 新增ZeroRpcServer多协议集成测试,完善TCP/UDP/WS/HTTP全流程断言。 - 优化DefaultDeviceService令牌校验、Program.cs测试环境隔离等细节,提升测试健壮性。 - 更新解决方案文件,纳入新测试项目,便于统一管理和持续集成。 无破坏性API变更,主要为测试和文档增强,生产环境无性能影响。大石头 authored at 2026-04-27 15:33:16
diff --git a/.github/instructions/xcode.instructions.md b/.github/instructions/xcode.instructions.md
index 2a79c8e..b1e1788 100644
--- a/.github/instructions/xcode.instructions.md
+++ b/.github/instructions/xcode.instructions.md
@@ -344,6 +344,44 @@ var count = User.FindCount(User._.Status == 1);
var maxId = User.FindMax(User._.Id, null);
```
+### 5.2.1 字段表达式方法参考
+
+`FieldItem`(`Entity._.FieldName`)提供丰富的表达式方法,可直接用于 `WhereExpression` 或 `FindAll` 条件:
+
+| 方法 | 说明 | 生成 SQL 示例 |
+|------|------|------|
+| `_.Name == value` | 等于 | `Name = 'test'` |
+| `_.Name != value` | 不等于 | `Name <> 'test'` |
+| `_.Id > value` | 大于 | `Id > 10` |
+| `_.Id >= value` | 大于等于 | `Id >= 10` |
+| `_.Id < value` | 小于 | `Id < 100` |
+| `_.Id <= value` | 小于等于 | `Id <= 100` |
+| `_.Name.Contains("x")` | 包含 | `Name Like '%x%'` |
+| `_.Name.NotContains("x")` | 不包含 | `Name Not Like '%x%'` |
+| `_.Name.StartsWith("x")` | 开头 | `Name Like 'x%'` |
+| `_.Name.EndsWith("x")` | 结尾 | `Name Like '%x'` |
+| `_.Id.In(list)` | In 操作 | `Id In(1,2,3)` |
+| `_.Id.NotIn(list)` | Not In | `Id Not In(1,2,3)` |
+| `_.Name.IsNull()` | 是否 NULL | `Name Is Null` |
+| `_.Name.NotIsNull()` | 不为 NULL | `Name Is Not Null` |
+| `_.Name.IsNullOrEmpty()` | NULL 或空串(仅 String) | `(Name Is Null Or Name='')` |
+| **`_.Name.NotIsNullOrEmpty()`** | **不为 NULL 且不为空串**(仅 String) | `(Name Is Not Null And Name<>'')` |
+| `_.Flag.IsTrue(true)` | 布尔真值 | `Flag=True` |
+| `_.Flag.IsTrue(false)` | 布尔假值/NULL | `Flag<>True Or Flag Is Null` |
+| `_.Id.Asc()` | 升序排序 | `Id Asc` |
+| `_.Id.Desc()` | 降序排序 | `Id Desc` |
+
+**空值判断常见写法**:
+```csharp
+// ✅ 推荐:使用内置方法
+exp &= _.DevName.NotIsNullOrEmpty(); // 字段不为空且不为空串
+
+// ❌ 不推荐:手写两个条件
+exp &= _.DevName != "" & _.DevName != null;
+```
+
+**注意**:`IsNullOrEmpty()` / `NotIsNullOrEmpty()` 仅支持 `String` 类型字段,非字符串字段请使用 `IsNull()` / `NotIsNull()`。
+
### 5.3 批量操作
```csharp
@@ -381,6 +419,82 @@ var list = User.FindAllWithCache();
var user = User.FindByKeyWithCache(1);
```
+### 5.6 Biz 文件数据层逻辑规范
+
+**核心理念**:所有需要人工编写的数据层逻辑代码,一律放在实体类的 Biz 文件(`*.Biz.cs`)中,包括**高级查询**(`#region 高级查询`)与**添删改查重载**等。外部调用方只传语义化参数,不感知 `WhereExpression` 拼接细节。
+
+#### 高级查询封装选择
+
+| 场景 | 方法形式 | 说明 | 示例 |
+|------|---------|------|------|
+| 返回单个对象,参数 ≤2 个 | `FindByXxx` | 未查到时返回 `null` | `FindByUserId(userId)` |
+| 返回列表,参数 ≤2 个,无模糊查询、无分页 | `FindAllByXxx` / `FindAllByXxxAndYyy` | 未查到时返回空列表,**不返回 null** | `FindAllByUserId(userId)` |
+| 参数较多,或含模糊查询,或含分页 | `Search(...)` | 未查到时返回空列表,**不返回 null** | `Search(userId, key, page)` |
+| 实体缓存内过滤(`Meta.Cache.FindAll(...)`) | `FindAllCachedXxx` / `FindCachedXxx` | — | `FindAllCachedEnabled()` / `FindCachedByQuestion(q)` |
+
+**命名约定说明**:
+- `FindByXxx`:返回**单个对象**(`TEntity?`),语义为"按条件查找一条记录",未找到返回 `null`
+- `FindAllByXxx`:返回**对象列表**(`IList<TEntity>`),语义为"按条件查找所有匹配记录",结果为空时返回空列表而非 `null`
+- `Search`:同样返回**对象列表**,结果为空时返回空列表而非 `null`
+
+#### Search 方法签名约定
+
+参数顺序(由左到右,按重要程度):
+
+```
+Search(业务过滤字段..., DateTime start, DateTime end, String? key, PageParameter page)
+```
+
+- 时间区间 `(DateTime start, DateTime end)` 放在 key / page 左边
+- 模糊查询关键词 `String? key` 放在 page 左边(倒数第二)
+- 分页参数 `PageParameter page` 始终最后
+
+#### 表达式简写
+
+在 Biz 文件的静态方法内部,可**省略类名前缀**:
+
+```csharp
+// ✅ 推荐(Biz 文件内部)
+var exp = _.UserId == userId;
+if (!keyword.IsNullOrEmpty()) exp &= _.Title.Contains(keyword.Trim());
+return FindAll(exp, page);
+
+// ❌ 避免(外部业务代码中拼接表达式)
+var exp = Conversation._.UserId == userId;
+if (!keyword.IsNullOrEmpty()) exp &= Conversation._.Title.Contains(keyword.Trim());
+var list = Conversation.FindAll(exp, p);
+```
+
+#### 示例
+
+```csharp
+// Biz 文件内 #region 高级查询
+
+/// <summary>根据用户编号查找最新一条会话</summary>
+/// <param name="userId">用户编号</param>
+/// <returns>会话对象,不存在时返回 null</returns>
+public static Conversation? FindByUserId(Int32 userId) => Find(_.UserId == userId);
+
+/// <summary>根据用户编号查找所有会话</summary>
+/// <param name="userId">用户编号</param>
+/// <returns>会话列表,不存在时返回空列表</returns>
+public static IList<Conversation> FindAllByUserId(Int32 userId) => FindAll(_.UserId == userId);
+
+/// <summary>分页搜索用户会话列表</summary>
+/// <param name="userId">用户编号</param>
+/// <param name="keyword">标题关键字,为空时不过滤</param>
+/// <param name="page">分页参数</param>
+/// <returns>会话列表,不存在时返回空列表</returns>
+public static IList<Conversation> Search(Int32 userId, String? keyword, PageParameter page)
+{
+ var exp = new WhereExpression();
+ exp &= _.UserId == userId;
+ if (!keyword.IsNullOrEmpty()) exp &= _.Title.Contains(keyword.Trim());
+
+ return FindAll(exp, page);
+}
+```
+
---
## 6. 运行时机制
diff --git a/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj b/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
index a27cc5b..9dd591f 100644
--- a/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
+++ b/NewLife.Remoting.Extensions/NewLife.Remoting.Extensions.csproj
@@ -64,7 +64,7 @@
</None>
</ItemGroup>
<ItemGroup>
- <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+ <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NewLife.Remoting\NewLife.Remoting.csproj" />
diff --git a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
index 9061fab..158885a 100644
--- a/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
+++ b/NewLife.Remoting.Extensions/Services/DefaultDeviceService.cs
@@ -436,11 +436,14 @@ public abstract class DefaultDeviceService<TDevice, TOnline>(ISessionManager ses
var target = GetDevice(model.Code!);
if (target == null) throw new ArgumentNullException(nameof(model.Code), "未找到指定设备 " + model.Code);
- // 验证令牌
+ // 验证令牌。[AllowAnonymous] 接口(如 SendCommand)使用应用令牌而非设备令牌:
+ // 若未注册 ITokenService(测试环境),则跳过;注册了则必须携带合法令牌。
var tokenService = serviceProvider.GetService<ITokenService>();
if (tokenService != null)
{
- var (jwt, ex) = tokenService.DecodeToken(context.Token!);
+ if (context.Token.IsNullOrEmpty())
+ throw new ApiException(ApiCode.Unauthorized, "SendCommand 需要应用令牌");
+ var (jwt, ex) = tokenService.DecodeToken(context.Token);
if (ex != null) throw ex;
}
diff --git a/NewLife.Remoting.sln b/NewLife.Remoting.sln
index 4a24dbf..9deffc2 100644
--- a/NewLife.Remoting.sln
+++ b/NewLife.Remoting.sln
@@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Benchmark", "Benchmark", "{
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NewLife.Remoting.Benchmarks", "Benchmark\NewLife.Remoting.Benchmarks.csproj", "{F57E02C1-1ED8-4029-BB8A-14CDCE233551}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XUnitTest.Samples", "XUnitTest.Samples\XUnitTest.Samples.csproj", "{8058AB29-267E-4678-8615-61F44F733DAE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -167,6 +169,18 @@ Global
{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x64.Build.0 = Release|Any CPU
{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x86.ActiveCfg = Release|Any CPU
{F57E02C1-1ED8-4029-BB8A-14CDCE233551}.Release|x86.Build.0 = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x64.Build.0 = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Debug|x86.Build.0 = Debug|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|x64.ActiveCfg = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|x64.Build.0 = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|x86.ActiveCfg = Release|Any CPU
+ {8058AB29-267E-4678-8615-61F44F733DAE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Samples/IoTZero/IoTZero.csproj b/Samples/IoTZero/IoTZero.csproj
index 450f1fe..3b3df38 100644
--- a/Samples/IoTZero/IoTZero.csproj
+++ b/Samples/IoTZero/IoTZero.csproj
@@ -23,12 +23,12 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="NewLife.Cube.Core" Version="6.9.2026.318-beta1121" />
+ <PackageReference Include="NewLife.Cube.Core" Version="6.10.2026.404" />
<PackageReference Include="NewLife.IoT" Version="2.7.2026.201" />
- <PackageReference Include="NewLife.MQTT" Version="3.0.2026.305" />
- <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
- <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.307" />
- <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+ <PackageReference Include="NewLife.MQTT" Version="3.0.2026.403" />
+ <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+ <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.403" />
+ <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
</ItemGroup>
<ItemGroup>
diff --git a/Samples/IoTZero/Program.cs b/Samples/IoTZero/Program.cs
index 9cc164c..a8ffe3d 100644
--- a/Samples/IoTZero/Program.cs
+++ b/Samples/IoTZero/Program.cs
@@ -65,18 +65,26 @@ app.MapControllerRoute(
name: "default",
pattern: "{controller=CubeHome}/{action=Index}/{id?}");
-app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
+if (!app.Environment.IsEnvironment("Testing"))
+ app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
// 反射查找并调用客户端测试,该代码仅用于测试,实际项目中不要这样做
-var clientType = "IoTZero.Clients.ClientTest".GetTypeEx();
-var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
-if (test != null) _ = Task.Run(() => test(app.Services));
+if (!app.Environment.IsEnvironment("Testing"))
+{
+ var clientType = "IoTZero.Clients.ClientTest".GetTypeEx();
+ var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
+ if (test != null) _ = Task.Run(() => test(app.Services));
+}
app.Run();
void InitConfig()
{
// 把数据目录指向上层,例如部署到 /root/iot/edge/,这些目录放在 /root/iot/
+ // Testing 环境下路径已由 Fixture 配置,跳过默认路径重写
+ // 注意:InitConfig 在 builder.Build() 之前调用,必须用 builder.Environment 而非 app.Environment
+ if (builder.Environment.IsEnvironment("Testing")) return;
+
var set = NewLife.Setting.Current;
if (set.IsNew)
{
@@ -100,4 +108,7 @@ void InitConfig()
set3.SingleCacheExpire = 60;
set3.Save();
}
-}
\ No newline at end of file
+}
+
+// 供 WebApplicationFactory 反射访问
+public partial class Program { }
\ No newline at end of file
diff --git a/Samples/Zero.Desktop/Zero.Desktop.csproj b/Samples/Zero.Desktop/Zero.Desktop.csproj
index b7b15fd..bb0acea 100644
--- a/Samples/Zero.Desktop/Zero.Desktop.csproj
+++ b/Samples/Zero.Desktop/Zero.Desktop.csproj
@@ -26,8 +26,8 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="NewLife.Stardust" Version="3.7.2026.307" />
- <PackageReference Include="System.Speech" Version="10.0.5" />
+ <PackageReference Include="NewLife.Stardust" Version="3.7.2026.403" />
+ <PackageReference Include="System.Speech" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
diff --git a/Samples/Zero.RpcServer/ClientTest.cs b/Samples/Zero.RpcServer/ClientTest.cs
index 3662ceb..a388e29 100644
--- a/Samples/Zero.RpcServer/ClientTest.cs
+++ b/Samples/Zero.RpcServer/ClientTest.cs
@@ -106,7 +106,7 @@ static class ClientTest
var client = new ApiHttpClient($"http://127.0.0.2:{port}");
client.Log = XTrace.Log;
- var apis = await client.GetAsync<String[]>("api/all");
+ var apis = await client.GetAsync<String[]>("api/all");
client.WriteLog("共有接口数:{0}", apis.Length);
var state = Rand.NextString(8);
diff --git a/Samples/Zero.RpcServer/Program.cs b/Samples/Zero.RpcServer/Program.cs
index eaed88b..6f7826a 100644
--- a/Samples/Zero.RpcServer/Program.cs
+++ b/Samples/Zero.RpcServer/Program.cs
@@ -24,7 +24,7 @@ services.AddSingleton<ICacheProvider, RedisCacheProvider>();
EntityFactory.InitAll();
-var port = 8080;
+var port = 8780;
// 实例化RPC服务端,指定端口,同时在Tcp/Udp/IPv4/IPv6上监听
using var server = new ApiServer(port)
diff --git a/Samples/Zero.RpcServer/Zero.RpcServer.csproj b/Samples/Zero.RpcServer/Zero.RpcServer.csproj
index d9e4d55..ec026cc 100644
--- a/Samples/Zero.RpcServer/Zero.RpcServer.csproj
+++ b/Samples/Zero.RpcServer/Zero.RpcServer.csproj
@@ -20,9 +20,9 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
- <PackageReference Include="NewLife.Stardust" Version="3.7.2026.307" />
- <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+ <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+ <PackageReference Include="NewLife.Stardust" Version="3.7.2026.403" />
+ <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
</ItemGroup>
<ItemGroup>
diff --git a/Samples/ZeroServer/Program.cs b/Samples/ZeroServer/Program.cs
index dab5fe9..f1da039 100644
--- a/Samples/ZeroServer/Program.cs
+++ b/Samples/ZeroServer/Program.cs
@@ -57,18 +57,26 @@ app.MapControllerRoute(
name: "default",
pattern: "{controller=CubeHome}/{action=Index}/{id?}");
-app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
+if (!app.Environment.IsEnvironment("Testing"))
+ app.RegisterService(star.AppId, null, app.Environment.EnvironmentName);
// 反射查找并调用客户端测试,该代码仅用于测试,实际项目中不要这样做
-var clientType = "ZeroClient.ClientTest".GetTypeEx();
-var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
-if (test != null) _ = Task.Run(() => test(app.Services));
+if (!app.Environment.IsEnvironment("Testing"))
+{
+ var clientType = "ZeroClient.ClientTest".GetTypeEx();
+ var test = clientType?.GetMethodEx("Process").As<Func<IServiceProvider, Task>>();
+ if (test != null) _ = Task.Run(() => test(app.Services));
+}
app.Run();
void InitConfig()
{
// 把数据目录指向上层,例如部署到 /root/iot/edge/,这些目录放在 /root/iot/
+ // Testing 环境下路径已由 Fixture 配置,跳过默认路径重写
+ // 注意:InitConfig 在 builder.Build() 之前调用,必须用 builder.Environment 而非 app.Environment
+ if (builder.Environment.IsEnvironment("Testing")) return;
+
var set = NewLife.Setting.Current;
if (set.IsNew)
{
@@ -92,4 +100,7 @@ void InitConfig()
set3.SingleCacheExpire = 60;
set3.Save();
}
-}
\ No newline at end of file
+}
+
+// 供 WebApplicationFactory 反射访问
+public partial class Program { }
\ No newline at end of file
diff --git a/Samples/ZeroServer/ZeroServer.csproj b/Samples/ZeroServer/ZeroServer.csproj
index e6caa5c..bf44450 100644
--- a/Samples/ZeroServer/ZeroServer.csproj
+++ b/Samples/ZeroServer/ZeroServer.csproj
@@ -19,10 +19,10 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="NewLife.Cube.Core" Version="6.9.2026.318-beta1121" />
- <PackageReference Include="NewLife.Redis" Version="6.5.2026.303" />
- <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.307" />
- <PackageReference Include="NewLife.XCode" Version="11.25.2026.304-beta0242" />
+ <PackageReference Include="NewLife.Cube.Core" Version="6.10.2026.404" />
+ <PackageReference Include="NewLife.Redis" Version="6.5.2026.403" />
+ <PackageReference Include="NewLife.Stardust.Extensions" Version="3.7.2026.403" />
+ <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
</ItemGroup>
<ItemGroup>
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
new file mode 100644
index 0000000..5a688e2
--- /dev/null
+++ b/XUnitTest.Samples/IoTZero/IoTZeroIntegrationTests.cs
@@ -0,0 +1,368 @@
+extern alias IoTZero;
+
+using System.Text.Json;
+using IoTZero::IoT.Data;
+using IoTZero::IoTEdge;
+using NewLife;
+using NewLife.Log;
+using NewLife.Remoting.Models;
+using NewLife.Security;
+using XCode;
+using Xunit;
+
+namespace XUnitTest.Samples.IoTZero;
+
+/// <summary>
+/// IoTZero HTTP 全链路集成测试。
+/// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),HttpDevice 通过 HTTP 连接。
+/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
+/// 心跳计数、WebSocket 通知建立、SendCommand 投递、升级检查、事件上报。
+/// </summary>
+[Collection("SamplesIntegration")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class IoTZeroIntegrationTests : IClassFixture<IoTZeroWebFactory>
+{
+ private readonly IoTZeroWebFactory _factory;
+
+ public IoTZeroIntegrationTests(IoTZeroWebFactory factory) => _factory = factory;
+
+ #region 9 步串行集成测试
+ [Fact(DisplayName = "IoTZero_9步全链路集成测试")]
+ public async Task FullIntegrationFlow_AllNineSteps()
+ {
+ // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+ Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
+ XTrace.WriteLine("IoTZero 测试服务地址:{0}", _factory.BaseUrl);
+
+ // 清空上次测试残留数据:XCode DAL 连接 IoT 持久化在 AppBase/Data 目录,
+ // 跨测试运行累积;若不清理,Step3 的 Assert.Empty(DeviceOnline) 会因旧记录而误判失败
+ DeviceOnline.Delete("1=1");
+ DeviceHistory.Delete("1=1");
+
+ // 创建新的 ClientSetting(空 DeviceCode/DeviceSecret,触发服务端自动注册)
+ var setting = new ClientSetting { Server = _factory.BaseUrl };
+
+ using var client = new HttpDevice(setting);
+ client.Log = XTrace.Log;
+
+ // Step 1 - 自动注册登录
+ var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
+
+ // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
+ var deviceId = await Step2_VerifyServerEntities(code);
+
+ // Step 3 - 注销
+ await Step3_Logout(client, deviceId);
+
+ // Step 4 - 修改密钥后重登
+ await Step4_ReLoginWithNewSecret(client, setting, code, deviceId);
+
+ // Step 5 - 心跳
+ await Step5_Ping(client, deviceId);
+
+ // Step 6 - WebSocket 建立确认
+ await Step6_WaitWebSocket(deviceId);
+
+ // Step 7 - SendCommand 投递(Timeout=0,服务端立即返回,客户端通过 WebSocket 收到命令)
+ await Step7_SendCommandAndReceive(client, code);
+
+ // Step 8 - 升级检查
+ await Step8_Upgrade(client);
+
+ // Step 9 - 事件上报
+ await Step9_PostEvents(client);
+ }
+ #endregion
+
+ #region Step 1 — 自动注册登录
+ private async Task<(String code, String secret)> Step1_AutoRegisterLogin(HttpDevice client, ClientSetting setting)
+ {
+ XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+
+ // 确保以空 DeviceCode/DeviceSecret 开始,触发服务端自动注册
+ setting.DeviceCode = null!;
+ setting.DeviceSecret = null!;
+
+ await client.Login(null, CancellationToken.None).ConfigureAwait(false);
+
+ // 客户端状态验证
+ Assert.True(client.Logined, "Login 后 Logined 应为 true");
+
+ // 服务端应已写回 DeviceCode 和 DeviceSecret(通过 IClientSetting.Code/Secret 接口)
+ Assert.False(setting.DeviceCode.IsNullOrEmpty(), "服务端应已回填 DeviceCode");
+ Assert.False(setting.DeviceSecret.IsNullOrEmpty(), "服务端应已回填 DeviceSecret");
+
+ XTrace.WriteLine("自动注册成功,DeviceCode={0}", setting.DeviceCode);
+
+ // 等待 ClientSetting.Save() 写文件(异步 IO,稍作等待)
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 配置文件持久化验证(ClientSetting 的 [Config("IoTClient")] 写到 config/IoTClient.config)
+ var configContent = _factory.ReadClientConfigFile();
+ Assert.False(configContent.IsNullOrEmpty(), "config/IoTClient.config 应已写入磁盘");
+ Assert.Contains(setting.DeviceCode, configContent!, StringComparison.Ordinal);
+ Assert.Contains(setting.DeviceSecret, configContent!, StringComparison.Ordinal);
+
+ XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
+
+ return (setting.DeviceCode, setting.DeviceSecret);
+ }
+ #endregion
+
+ #region Step 2 — 服务端实体验证
+ private async Task<Int32> Step2_VerifyServerEntities(String code)
+ {
+ XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
+
+ // 等待服务端异步写库完成(Device/DeviceOnline 由 Save 同步写入,无需长等待)
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 验证 Device 实体已创建(FindByCode 单参数)
+ var device = Device.FindByCode(code);
+ Assert.NotNull(device);
+ Assert.Equal(code, device.Code);
+ Assert.True(device.Enable, "新注册的 Device 应为启用状态");
+ Assert.False(device.Secret.IsNullOrEmpty(), "Device.Secret 不应为空");
+
+ XTrace.WriteLine("Device 已创建,ID={0}, Enable={1}", device.Id, device.Enable);
+
+ // 验证 DeviceOnline 已创建(DeviceOnline 无 FindAllByDeviceId,使用 FindAll 表达式)
+ var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == device.Id, null, null, 0, 0);
+ XTrace.WriteLine("DeviceOnline 数量={0}, DeviceId={1}", onlines.Count, device.Id);
+ if (onlines.Count == 0)
+ {
+ // 诊断:打印所有 DeviceOnline 记录
+ var allOnlines = DeviceOnline.FindAll(null, null, null, 0, 0);
+ XTrace.WriteLine("所有 DeviceOnline({0}):", allOnlines.Count);
+ foreach (var o in allOnlines)
+ XTrace.WriteLine(" Id={0}, DeviceId={1}, SessionId={2}", o.Id, o.DeviceId, o.SessionId);
+ }
+ Assert.NotEmpty(onlines);
+
+ XTrace.WriteLine("DeviceOnline 数量={0}", onlines.Count);
+
+ // 验证 DeviceHistory 已记录登录(DefaultDeviceService.OnLogin 写 source+"登录",source=Http)
+ // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+ var logins = await WaitForDeviceHistory(device.Id, "Http登录").ConfigureAwait(false);
+ Assert.NotEmpty(logins);
+ Assert.True(logins[0].Success, "第一次登录的 DeviceHistory 应为成功");
+
+ XTrace.WriteLine("DeviceHistory 登录记录={0}", logins.Count);
+
+ return device.Id;
+ }
+ #endregion
+
+ #region Step 3 — 注销
+ private async Task Step3_Logout(HttpDevice client, Int32 deviceId)
+ {
+ XTrace.WriteLine("=== Step 3: 注销 ===");
+
+ await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
+
+ Assert.False(client.Logined, "Logout 后 Logined 应为 false");
+
+ // 等待服务端写库
+ await Task.Delay(500).ConfigureAwait(false);
+
+ // DeviceOnline 应已清除(直接查 DB,绕过实体缓存)
+ var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == deviceId, null, null, 0, 0);
+ if (onlines.Count > 0)
+ {
+ foreach (var o in onlines)
+ XTrace.WriteLine("残留 DeviceOnline: Id={0}, DeviceId={1}, SessionId={2}, UpdateTime={3}", o.Id, o.DeviceId, o.SessionId, o.UpdateTime);
+ }
+ Assert.True(onlines.Count == 0, $"DeviceOnline 应已清除,但有 {onlines.Count} 条记录,SessionIds=[{String.Join(",", onlines.Select(o => o.SessionId))}]");
+
+ // DeviceHistory 应有下线记录(DefaultDeviceService.Logout 写 source+"设备下线",source=Http)
+ // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+ var logouts = await WaitForDeviceHistory(deviceId, "Http设备下线").ConfigureAwait(false);
+ Assert.NotEmpty(logouts);
+
+ XTrace.WriteLine("注销成功,DeviceHistory 下线记录={0}", logouts.Count);
+ }
+ #endregion
+
+ #region Step 4 — 修改密钥后重登
+ private async Task Step4_ReLoginWithNewSecret(HttpDevice client, ClientSetting setting, String code, Int32 deviceId)
+ {
+ XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
+
+ // 在服务端直接修改 Device.Secret(模拟运维修改密钥场景)
+ var device = Device.FindByCode(code)!;
+ var newSecret = Rand.NextString(16);
+ device.Secret = newSecret;
+ device.Update();
+
+ // 客户端同步使用新密钥(通过 IClientSetting.Secret 接口设置 DeviceSecret)
+ setting.DeviceSecret = newSecret;
+
+ // 重新登录
+ await client.Login("重登测试", CancellationToken.None).ConfigureAwait(false);
+
+ Assert.True(client.Logined, "修改密钥后重登,Logined 应为 true");
+
+ // 等待写库
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // DeviceOnline 应重新出现
+ var onlines = DeviceOnline.FindAll(DeviceOnline._.DeviceId == deviceId);
+ Assert.NotEmpty(onlines);
+
+ // 登录历史应有 2 条记录(第一次自动注册 + 本次)
+ // WriteHistory 调用 SaveAsync,XCode EntityQueue ~1秒刷新,需轮询等待
+ var logins = await WaitForDeviceHistory(deviceId, "Http登录", 2).ConfigureAwait(false);
+ Assert.True(logins.Count >= 2, $"应有至少 2 条登录历史,实际={logins.Count}");
+
+ XTrace.WriteLine("重登成功,DeviceHistory 登录记录共={0}", logins.Count);
+ }
+ #endregion
+
+ #region Step 5 — 心跳
+ private async Task Step5_Ping(HttpDevice client, Int32 deviceId)
+ {
+ XTrace.WriteLine("=== Step 5: 心跳 ===");
+
+ // 记录心跳前的 Pings(DeviceOnline 的 AdditionalFields 有 Pings 累加字段)
+ var onlineBefore = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+ var pingsBefore = onlineBefore?.Pings ?? 0;
+
+ var rs = await client.Ping(CancellationToken.None).ConfigureAwait(false);
+ Assert.NotNull(rs);
+
+ // 等待服务端写库
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 刷新实体缓存后验证 Pings 增加
+ var onlineAfter = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+ Assert.NotNull(onlineAfter);
+ Assert.True(onlineAfter.Pings > pingsBefore,
+ $"Ping 后 Pings 应增加,before={pingsBefore}, after={onlineAfter.Pings}");
+
+ XTrace.WriteLine("心跳成功,Pings={0}", onlineAfter.Pings);
+ }
+ #endregion
+
+ #region Step 6 — WebSocket 建立确认
+ private async Task Step6_WaitWebSocket(Int32 deviceId)
+ {
+ XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
+
+ // HttpDevice 登录后会自动建立 WebSocket 通知连接(Features 包含 Notify)
+ // 轮询最长 5 秒等待 DeviceOnline.WebSocket = true
+ var deadline = DateTime.Now.AddSeconds(5);
+ DeviceOnline? online = null;
+ while (DateTime.Now < deadline)
+ {
+ online = DeviceOnline.Find(DeviceOnline._.DeviceId == deviceId);
+ if (online?.WebSocket == true) break;
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+
+ Assert.NotNull(online);
+ Assert.True(online.WebSocket, "DeviceOnline.WebSocket 应在 5 秒内变为 true");
+
+ XTrace.WriteLine("WebSocket 已建立,SessionId={0}", online.SessionId);
+ }
+ #endregion
+
+ #region Step 7 — SendCommand 投递
+ private async Task Step7_SendCommandAndReceive(HttpDevice client, String code)
+ {
+ XTrace.WriteLine("=== Step 7: SendCommand 投递 ===");
+
+ var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ client.Received += (_, e) =>
+ {
+ // 只接受我们发出的命令;不设置 e.Reply,避免触发 Thing/ServiceReply(NotImplementedException)
+ if (e.Model?.Command == "test:echo")
+ tcs.TrySetResult(e);
+ };
+
+ // 通过 HttpClient 调用 [AllowAnonymous] 的 Device/SendCommand 接口
+ // SendCommand 在 Service 层使用应用令牌验证,此处用已登录的设备令牌代替(测试环境 TokenService 不区分令牌来源)
+ using var http = new HttpClient();
+ var deviceToken = ((NewLife.Remoting.IApiClient)client).Token;
+ if (!deviceToken.IsNullOrEmpty())
+ http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {deviceToken}");
+ var payload = JsonSerializer.Serialize(new
+ {
+ Code = code,
+ Command = "test:echo",
+ Argument = "hello",
+ Timeout = 0,
+ });
+ var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await http.PostAsync($"{_factory.BaseUrl}/Device/SendCommand", content).ConfigureAwait(false);
+ XTrace.WriteLine("SendCommand HTTP 状态={0}", response.StatusCode);
+
+ // 等待客户端通过 WebSocket 收到命令(最多 10 秒)
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var cmdEvt = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
+
+ Assert.NotNull(cmdEvt.Model);
+ Assert.Equal("test:echo", cmdEvt.Model!.Command);
+ Assert.Equal("hello", cmdEvt.Model.Argument);
+
+ XTrace.WriteLine("已收到命令,Command={0}, Argument={1}", cmdEvt.Model.Command, cmdEvt.Model.Argument);
+ }
+ #endregion
+
+ #region Step 8 — 升级检查
+ private async Task Step8_Upgrade(HttpDevice client)
+ {
+ XTrace.WriteLine("=== Step 8: 升级检查 ===");
+
+ // 无新版本时应返回 null,不应抛出异常
+ IUpgradeInfo? info = null;
+ var ex = await Record.ExceptionAsync(async () =>
+ {
+ info = await client.Upgrade(null, CancellationToken.None).ConfigureAwait(false);
+ });
+
+ Assert.Null(ex);
+ // info 为 null 表示无可用升级,合法
+ XTrace.WriteLine("升级检查成功,info={0}", info == null ? "null(无升级)" : info.Version);
+ }
+ #endregion
+
+ #region Step 9 — 事件上报
+ private async Task Step9_PostEvents(HttpDevice client)
+ {
+ XTrace.WriteLine("=== Step 9: 事件上报 ===");
+
+ var events = new[]
+ {
+ new EventModel { Name = "TestEvent1", Type = "info", Remark = "IoTZero 集成测试事件1" },
+ new EventModel { Name = "TestEvent2", Type = "alert", Remark = "IoTZero 集成测试事件2" },
+ };
+
+ var count = await client.PostEvents(events).ConfigureAwait(false);
+
+ Assert.Equal(2, count);
+ XTrace.WriteLine("事件上报成功,返回数量={0}", count);
+ }
+ #endregion
+
+ #region 辅助方法
+ /// <summary>轮询等待 SaveAsync 写库并按条件查询 DeviceHistory(直接查 DB,绕过实体缓存)。
+ /// XCode EntityQueue 约每 1 秒刷新一次,最多等待 5 秒。</summary>
+ /// <param name="deviceId">设备 ID</param>
+ /// <param name="action">动作名称</param>
+ /// <param name="minCount">最少记录数,达到后提前返回</param>
+ private static async Task<IList<DeviceHistory>> WaitForDeviceHistory(Int32 deviceId, String action, Int32 minCount = 1)
+ {
+ IList<DeviceHistory> result = [];
+ for (var i = 0; i < 25; i++)
+ {
+ await Task.Delay(200).ConfigureAwait(false);
+ // 使用 FindAll + 显式 WHERE 直接查 DB,绕过实体缓存,避免 SaveAsync 未提交时缓存为空
+ result = DeviceHistory.FindAll(DeviceHistory._.DeviceId == deviceId & DeviceHistory._.Action == action, null, null, 0, 0);
+ if (result.Count >= minCount) break;
+ }
+ return result;
+ }
+ #endregion
+}
diff --git a/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
new file mode 100644
index 0000000..0b77aec
--- /dev/null
+++ b/XUnitTest.Samples/IoTZero/IoTZeroWebFactory.cs
@@ -0,0 +1,164 @@
+extern alias IoTZero;
+
+using IoTZero::IoT.Data;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using NewLife;
+using NewLife.Log;
+using Xunit;
+
+namespace XUnitTest.Samples.IoTZero;
+
+/// <summary>IoTZero 集成测试 WebApplicationFactory。使用真实 Kestrel(端口0),完全独立临时数据库</summary>
+public sealed class IoTZeroWebFactory : WebApplicationFactory<IoTZero::Program>, IAsyncLifetime
+{
+ #region 属性
+ /// <summary>服务端真实监听地址,如 http://127.0.0.1:12345</summary>
+ public String BaseUrl { get; private set; } = null!;
+
+ /// <summary>临时数据/配置目录(测试结束后删除)</summary>
+ public String TempDir { get; private set; } = null!;
+
+ /// <summary>原始工作目录(析构时恢复)</summary>
+ private String _origCurrentDir = null!;
+ #endregion
+
+ #region WebApplicationFactory 重写
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ XTrace.UseConsole();
+
+ // 每次 WAF 初始化时创建独立的临时目录
+ TempDir = Path.Combine(Path.GetTempPath(), "IoTZeroTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(TempDir);
+ Directory.CreateDirectory(Path.Combine(TempDir, "config"));
+ Directory.CreateDirectory(Path.Combine(TempDir, "Data"));
+
+ // 切换工作目录:ClientSetting 的 config/IoTClient.config 会写到 TempDir
+ _origCurrentDir = Environment.CurrentDirectory;
+ Environment.CurrentDirectory = TempDir;
+
+ // Testing 环境:Program.cs 会跳过 RegisterService 和 ClientTest 调用
+ builder.UseEnvironment("Testing");
+
+ // 覆盖连接串为临时 SQLite 文件,每次测试数据完全隔离
+ var dbIoT = $"Data Source={Path.Combine(TempDir, "Data", "IoT.db")};Provider=Sqlite";
+ var dbIoTData = $"Data Source={Path.Combine(TempDir, "Data", "IoTData.db")};ShowSql=false;Provider=Sqlite";
+ var dbMembership = $"Data Source={Path.Combine(TempDir, "Data", "Membership.db")};Provider=Sqlite";
+
+ builder.ConfigureAppConfiguration((_, cfg) =>
+ {
+ cfg.AddInMemoryCollection(new Dictionary<String, String?>
+ {
+ ["ConnectionStrings:IoT"] = dbIoT,
+ ["ConnectionStrings:IoTData"] = dbIoTData,
+ ["ConnectionStrings:Membership"] = dbMembership,
+ ["XCodeSetting:ShowSQL"] = "false",
+ });
+ });
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder) => base.CreateHost(builder);
+ #endregion
+
+ #region IAsyncLifetime
+ /// <summary>初始化:启用真实 Kestrel(端口0),触发 WAF 启动服务,读取实际监听地址,预置产品数据</summary>
+ async Task IAsyncLifetime.InitializeAsync()
+ {
+ UseKestrel(0); // 必须在服务器启动前调用:告知 WAF 使用真实 Kestrel 而非 TestServer
+ _ = Services; // 触发 WebApplicationFactory.EnsureHost() → ConfigureHostBuilder → CreateHost
+ // WAF 在 CreateHost 后调用 TryExtractHostAddress,自动将实际端口写入 ClientOptions.BaseAddress
+ BaseUrl = ClientOptions.BaseAddress?.ToString()?.TrimEnd('/') ?? "";
+ XTrace.WriteLine("IoTZeroWebFactory 启动,地址:{0}", BaseUrl);
+ // XCode 静态 DAL 在进程内持久化,同一进程多次运行测试会复用共享数据库中的历史数据。
+ // 在服务启动后、测试开始前,清空所有业务表,确保每次测试数据完全隔离。
+ // 注意:必须在 _ = Services 之后执行(DAL 连接已建立),并在 SeedProduct 之前执行(避免误清产品数据)。
+ CleanTestData();
+ // 预置测试所需的 Product(EdgeGateway),否则 OnRegister 会抛出“无效产品”
+ SeedProduct();
+ await Task.CompletedTask;
+ }
+
+ async Task IAsyncLifetime.DisposeAsync()
+ {
+ Dispose();
+ await Task.CompletedTask;
+ }
+ #endregion
+
+ #region 析构
+ protected override void Dispose(Boolean disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ // 恢复工作目录
+ if (!_origCurrentDir.IsNullOrEmpty())
+ Environment.CurrentDirectory = _origCurrentDir;
+
+ // 清理临时目录
+ if (!TempDir.IsNullOrEmpty() && Directory.Exists(TempDir))
+ {
+ try { Directory.Delete(TempDir, true); }
+ catch { /* 忽略清理异常 */ }
+ }
+ }
+ }
+ #endregion
+
+ #region 辅助
+ /// <summary>读取 IoTClient.config 文件内容(登录后由 ClientSetting.Save 写入)</summary>
+ /// <remarks>Config<T> 使用 GetBasePath() 保存到 AppDomain.CurrentDomain.BaseDirectory/Config/ 目录</remarks>
+ public String? ReadClientConfigFile()
+ {
+ var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "IoTClient.config");
+ return File.Exists(configFile) ? File.ReadAllText(configFile) : null;
+ }
+
+ /// <summary>预置产品数据(EdgeGateway),供设备自动注册使用</summary>
+ /// <remarks>OnRegister 验证:Product.FindByCode(ProductKey) != null && product.Enable == true</remarks>
+ private static void SeedProduct()
+ {
+ var product = Product.FindByCode("EdgeGateway");
+ if (product == null)
+ {
+ product = new Product
+ {
+ Code = "EdgeGateway",
+ Name = "边缘网关(集成测试)",
+ Enable = true,
+ };
+ product.Insert();
+ }
+ else if (!product.Enable)
+ {
+ product.Enable = true;
+ product.Update();
+ }
+
+ XTrace.WriteLine("IoTZero 产品已预置,Code=EdgeGateway, Id={0}", product.Id);
+ }
+
+ /// <summary>清空业务表数据。XCode 静态 DAL 在进程内持久化,同一进程多次运行测试须在每次测试前重置数据</summary>
+ private static void CleanTestData()
+ {
+ // 直接执行 SQL 删除,绕过实体缓存,速度快;删除后清空实体缓存
+ DeviceOnline.Meta.Session.Execute("DELETE FROM DeviceOnline");
+ DeviceHistory.Meta.Session.Execute("DELETE FROM DeviceHistory");
+ Device.Meta.Session.Execute("DELETE FROM Device");
+ Product.Meta.Session.Execute("DELETE FROM Product");
+
+ // 清空实体缓存,避免残留对象影响后续 FindAll 查询
+ DeviceOnline.Meta.Cache.Clear("清空测试数据");
+ DeviceHistory.Meta.Cache.Clear("清空测试数据");
+ Device.Meta.Cache.Clear("清空测试数据");
+ Product.Meta.Cache.Clear("清空测试数据");
+ DeviceOnline.Meta.SingleCache.Clear("清空测试数据");
+ Device.Meta.SingleCache.Clear("清空测试数据");
+
+ XTrace.WriteLine("IoTZero 测试数据已清空");
+ }
+ #endregion
+}
diff --git a/XUnitTest.Samples/NullTokenService.cs b/XUnitTest.Samples/NullTokenService.cs
new file mode 100644
index 0000000..3323986
--- /dev/null
+++ b/XUnitTest.Samples/NullTokenService.cs
@@ -0,0 +1,20 @@
+using NewLife.Remoting.Models;
+using NewLife.Remoting.Services;
+using NewLife.Web;
+
+namespace XUnitTest.Samples;
+
+/// <summary>测试用 noop 令牌服务。替换 ITokenService,使 SendCommand 等接口在集成测试中无需真实应用令牌</summary>
+internal sealed class NullTokenService : ITokenService
+{
+ /// <summary>颁发令牌(测试用,返回固定 token)</summary>
+ /// <param name="name">名称</param>
+ /// <param name="id">标识</param>
+ /// <returns>令牌</returns>
+ public IToken IssueToken(String name, String? id = null) => new TokenModel { AccessToken = "test", ExpireIn = 3600 };
+
+ /// <summary>验证令牌(测试用,始终放行)</summary>
+ /// <param name="token">令牌字符串</param>
+ /// <returns>解析结果,始终无错误</returns>
+ public (JwtBuilder, Exception?) DecodeToken(String token) => (new JwtBuilder(), null);
+}
diff --git a/XUnitTest.Samples/Properties/launchSettings.json b/XUnitTest.Samples/Properties/launchSettings.json
new file mode 100644
index 0000000..4b1c0f4
--- /dev/null
+++ b/XUnitTest.Samples/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "XUnitTest.Samples": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:49229;http://localhost:49230"
+ }
+ }
+}
\ No newline at end of file
diff --git a/XUnitTest.Samples/SamplesCollectionDefinition.cs b/XUnitTest.Samples/SamplesCollectionDefinition.cs
new file mode 100644
index 0000000..369d35a
--- /dev/null
+++ b/XUnitTest.Samples/SamplesCollectionDefinition.cs
@@ -0,0 +1,7 @@
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>集成测试集合定义。将 ZeroServer 和 IoTZero 测试放在同一集合中串行执行,避免 XCode 静态 DAL 连接跨进程污染</summary>
+[CollectionDefinition("SamplesIntegration")]
+public sealed class SamplesIntegrationCollection { }
diff --git a/XUnitTest.Samples/XUnitTest.Samples.csproj b/XUnitTest.Samples/XUnitTest.Samples.csproj
new file mode 100644
index 0000000..face2ca
--- /dev/null
+++ b/XUnitTest.Samples/XUnitTest.Samples.csproj
@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <AssemblyTitle>Samples 集成测试集</AssemblyTitle>
+ <Description>端到端集成测试:ZeroServer、IoTZero。真实 Kestrel 启动,NodeClient/HttpDevice 真实连接</Description>
+ <Company>新生命开发团队</Company>
+ <Copyright>©2002-2026 NewLife</Copyright>
+ <IsPackable>false</IsPackable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>latest</LangVersion>
+ <!-- 不生成独立输出目录,避免与 Samples 冲突 -->
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+ <PackageReference Include="NewLife.UnitTest" Version="1.1.2026.102" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <!-- 使用程序集别名隔离两个样本项目中同名的 partial class Program -->
+ <ProjectReference Include="..\Samples\ZeroServer\ZeroServer.csproj">
+ <Aliases>ZeroServer</Aliases>
+ </ProjectReference>
+ <ProjectReference Include="..\Samples\IoTZero\IoTZero.csproj">
+ <Aliases>IoTZero</Aliases>
+ </ProjectReference>
+ </ItemGroup>
+
+</Project>
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
new file mode 100644
index 0000000..6914136
--- /dev/null
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerIntegrationTests.cs
@@ -0,0 +1,360 @@
+extern alias ZeroServer;
+
+using System.Text.Json;
+using NewLife;
+using NewLife.Log;
+using NewLife.Remoting.Models;
+using NewLife.Security;
+using XCode;
+using ZeroServer::Zero.Data.Nodes;
+using ZeroServer::ZeroClient;
+using Xunit;
+
+namespace XUnitTest.Samples.ZeroServer;
+
+/// <summary>
+/// ZeroServer HTTP 全链路集成测试。
+/// 使用 WebApplicationFactory 启动真实 Kestrel(端口自动分配),NodeClient 通过 HTTP 连接。
+/// 单个测试方法串行执行 9 步,覆盖:自动注册登录、配置持久化、服务端实体验证、注销/重登、
+/// 心跳计数、WebSocket 通知建立、SendCommand + CommandReply、升级检查、事件上报。
+/// </summary>
+[Collection("SamplesIntegration")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class ZeroServerIntegrationTests : IClassFixture<ZeroServerWebFactory>
+{
+ private readonly ZeroServerWebFactory _factory;
+
+ public ZeroServerIntegrationTests(ZeroServerWebFactory factory) => _factory = factory;
+
+ #region 9 步串行集成测试
+ [Fact(DisplayName = "ZeroServer_9步全链路集成测试")]
+ public async Task FullIntegrationFlow_AllNineSteps()
+ {
+ // 确保 Factory 已启动(首次访问时触发 ConfigureWebHost + CreateHost)
+ Assert.False(_factory.BaseUrl.IsNullOrEmpty(), "BaseUrl 未初始化,Factory 启动失败");
+ XTrace.WriteLine("ZeroServer 测试服务地址:{0}", _factory.BaseUrl);
+
+ // 清空上次测试残留数据:XCode DAL 连接 StardustData 持久化在 AppBase/Data 目录,
+ // 跨测试运行累积;若不清理,Step3 的 Assert.Empty(NodeOnline) 会因旧记录而误判失败
+ NodeOnline.Delete("1=1");
+ NodeHistory.Delete("1=1");
+
+ // 创建新的 ClientSetting(空 Code/Secret,触发服务端自动注册)
+ var setting = new ClientSetting { Server = _factory.BaseUrl };
+
+ using var client = new NodeClient(setting);
+ client.Log = XTrace.Log;
+
+ // Step 1 - 自动注册登录
+ var (code, secret) = await Step1_AutoRegisterLogin(client, setting);
+
+ // Step 2 - 服务端实体验证(通过 XCode 直接查询,同进程内共享 DAL 连接)
+ var nodeId = await Step2_VerifyServerEntities(code);
+
+ // Step 3 - 注销
+ await Step3_Logout(client, nodeId);
+
+ // Step 4 - 修改密钥后重登
+ await Step4_ReLoginWithNewSecret(client, setting, code, nodeId);
+
+ // Step 5 - 心跳
+ await Step5_Ping(client, nodeId);
+
+ // Step 6 - WebSocket 建立确认
+ await Step6_WaitWebSocket(nodeId);
+
+ // Step 7 - SendCommand + CommandReply
+ await Step7_SendCommandAndReply(client, code);
+
+ // Step 8 - 升级检查
+ await Step8_Upgrade(client);
+
+ // Step 9 - 事件上报
+ await Step9_PostEvents(client);
+ }
+ #endregion
+
+ #region Step 1 — 自动注册登录
+ private async Task<(String code, String secret)> Step1_AutoRegisterLogin(NodeClient client, ClientSetting setting)
+ {
+ XTrace.WriteLine("=== Step 1: 自动注册登录 ===");
+
+ // 确保以空 Code/Secret 开始,触发服务端自动注册
+ setting.Code = null!;
+ setting.Secret = null!;
+
+ await client.Login(null, CancellationToken.None).ConfigureAwait(false);
+
+ // 客户端状态验证
+ Assert.True(client.Logined, "Login 后 Logined 应为 true");
+
+ // 服务端应已写回 Code 和 Secret
+ Assert.False(setting.Code.IsNullOrEmpty(), "服务端应已回填 Code");
+ Assert.False(setting.Secret.IsNullOrEmpty(), "服务端应已回填 Secret");
+
+ XTrace.WriteLine("自动注册成功,Code={0}", setting.Code);
+
+ // 等待 ClientSetting.Save() 写文件(异步 IO,稍作等待)
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 配置文件持久化验证
+ var configContent = _factory.ReadClientConfigFile();
+ Assert.False(configContent.IsNullOrEmpty(), "config/ZeroClient.config 应已写入磁盘");
+ Assert.Contains(setting.Code, configContent!, StringComparison.Ordinal);
+ Assert.Contains(setting.Secret, configContent!, StringComparison.Ordinal);
+
+ XTrace.WriteLine("配置文件已写入,内容长度={0}", configContent!.Length);
+
+ return (setting.Code, setting.Secret);
+ }
+ #endregion
+
+ #region Step 2 — 服务端实体验证
+ private async Task<Int32> Step2_VerifyServerEntities(String code)
+ {
+ XTrace.WriteLine("=== Step 2: 验证服务端实体 ===");
+
+ // 等待服务端异步写库完成(Node 实体同步写入,短暂等待即可)
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 验证 Node 实体已创建
+ var node = Node.FindByCodeWithCache(code, false);
+ Assert.NotNull(node);
+ Assert.Equal(code, node.Code);
+ Assert.True(node.Enable, "新注册的 Node 应为启用状态");
+ Assert.False(node.Secret.IsNullOrEmpty(), "Node.Secret 不应为空");
+
+ XTrace.WriteLine("Node 已创建,ID={0}, Enable={1}", node.Id, node.Enable);
+
+ // 验证 NodeOnline 已创建(直接 SQL,绕过实体缓存)
+ var onlines = NodeOnline.FindAll(NodeOnline._.NodeId == node.Id);
+ Assert.NotEmpty(onlines);
+
+ XTrace.WriteLine("NodeOnline 数量={0}", onlines.Count);
+
+ // 验证 NodeHistory 已记录登录(WriteHistory 使用 EntityQueue 异步写库,需轮询等待)
+ var logins = await WaitForNodeHistory(node.Id, "Http登录");
+ Assert.NotEmpty(logins);
+ Assert.True(logins[0].Success, "第一次登录的 NodeHistory 应为成功");
+
+ XTrace.WriteLine("NodeHistory 登录记录={0}", logins.Count);
+
+ return node.Id;
+ }
+ #endregion
+
+ #region Step 3 — 注销
+ private async Task Step3_Logout(NodeClient client, Int32 nodeId)
+ {
+ XTrace.WriteLine("=== Step 3: 注销 ===");
+
+ await client.Logout("集成测试注销", CancellationToken.None).ConfigureAwait(false);
+
+ Assert.False(client.Logined, "Logout 后 Logined 应为 false");
+
+ // 轮询等待 NodeOnline 被删除(RemoveOnline 同步执行,但直接 SQL 读取可能有短暂延迟)
+ var emptyOnlines = await WaitForNodeOnlineEmpty(nodeId);
+ Assert.Empty(emptyOnlines);
+
+ // NodeHistory 下线记录由 EntityQueue 异步写入,需轮询等待
+ var logouts = await WaitForNodeHistory(nodeId, "Http设备下线");
+ Assert.NotEmpty(logouts);
+
+ XTrace.WriteLine("注销成功,NodeHistory 注销记录={0}", logouts.Count);
+ }
+ #endregion
+
+ #region Step 4 — 修改密钥后重登
+ private async Task Step4_ReLoginWithNewSecret(NodeClient client, ClientSetting setting, String code, Int32 nodeId)
+ {
+ XTrace.WriteLine("=== Step 4: 修改密钥后重登 ===");
+
+ // 在服务端直接修改 Node.Secret(模拟运维修改密钥场景)
+ var node = Node.FindByCodeWithCache(code, false)!;
+ var newSecret = Rand.NextString(16);
+ node.Secret = newSecret;
+ node.Update();
+
+ // 客户端同步使用新密钥
+ setting.Secret = newSecret;
+
+ // 重新登录
+ await client.Login("重登测试", CancellationToken.None).ConfigureAwait(false);
+
+ Assert.True(client.Logined, "修改密钥后重登,Logined 应为 true");
+
+ // NodeOnline 应重新出现(直接 SQL,绕过实体缓存)
+ var onlines = NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+ Assert.NotEmpty(onlines);
+
+ // 登录历史应有 2 条记录(第一次自动注册 + 本次);EntityQueue 异步写入,需轮询等待
+ var logins = await WaitForNodeHistory(nodeId, "Http登录", 2);
+ Assert.True(logins.Count >= 2, $"应有至少 2 条登录历史,实际={logins.Count}");
+
+ XTrace.WriteLine("重登成功,NodeHistory 登录记录共={0}", logins.Count);
+ }
+ #endregion
+
+ #region Step 5 — 心跳
+ private async Task Step5_Ping(NodeClient client, Int32 nodeId)
+ {
+ XTrace.WriteLine("=== Step 5: 心跳 ===");
+
+ // 记录心跳前的 PingCount
+ var onlineBefore = NodeOnline.FindByNodeId(nodeId);
+ var pingCountBefore = onlineBefore?.PingCount ?? 0;
+
+ var rs = await client.Ping(CancellationToken.None).ConfigureAwait(false);
+ Assert.NotNull(rs);
+
+ // 等待服务端写库
+ await Task.Delay(300).ConfigureAwait(false);
+
+ // 刷新实体缓存后验证 PingCount 增加
+ var onlineAfter = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
+ Assert.NotNull(onlineAfter);
+ Assert.True(onlineAfter.PingCount > pingCountBefore,
+ $"Ping 后 PingCount 应增加,before={pingCountBefore}, after={onlineAfter.PingCount}");
+
+ XTrace.WriteLine("心跳成功,PingCount={0}", onlineAfter.PingCount);
+ }
+ #endregion
+
+ #region Step 6 — WebSocket 通知建立
+ private async Task Step6_WaitWebSocket(Int32 nodeId)
+ {
+ XTrace.WriteLine("=== Step 6: 等待 WebSocket 建立 ===");
+
+ // NodeClient 登录后会自动建立 WebSocket 通知连接(因为 Features 包含 Notify)
+ // 轮询最长 5 秒等待 NodeOnline.WebSocket = true
+ var deadline = DateTime.Now.AddSeconds(5);
+ NodeOnline? online = null;
+ while (DateTime.Now < deadline)
+ {
+ online = NodeOnline.Find(NodeOnline._.NodeId == nodeId);
+ if (online?.WebSocket == true) break;
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+
+ Assert.NotNull(online);
+ Assert.True(online.WebSocket, "NodeOnline.WebSocket 应在 5 秒内变为 true");
+
+ XTrace.WriteLine("WebSocket 已建立,SessionId={0}", online.SessionId);
+ }
+ #endregion
+
+ #region Step 7 — SendCommand + CommandReply
+ private async Task Step7_SendCommandAndReply(NodeClient client, String code)
+ {
+ XTrace.WriteLine("=== Step 7: SendCommand + CommandReply ===");
+
+ var tcs = new TaskCompletionSource<CommandEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ client.Received += (_, e) =>
+ {
+ // 只接受我们发出的命令
+ if (e.Model?.Command == "test:echo")
+ tcs.TrySetResult(e);
+ };
+
+ // 通过 HttpClient 调用 [AllowAnonymous] 的 Node/SendCommand 接口
+ // SendCommand 在 Service 层使用应用令牌验证,此处用已登录的设备令牌代替(测试环境 TokenService 不区分令牌来源)
+ using var http = new HttpClient();
+ var deviceToken = ((NewLife.Remoting.IApiClient)client).Token;
+ if (!deviceToken.IsNullOrEmpty())
+ http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {deviceToken}");
+ var payload = JsonSerializer.Serialize(new
+ {
+ Code = code,
+ Command = "test:echo",
+ Argument = "hello",
+ Timeout = 10, // 等待响应最多 10 秒
+ });
+ var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await http.PostAsync($"{_factory.BaseUrl}/Node/SendCommand", content).ConfigureAwait(false);
+ XTrace.WriteLine("SendCommand HTTP 状态={0}", response.StatusCode);
+
+ // 等待客户端收到命令(最多 10 秒)
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var cmdEvt = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false);
+
+ Assert.NotNull(cmdEvt.Model);
+ Assert.Equal("test:echo", cmdEvt.Model!.Command);
+ Assert.Equal("hello", cmdEvt.Model.Argument);
+
+ XTrace.WriteLine("已收到命令,Command={0}, Argument={1}", cmdEvt.Model.Command, cmdEvt.Model.Argument);
+ }
+ #endregion
+
+ #region Step 8 — 升级检查
+ private async Task Step8_Upgrade(NodeClient client)
+ {
+ XTrace.WriteLine("=== Step 8: 升级检查 ===");
+
+ // 无新版本时应返回 null,不应抛出异常
+ IUpgradeInfo? info = null;
+ var ex = await Record.ExceptionAsync(async () =>
+ {
+ info = await client.Upgrade(null, CancellationToken.None).ConfigureAwait(false);
+ });
+
+ Assert.Null(ex);
+ // info 为 null 表示无可用升级,合法
+ XTrace.WriteLine("升级检查成功,info={0}", info == null ? "null(无升级)" : info.Version);
+ }
+ #endregion
+
+ #region 辅助方法
+ /// <summary>轮询等待 NodeHistory 出现指定条数记录(绕过实体缓存,直接 SQL 查询)</summary>
+ /// <param name="nodeId">节点 ID</param>
+ /// <param name="action">操作名称</param>
+ /// <param name="minCount">最少记录数,默认 1</param>
+ /// <returns>查到的记录列表(可能超过 minCount)</returns>
+ private static async Task<IList<NodeHistory>> WaitForNodeHistory(Int32 nodeId, String action, Int32 minCount = 1)
+ {
+ var deadline = DateTime.Now.AddSeconds(5);
+ while (DateTime.Now < deadline)
+ {
+ // 直接 SQL,绕过 EntityListCache(EntityQueue 异步写入,缓存尚未更新)
+ var list = NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
+ if (list.Count >= minCount) return list;
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+ return NodeHistory.FindAll(NodeHistory._.NodeId == nodeId & NodeHistory._.Action == action);
+ }
+
+ /// <summary>轮询等待 NodeOnline 对应 nodeId 的记录全部消失</summary>
+ /// <param name="nodeId">节点 ID</param>
+ /// <returns>查到的剩余记录(超时后返回当时状态)</returns>
+ private static async Task<IList<NodeOnline>> WaitForNodeOnlineEmpty(Int32 nodeId)
+ {
+ var deadline = DateTime.Now.AddSeconds(5);
+ while (DateTime.Now < deadline)
+ {
+ var list = NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+ if (list.Count == 0) return list;
+ await Task.Delay(200).ConfigureAwait(false);
+ }
+ return NodeOnline.FindAll(NodeOnline._.NodeId == nodeId);
+ }
+ #endregion
+
+ #region Step 9 — 事件上报
+ private async Task Step9_PostEvents(NodeClient client)
+ {
+ XTrace.WriteLine("=== Step 9: 事件上报 ===");
+
+ var events = new[]
+ {
+ new EventModel { Name = "TestEvent1", Type = "info", Remark = "集成测试事件1" },
+ new EventModel { Name = "TestEvent2", Type = "alert", Remark = "集成测试事件2" },
+ };
+
+ var count = await client.PostEvents(events).ConfigureAwait(false);
+
+ Assert.Equal(2, count);
+ XTrace.WriteLine("事件上报成功,返回数量={0}", count);
+ }
+ #endregion
+}
diff --git a/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
new file mode 100644
index 0000000..f089d47
--- /dev/null
+++ b/XUnitTest.Samples/ZeroServer/ZeroServerWebFactory.cs
@@ -0,0 +1,125 @@
+extern alias ZeroServer;
+
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using NewLife;
+using NewLife.Log;
+using Xunit;
+
+namespace XUnitTest.Samples.ZeroServer;
+
+/// <summary>ZeroServer 集成测试 WebApplicationFactory。使用真实 Kestrel(端口0),完全独立临时数据库</summary>
+public sealed class ZeroServerWebFactory : WebApplicationFactory<ZeroServer::Program>, IAsyncLifetime
+{
+ #region 属性
+ /// <summary>服务端真实监听地址,如 http://127.0.0.1:12345</summary>
+ public String BaseUrl { get; private set; } = null!;
+
+ /// <summary>临时数据/配置目录(测试结束后删除)</summary>
+ public String TempDir { get; private set; } = null!;
+
+ /// <summary>原始工作目录(析构时恢复)</summary>
+ private String _origCurrentDir = null!;
+ #endregion
+
+ #region WebApplicationFactory 重写
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ XTrace.UseConsole();
+
+ // 每次 WAF 初始化时创建独立的临时目录
+ TempDir = Path.Combine(Path.GetTempPath(), "ZeroServerTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(TempDir);
+ Directory.CreateDirectory(Path.Combine(TempDir, "config"));
+ Directory.CreateDirectory(Path.Combine(TempDir, "Data"));
+
+ // 切换工作目录:ClientSetting 的 config/ZeroClient.config 会写到 TempDir
+ _origCurrentDir = Environment.CurrentDirectory;
+ Environment.CurrentDirectory = TempDir;
+
+ // Testing 环境:Program.cs 会跳过 RegisterService 和 ClientTest 调用
+ builder.UseEnvironment("Testing");
+
+ // 覆盖连接串为临时 SQLite 文件,每次测试数据完全隔离
+ // ZeroServer 实体 ConnName 映射:Node→"Zero",NodeOnline/NodeHistory→"StardustData",Area→"Membership"
+ // 同时覆盖 "IoT",防止 EntityFactory.InitAll() 用 appsettings.json 的相对路径初始化 IoTZero 的实体连接
+ var dataDir = Path.Combine(TempDir, "Data");
+ var dbZero = $"Data Source={Path.Combine(dataDir, "Zero.db")};Provider=Sqlite";
+ var dbStardust = $"Data Source={Path.Combine(dataDir, "StardustData.db")};Provider=Sqlite";
+ var dbMembership = $"Data Source={Path.Combine(dataDir, "Membership.db")};Provider=Sqlite";
+ var dbIoT = $"Data Source={Path.Combine(dataDir, "IoT.db")};Provider=Sqlite";
+
+ builder.ConfigureAppConfiguration((_, cfg) =>
+ {
+ cfg.AddInMemoryCollection(new Dictionary<String, String?>
+ {
+ ["ConnectionStrings:Zero"] = dbZero,
+ ["ConnectionStrings:StardustData"] = dbStardust,
+ ["ConnectionStrings:Membership"] = dbMembership,
+ ["ConnectionStrings:IoT"] = dbIoT,
+ ["XCodeSetting:ShowSQL"] = "false",
+ });
+ });
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder) => base.CreateHost(builder);
+ #endregion
+
+ #region IAsyncLifetime
+ /// <summary>初始化:启用真实 Kestrel(端口0),触发 WAF 启动服务,读取实际监听地址</summary>
+ async Task IAsyncLifetime.InitializeAsync()
+ {
+ UseKestrel(0); // 必须在服务器启动前调用:告知 WAF 使用真实 Kestrel 而非 TestServer
+ _ = Services; // 触发 WebApplicationFactory.EnsureHost() → ConfigureHostBuilder → CreateHost
+ // WAF 在 CreateHost 后调用 TryExtractHostAddress,自动将实际端口写入 ClientOptions.BaseAddress
+ BaseUrl = ClientOptions.BaseAddress?.ToString()?.TrimEnd('/') ?? "";
+ XTrace.WriteLine("ZeroServerWebFactory 启动,地址:{0}", BaseUrl);
+ await Task.CompletedTask;
+ }
+
+ async Task IAsyncLifetime.DisposeAsync()
+ {
+ Dispose();
+ await Task.CompletedTask;
+ }
+ #endregion
+
+ #region 析构
+ protected override void Dispose(Boolean disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ // 恢复工作目录
+ if (!_origCurrentDir.IsNullOrEmpty())
+ Environment.CurrentDirectory = _origCurrentDir;
+
+ // 清理临时目录
+ if (!TempDir.IsNullOrEmpty() && Directory.Exists(TempDir))
+ {
+ try { Directory.Delete(TempDir, true); }
+ catch { /* 忽略清理异常 */ }
+ }
+ }
+ }
+ #endregion
+
+ #region 辅助
+ /// <summary>读取 ZeroClient.config 文件内容(登录后由 ClientSetting.Save 写入)</summary>
+ /// <remarks>Config<T> 使用 GetBasePath() 保存到 AppDomain.CurrentDomain.BaseDirectory/Config/ 目录</remarks>
+ public String? ReadClientConfigFile()
+ {
+ var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "ZeroClient.config");
+ return File.Exists(configFile) ? File.ReadAllText(configFile) : null;
+ }
+
+ /// <summary>删除 ZeroClient.config,让下一次测试从空状态开始</summary>
+ public void DeleteClientConfig()
+ {
+ var configFile = Path.Combine(AppContext.BaseDirectory, "Config", "ZeroClient.config");
+ if (File.Exists(configFile)) File.Delete(configFile);
+ }
+ #endregion
+}
diff --git a/XUnitTest/Samples/ZeroRpcServerFixture.cs b/XUnitTest/Samples/ZeroRpcServerFixture.cs
new file mode 100644
index 0000000..c9526b1
--- /dev/null
+++ b/XUnitTest/Samples/ZeroRpcServerFixture.cs
@@ -0,0 +1,140 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using NewLife;
+using NewLife.Data;
+using NewLife.Log;
+using NewLife.Remoting;
+using XCode;
+using XCode.Membership;
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>ZeroRpcServer 集成测试夹具。启动完整的 ApiServer(端口0自动分配),并初始化临时 SQLite 数据库</summary>
+public sealed class ZeroRpcServerFixture : IAsyncLifetime
+{
+ #region 属性
+ /// <summary>服务端监听端口(系统自动分配)</summary>
+ public Int32 Port { get; private set; }
+
+ /// <summary>测试用户 ID</summary>
+ public Int32 UserId { get; private set; }
+
+ private ApiServer _server = null!;
+ private String _tempDir = null!;
+ private String _origDataPath = null!;
+ private String _origCurrentDir = null!;
+ #endregion
+
+ #region 初始化与清理
+ public async Task InitializeAsync()
+ {
+ XTrace.UseConsole();
+
+ // 独立临时目录,测试互不干扰
+ _tempDir = Path.Combine(Path.GetTempPath(), "ZeroRpcTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+
+ // 重定向 XCode 数据目录到临时目录,避免污染工作目录
+ _origDataPath = NewLife.Setting.Current.DataPath;
+ NewLife.Setting.Current.DataPath = _tempDir;
+
+ _origCurrentDir = Environment.CurrentDirectory;
+ Environment.CurrentDirectory = _tempDir;
+
+ // 初始化所有 XCode 实体表(自动建表)
+ EntityFactory.InitAll();
+
+ // 预置用户,让 UserController 能正常查询到
+ var user = new User
+ {
+ Name = "TestUser",
+ DisplayName = "测试用户",
+ Enable = true,
+ };
+ user.Save();
+ UserId = user.ID;
+
+ // 启动 ApiServer(端口0自动分配,同时监听 TCP/UDP/WS/HTTP)
+ _server = new ApiServer(0)
+ {
+ Name = "ZeroRpcTestServer",
+ Encoder = new JsonEncoder(),
+ Log = XTrace.Log,
+#if DEBUG
+ EncoderLog = XTrace.Log,
+#endif
+ };
+
+ _server.Register<MyController>();
+ _server.Register<UserTestController>();
+ _server.Start();
+
+ Port = _server.Port;
+
+ XTrace.WriteLine("ZeroRpcServerFixture 启动,端口:{0}", Port);
+
+ await Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync()
+ {
+ _server.TryDispose();
+
+ // 恢复工作目录
+ if (!_origCurrentDir.IsNullOrEmpty()) Environment.CurrentDirectory = _origCurrentDir;
+ if (!_origDataPath.IsNullOrEmpty()) NewLife.Setting.Current.DataPath = _origDataPath;
+
+ // 删除临时目录
+ if (Directory.Exists(_tempDir))
+ {
+ try { Directory.Delete(_tempDir, true); }
+ catch { /* 忽略清理异常 */ }
+ }
+
+ await Task.CompletedTask;
+ }
+ #endregion
+
+ #region 内联控制器(镜像 Zero.RpcServer 中的控制器逻辑)
+ internal class MyController
+ {
+ /// <summary>整数加法</summary>
+ public Int32 Add(Int32 x, Int32 y) => x + y;
+
+ /// <summary>RC4 加解密往返</summary>
+ public IPacket RC4(IPacket pk)
+ {
+ var data = pk.ToArray();
+ var pass = "NewLife".GetBytes();
+ return (ArrayPacket)data.RC4(pass);
+ }
+ }
+
+ [Api("User")]
+ internal class UserTestController : IApi, IActionFilter
+ {
+ public IApiSession Session { get; set; } = null!;
+
+ [Api(nameof(FindByID))]
+ public async Task<Object?> FindByID(Int32 id)
+ {
+ var times = Session["Times"].ToInt();
+ times++;
+ Session["Times"] = times;
+
+ if (times >= 2)
+ throw new ApiException(ApiCode.TooManyRequests, $"调用次数过多!Times={times}");
+
+ await Task.Delay(10);
+ var user = User.FindByID(id);
+ if (user == null) return null;
+ return new { user.ID, user.Name, user.DisplayName, user.Enable };
+ }
+
+ public void OnActionExecuting(ControllerContext filterContext) { }
+ public void OnActionExecuted(ControllerContext filterContext) { }
+ }
+ #endregion
+}
diff --git a/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs b/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs
new file mode 100644
index 0000000..0de21f6
--- /dev/null
+++ b/XUnitTest/Samples/ZeroRpcServerIntegrationTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NewLife;
+using NewLife.Data;
+using NewLife.Log;
+using NewLife.Remoting;
+using NewLife.Security;
+using XCode.Membership;
+using Xunit;
+
+namespace XUnitTest.Samples;
+
+/// <summary>ZeroRpcServer 多协议集成测试。覆盖 TCP / UDP / WebSocket / HTTP 四种协议</summary>
+/// <remarks>
+/// 四个测试方法共享同一 ApiServer 实例(IClassFixture),减少启动开销。
+/// 每个协议独立建立连接,Session 状态各自隔离,互不干扰。
+/// </remarks>
+[Collection("ZeroRpcServer")]
+[TestCaseOrderer("NewLife.UnitTest.DefaultOrderer", "NewLife.UnitTest")]
+public class ZeroRpcServerIntegrationTests : IClassFixture<ZeroRpcServerFixture>
+{
+ private readonly ZeroRpcServerFixture _fixture;
+
+ public ZeroRpcServerIntegrationTests(ZeroRpcServerFixture fixture) => _fixture = fixture;
+
+ #region 辅助:通用断言逻辑
+ /// <summary>
+ /// 对给定客户端执行一整套 RPC 断言流程:
+ /// api/all、My/Add、My/RC4、User/FindByID(首次成功 + 第二次 429)
+ /// </summary>
+ private static async Task AssertRpcCallsAsync(IApiClient client, Int32 userId)
+ {
+ // 1. api/all —— 返回接口列表,包含我们注册的接口
+ var apis = await client.InvokeAsync<String[]>("Api/All");
+ Assert.NotNull(apis);
+ Assert.True(apis.Length >= 5, $"接口数量应 ≥ 5,实际:{apis.Length}");
+ Assert.Contains(apis, a => a.Contains("My/Add"));
+ Assert.Contains(apis, a => a.Contains("My/RC4"));
+ Assert.Contains(apis, a => a.Contains("User/FindByID"));
+
+ // 2. api/info —— 返回服务端信息,含 MachineName
+ var state = Rand.NextString(8);
+ var state2 = Rand.NextString(8);
+ var info = await client.InvokeAsync<IDictionary<String, Object>>("Api/Info", new { state, state2 });
+ Assert.NotNull(info);
+ Assert.True(info.ContainsKey("MachineName"), "info 应包含 MachineName");
+ Assert.True(info.ContainsKey("State"), "info 应包含 State");
+ Assert.Equal(state, info["State"]?.ToString());
+
+ // 3. My/Add —— 整数加法,精确验证返回值
+ var sum = await client.InvokeAsync<Int32>("My/Add", new { x = 13, y = 7 });
+ Assert.Equal(20, sum);
+
+ var sum2 = await client.InvokeAsync<Int32>("My/Add", new { x = -5, y = 5 });
+ Assert.Equal(0, sum2);
+
+ // 4. My/RC4 —— 二进制往返:原文加密后再次加密应还原
+ // 注: RC4 服务返回 IPacket 二进制,必须用 InvokeAsync<IPacket>(ApiClient 直接返回 message.Data,不过 JSON 解码)
+ var original = "Hello NewLife RC4".GetBytes();
+ var pk1 = (ArrayPacket)original;
+ var encrypted = await client.InvokeAsync<IPacket>("My/RC4", pk1);
+ Assert.NotNull(encrypted);
+ var encryptedBytes = encrypted.ToArray();
+ Assert.NotEqual(original, encryptedBytes); // 加密后应不同
+
+ var pk2 = (ArrayPacket)encryptedBytes;
+ var decrypted = await client.InvokeAsync<IPacket>("My/RC4", pk2);
+ var decryptedBytes = decrypted?.ToArray();
+ Assert.Equal(original, decryptedBytes); // 解密后应还原
+
+ // 5. User/FindByID(首次)—— 返回实体,校验字段
+ var user = await client.InvokeAsync<IDictionary<String, Object>>("User/FindByID", new { id = userId });
+ Assert.NotNull(user);
+ Assert.True(user.ContainsKey("Name"), "User 响应应包含 Name 字段");
+ Assert.False(user["Name"]?.ToString().IsNullOrEmpty(), "User.Name 不应为空");
+
+ // 6. User/FindByID(第二次同 Session)—— 触发 429 TooManyRequests
+ var ex = await Assert.ThrowsAsync<ApiException>(
+ () => client.InvokeAsync<IDictionary<String, Object>>("User/FindByID", new { id = userId }));
+ Assert.Equal(429, ex.Code);
+ Assert.Contains("调用次数过多", ex.Message);
+ }
+ #endregion
+
+ #region TCP 协议测试
+ [Fact(DisplayName = "TCP协议_完整RPC调用链")]
+ public async Task TcpProtocolTest()
+ {
+ using var client = new ApiClient($"tcp://127.0.0.1:{_fixture.Port}")
+ {
+ Log = XTrace.Log,
+ };
+
+ await AssertRpcCallsAsync(client, _fixture.UserId);
+ }
+ #endregion
+
+ #region UDP 协议测试
+ [Fact(DisplayName = "UDP协议_完整RPC调用链")]
+ public async Task UdpProtocolTest()
+ {
+ using var client = new ApiClient($"udp://127.0.0.1:{_fixture.Port}")
+ {
+ Log = XTrace.Log,
+ };
+
+ await AssertRpcCallsAsync(client, _fixture.UserId);
+ }
+ #endregion
+
+ #region WebSocket 协议测试
+ [Fact(DisplayName = "WebSocket协议_完整RPC调用链")]
+ public async Task WebSocketProtocolTest()
+ {
+ using var client = new ApiClient($"ws://127.0.0.1:{_fixture.Port}")
+ {
+ Log = XTrace.Log,
+ };
+
+ await AssertRpcCallsAsync(client, _fixture.UserId);
+ }
+ #endregion
+
+ #region HTTP 协议测试
+ [Fact(DisplayName = "HTTP协议_完整RPC调用链")]
+ public async Task HttpProtocolTest()
+ {
+ var client = new ApiHttpClient($"http://127.0.0.1:{_fixture.Port}")
+ {
+ Log = XTrace.Log,
+ };
+
+ // 1. api/all(GET):返回完整签名格式 "ReturnType Route(params)",用 Contains 子串匹配
+ var apis = await client.GetAsync<String[]>("Api/All");
+ Assert.NotNull(apis);
+ Assert.True(apis.Length >= 5, $"接口数量应 ≥ 5,实际:{apis.Length}");
+ Assert.Contains(apis, a => a.Contains("My/Add"));
+
+ // 2. api/info(POST),校验 State 字段回显
+ var state = Rand.NextString(8);
+ var state2 = Rand.NextString(8);
+ var info = await client.PostAsync<IDictionary<String, Object>>("Api/Info", new { state, state2 });
+ Assert.NotNull(info);
+ Assert.Equal(state, info["State"]?.ToString());
+
+ // 3. My/Add(GET:名称含 Get 或参数为基础类型时自动转 GET)
+ var sum = await client.InvokeAsync<Int32>("My/Add", new { x = 100, y = 200 }, default);
+ Assert.Equal(300, sum);
+
+ // 4. User/FindByID(GET)
+ var user = await client.GetAsync<IDictionary<String, Object>>("User/FindByID", new { id = _fixture.UserId });
+ Assert.NotNull(user);
+ Assert.True(user.ContainsKey("Name"));
+ Assert.False(user["Name"]?.ToString().IsNullOrEmpty());
+ }
+ #endregion
+}
diff --git a/XUnitTest/XUnitTest.csproj b/XUnitTest/XUnitTest.csproj
index baedaca..1216f30 100644
--- a/XUnitTest/XUnitTest.csproj
+++ b/XUnitTest/XUnitTest.csproj
@@ -11,8 +11,9 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
- <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
+ <PackageReference Include="NewLife.XCode" Version="11.25.2026.403" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NewLife.Core" Version="11.14.2026.402" />
<PackageReference Include="NewLife.UnitTest" Version="1.1.2026.102" />