diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..689071fa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,172 @@ +# copilot-instructions.md (Bing.NetCore / framework) + +> 适用范围:本仓库 `framework/` +> - `framework/src`:框架源码 +> - `framework/tests`:测试项目(已按模块拆分,含 Unit + Integration + Shared Test Infrastructure) + +本指引用于约束 GitHub Copilot / Copilot Chat / Codex 在本仓库生成代码与测试的方式,使输出 **可合并、可维护、可测试、与现有结构一致**。 + +--- + +## 1. 现有测试结构(必须遵守) + +### 1.1 目录真实结构(以当前仓库为准) +- 单元测试(Unit Tests):`framework/tests/.Tests` + - 示例:`Bing.Core.Tests`、`Bing.Auditing.Tests`、`Bing.MultiTenancy.Tests` +- 集成测试(Integration Tests):`framework/tests/.Tests.Integration` + - 示例:`Bing.Caching.CSRedis.Tests.Integration`、`Bing.Dapper.MySql.Tests.Integration` +- 共享测试基建(Test Infrastructure / Shared) + - `Bing.Test.Shared` + - `Bing.TestShare` + - `Bing.TestShare.MySql` +- 其它:存在 `Bing.Tests`(聚合/兼容/历史用途),生成内容时需避免与现有职责冲突。 + +> 规则:新增测试项目必须优先沿用上述命名与分层,不要引入新的目录体系。 + +### 1.2 Unit vs Integration 的边界(强制) +- **Unit Test(默认)** + - 只测纯逻辑与边界行为 + - 不依赖网络、真实 DB、真实缓存、真实文件系统 + - 必须确定性、可重复运行、速度快 +- **Integration Test(仅在必须时)** + - 仅用于验证:真实数据库/缓存/DI 组合/ASP.NET Core 管道/中间件链路 + - 必须具备可重复运行能力:可本地跑、可 CI 跑 + - 若依赖外部服务(MySql/PostgreSql/Redis),需提供可控的启动方式(优先容器化/测试环境变量配置),不得硬编码连接信息 + +--- + +## 2. 生成代码的通用原则 + +### 2.1 不引入破坏性变更 +- 不随意修改 public API(类名、方法签名、异常类型、默认行为)。 +- 如必须调整 API: + - 同步:单元测试/集成测试(按影响范围) + - 更新文档或注释(如该模块已有说明/README/使用示例) + +### 2.2 模块依赖方向必须合理 +- `Core/Abstractions` 不允许依赖 `AspNetCore` 或其它上层实现。 +- 上层模块可依赖基础模块,但不能反向引用。 +- 跨模块能力(异常、多租户、审计、事件总线、缓存等)通过抽象/接口/扩展点协作,避免硬耦合。 + +### 2.3 优先做“最小变更” +- 避免引入无意义格式化/大规模重命名导致 diff 失真。 +- 改动必须聚焦目标,保持可 review。 + +--- + +## 3. 测试规范(你这个仓库的强约束) + +### 3.1 测试项目引用规则 +当为某个 `framework/src/` 增加或完善测试时: + +- Unit Test 项目:`framework/tests/.Tests` + - 必须 `ProjectReference` 指向被测 `framework/src//.csproj` + - 必须引用共享测试基建:优先使用仓库现有的 `Bing.Test.Shared` / `Bing.TestShare*`(不要新造轮子) + +- Integration Test 项目:`framework/tests/.Tests.Integration` + - 除上述规则外,还需要明确: + - 测试运行前置条件(DB/Redis 等) + - 如何在 CI 运行(环境变量/容器/跳过策略) + +> 禁止:为了让测试“看起来能跑”,在 Integration 里写 sleep、随机等待、依赖公网。 + +### 3.2 命名规范 +- 测试方法名:**英文**,建议:`Method_State_Expected()` +- 测试注释:**中文**,每个测试必须写“测试目的” +- 结构:AAA(Arrange / Act / Assert) + +示例: +```csharp +/// +/// 测试目的:当租户标识缺失时,解析器应返回 null,避免抛异常影响上游管道。 +/// +[Fact] +public void Resolve_WhenTenantIdMissing_ShouldReturnNull() +{ + // Arrange + ... + + // Act + ... + + // Assert + ... +} +``` + +### 3.3 Mock 边界(重点) +- 只 Mock 外部依赖:时间、Guid、随机数、IO、HTTP、数据库、缓存、日志等。 +- 不 Mock 被测模块内部实现细节(避免“验证调用次数”替代“验证行为结果”)。 +- 日志测试:通常只验证“不抛异常/路径正确/包含关键字段(若有结构化事件)”,不要断言完整文本。 + +--- + +## 4. 针对你当前模块的“优先补测策略” + +### P0(必须先补齐) +- `Bing.Core.Tests` + - 基础工具类、Guard/参数校验、扩展方法、序列化/转换、线程安全与边界 +- `Bing.MultiTenancy.Tests` + - 租户解析优先级、TenantScope/上下文传播、fallback 行为 +- `Bing.AspNetCore.Mvc.Tests` / `Bing.Aop.AspectCore.Tests`(如涉及关键管道/拦截) + - 中间件/过滤器/拦截器的关键行为(可用少量集成测试兜底) +- `Bing.Logging.Tests` / `Bing.Logging.Serilog.Tests` + - logger provider 组合、结构化字段是否被正确输出(避免测具体文本) +- `Bing.EventBus.Tests` + - 发布/订阅基本语义、异常传播、重试策略(若有) + +### P1(增强) +- `Bing.Auditing.Tests` + - 审计字段填充策略、忽略规则、边界数据 +- `Bing.AutoMapper.Tests` + - profile 注册、映射配置验证、关键映射行为(避免测 AutoMapper 内部) + +### P2(最后) +- 多 DB / 多缓存的集成链路补齐与稳定性增强 + - `Bing.Dapper.*.Tests.Integration` + - `Bing.Caching.*.Tests.Integration` + - 重点:连接配置、隔离(schema/db)、清理策略、并发稳定性 + +--- + +## 5. Copilot 输出要求(强制工作流) + +当你让 Copilot “完善某模块/补单测/修 bug”时,必须按以下步骤输出: + +1) **变更计划** + - 修改/新增文件路径列表 + - 影响的模块与依赖 + - 是否需要 Integration Test(为什么) +2) **用例矩阵** + - Given/When/Then(至少:正常 + 边界 + 负例) + - Mock 边界说明 +3) **落地代码** + - 可编译通过、可运行的测试代码 + - 如新增项目:给出 `.csproj` 关键引用片段 + +> 禁止:直接生成大量代码却不说明测试意图与用例覆盖范围。 + +--- + +## 6. CI/可执行性约束(提交验收) + +每次 PR 必须满足: +1. `dotnet build` 通过 +2. `dotnet test` 通过(Unit Tests 必须全绿) +3. 新增/修改行为必须包含对应测试 +4. Integration Tests 如需跳过必须有明确条件(例如 `RUN_INTEGRATION_TESTS=true`),并在说明中写清楚 + +--- + +## 7. 常用 Prompt 模板(按你仓库结构) + +### 7.1 针对某模块补齐 Unit Tests(默认) +“请为 `framework/src/` 补齐 P0 单元测试,写到 `framework/tests/.Tests`。先列 public API,再给用例矩阵(Given/When/Then),再输出可运行的 xUnit 测试代码,并复用 `Bing.Test.Shared / Bing.TestShare*` 的现有能力,不要新造测试基建。” + +### 7.2 针对某数据库实现补齐 Integration Tests +“请为 `framework/src/Bing.Dapper.` 补齐集成测试,写到 `framework/tests/Bing.Dapper..Tests.Integration`。说明依赖(DB/连接配置/容器化建议),并提供可重复运行的初始化与清理策略,避免 sleep 与非确定性。” + +### 7.3 仅做问题审计(不改代码) +“只扫描 `framework/src` + `framework/tests`,列出:测试覆盖缺口、P0 风险、依赖方向问题、API 不一致点,并给出可执行 PR 列表(每个 PR 的文件路径与验收标准)。” + +--- diff --git a/.github/skills/chinese-comments/SKILL.md b/.github/skills/chinese-comments/SKILL.md new file mode 100644 index 00000000..5694b9eb --- /dev/null +++ b/.github/skills/chinese-comments/SKILL.md @@ -0,0 +1,52 @@ +--- +name: chinese-comments +description: 为 C#/.NET 项目自动补全规范的中文 XML 注释。 +--- + +# 中文注释规范 + +在生成或修改 C#/.NET 项目代码时,请自动补全中文 XML 注释。 + +## 必须补全的对象 + +- Controller 与 Action +- class / interface / enum / record / struct / delegate / event +- public / protected / internal / private 方法 +- 构造函数 +- DTO / Entity / Options +- public / protected / internal / private 属性 +- 字段、常量、缓存键 + +## 注释要求 + +1. 使用简体中文。 +2. 注释应描述业务含义,不要只翻译名称。 +3. public、protected、internal、private 方法必须补全 ``、``、``。 +4. 枚举类型和枚举成员都要补全注释。 +5. DTO、实体、控制器、应用服务必须优先补全注释。 +6. 重要 private 字段和常量建议补充注释。 +7. 保持代码格式化,不要破坏原有逻辑。 +8. 如果发现已有注释质量较差,请优化为更准确的中文注释。 +9. 对于复杂的业务逻辑,建议在方法体内添加必要的注释,解释关键步骤和算法。 +10. 对于公共 API,建议在注释中包含示例代码或使用场景,以便其他开发者理解和使用。 +11. 对于涉及到性能优化的代码段,建议在注释中说明优化的原因和预期效果。 +12. 对于涉及到异常处理的代码段,建议在注释中说明可能抛出的异常类型及其处理方式。 +13. 对于涉及到多线程或异步操作的代码段,建议在注释中说明线程安全性和异步行为的注意事项。 +14. 对于涉及到外部依赖或第三方库的代码段,建议在注释中说明依赖的版本和使用方式。 +15. 对于涉及到配置项或环境变量的代码段,建议在注释中说明配置的作用和默认值。 +16. 对于涉及到安全性或权限控制的代码段,建议在注释中说明安全策略和权限要求。 +17. 对于涉及到数据验证或输入校验的代码段,建议在注释中说明验证规则和错误处理方式。 +18. 对于涉及到日志记录或监控的代码段,建议在注释中说明日志级别和监控指标。 +19. 对于返回 `Task` 的返回值,则无需补充 `` 注释。 + +## 风格要求 + +- 简洁、专业、准确 +- 符合企业级 .NET 项目风格 +- 与 ABP / DDD / CQRS 场景兼容 +- 优先体现业务用途,而非字面翻译 + +## 执行要求 + +当发现缺失注释时,请顺手补齐; +当发现已有注释质量较差时,请优化为更准确的中文注释。 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5167178e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,169 @@ +# Agent 全局执行规范 + +本文件用于约束 Copilot、Codex、Gemini、Claude 等 AI 工具在当前项目中的执行方式。 + +## 默认行为 + +- 默认使用简体中文回答。 +- 生成代码时,优先补充必要的中文注释。 +- 所有命令、脚本、文件读写、日志导出、Markdown 生成,都必须优先考虑 Windows + VS Code + PowerShell 环境下的中文乱码问题。 +- 默认编码统一使用 UTF-8。 + +## UTF-8 规则(强制) + +- 所有文本文件读取必须显式指定 `UTF-8`。 +- 所有文本文件写入必须显式指定 `UTF-8`。 +- 禁止使用 PowerShell 默认编码直接写入源码、Markdown、XML、Gradle、Kotlin、Java、YAML、JSON、Properties 文件。 +- 优先使用 Python 进行文件读写: + +```python +from pathlib import Path + +text = Path("input.txt").read_text(encoding="utf-8") +Path("output.txt").write_text(text, encoding="utf-8") +``` + +- 在 shell 命令中修改中文内容时,避免直接内联中文大段文本;优先使用 Python 组装字符串后以 UTF-8 写入。 +- 终端出现 `????` 时,不要直接判断文件已损坏;应优先用编辑器或 `unicode_escape` 检查文件真实内容。 + +## PowerShell 编码规则 + +在生成 PowerShell 脚本时,必须优先设置控制台编码: + +```powershell +[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [System.Text.UTF8Encoding]::new($false) +``` + +写入文件时必须显式指定编码: + +```powershell +Set-Content -Path $path -Value $content -Encoding utf8 +Add-Content -Path $path -Value $content -Encoding utf8 +Out-File -FilePath $path -Encoding utf8 +Export-Csv -Path $path -Encoding utf8 -NoTypeInformation +``` + +禁止在未确认编码的情况下,直接使用: + +```powershell +"中文内容" > file.md +"中文内容" >> file.md +``` + +## Python 编码规则 + +生成 Python 脚本时,所有文本文件读写必须显式指定编码: + +```python +from pathlib import Path + +content = Path("input.txt").read_text(encoding="utf-8") +Path("output.txt").write_text(content, encoding="utf-8") +``` + +禁止使用不带 `encoding` 的文件读写: + +```python +open("input.txt").read() +open("output.txt", "w").write("内容") +``` + +## .NET / C# 编码规则 + +生成 .NET / C# 文件读写代码时,必须显式指定 UTF-8: + +```csharp +using System.Text; + +var text = File.ReadAllText(path, Encoding.UTF8); +File.WriteAllText(path, text, Encoding.UTF8); +``` + +控制台程序如需输出中文,优先设置: + +```csharp +Console.InputEncoding = Encoding.UTF8; +Console.OutputEncoding = Encoding.UTF8; +``` + +## Node.js 编码规则 + +生成 Node.js / TypeScript 文件读写代码时,必须显式指定 `utf8`: + +```ts +import { readFileSync, writeFileSync } from "node:fs"; + +const text = readFileSync("input.txt", "utf8"); +writeFileSync("output.txt", text, "utf8"); +``` + +## 文件类型规则 + +以下文件必须按 UTF-8 处理: + +- `.cs` +- `.csproj` +- `.sln` +- `.props` +- `.targets` +- `.json` +- `.yaml` +- `.yml` +- `.xml` +- `.md` +- `.sql` +- `.ps1` +- `.bat` +- `.cmd` +- `.js` +- `.ts` +- `.vue` +- `.java` +- `.kt` +- `.gradle` +- `.properties` + +## 乱码排查规则 + +如果用户反馈乱码,优先按以下顺序排查: + +1. VS Code 当前文件编码是否为 UTF-8。 +2. PowerShell Profile 是否设置 UTF-8。 +3. `[Console]::OutputEncoding` 是否为 UTF-8。 +4. `$OutputEncoding` 是否为 UTF-8。 +5. `chcp` 是否为 `65001`。 +6. 文件写入命令是否显式指定 `-Encoding utf8`。 +7. Python / Node.js / .NET 是否显式指定 UTF-8。 +8. 是否由旧文件本身就是 GBK / ANSI 编码导致。 + +## 禁止行为 + +- 禁止默认依赖 Windows ANSI / GBK 编码。 +- 禁止在未确认编码时批量替换中文内容。 +- 禁止使用未指定编码的文件迁移脚本。 +- 禁止生成可能导致中文变成 `????` 的命令。 +- 禁止把终端显示乱码直接等同于文件内容损坏。 + +## 推荐处理方式 + +当需要批量修改文件内容时,优先生成 Python 脚本,并使用: + +```python +from pathlib import Path + +path = Path("target-file.md") +text = path.read_text(encoding="utf-8") +text = text.replace("旧内容", "新内容") +path.write_text(text, encoding="utf-8") +``` + +当需要验证文件真实内容时,可以使用: + +```python +from pathlib import Path + +text = Path("target-file.md").read_text(encoding="utf-8") +print(text.encode("unicode_escape").decode("ascii")) +``` \ No newline at end of file diff --git a/Bing.All.sln b/Bing.All.sln index 8188a9a7..3ea3dd55 100644 --- a/Bing.All.sln +++ b/Bing.All.sln @@ -194,6 +194,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Logging.Serilog", "fra EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Logging.Sinks.Exceptionless", "framework\src\Bing.Logging.Sinks.Exceptionless\Bing.Logging.Sinks.Exceptionless.csproj", "{C7E17279-AAEB-4A58-BBBB-AF443F924D9E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Logging.Serilog.Tests", "framework\tests\Bing.Logging.Serilog.Tests\Bing.Logging.Serilog.Tests.csproj", "{D3F2A891-5E7C-4B8A-9F61-C2D3E4F5A678}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Logging.Tests", "framework\tests\Bing.Logging.Tests\Bing.Logging.Tests.csproj", "{73871EF9-9CF6-479F-BC1C-AD443CE037AE}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Validation.Abstractions", "framework\src\Bing.Validation.Abstractions\Bing.Validation.Abstractions.csproj", "{19A3A852-6F7B-4864-8F53-AE91A04D0732}" @@ -316,6 +318,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Dapper.PostgreSql.Test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Dapper.SqlServer.Tests.Integration", "framework\tests\Bing.Dapper.SqlServer.Tests.Integration\Bing.Dapper.SqlServer.Tests.Integration.csproj", "{AF2C8041-3B77-4912-8BC3-A19A18864215}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Dapper.SqlServer.Tests", "framework\tests\Bing.Dapper.SqlServer.Tests\Bing.Dapper.SqlServer.Tests.csproj", "{5026A352-2C26-48CD-A4CE-61ABF1B448C8}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Dapper.Oracle.Tests", "framework\tests\Bing.Dapper.Oracle.Tests\Bing.Dapper.Oracle.Tests.csproj", "{12F822A5-1353-4C27-922C-02F2655E9443}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bing.Localization.Abstractions", "framework\src\Bing.Localization.Abstractions\Bing.Localization.Abstractions.csproj", "{15207D1B-721C-4F32-A3B8-A24B912B6043}" @@ -624,6 +628,10 @@ Global {C7E17279-AAEB-4A58-BBBB-AF443F924D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU {C7E17279-AAEB-4A58-BBBB-AF443F924D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU {C7E17279-AAEB-4A58-BBBB-AF443F924D9E}.Release|Any CPU.Build.0 = Release|Any CPU + {D3F2A891-5E7C-4B8A-9F61-C2D3E4F5A678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3F2A891-5E7C-4B8A-9F61-C2D3E4F5A678}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3F2A891-5E7C-4B8A-9F61-C2D3E4F5A678}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3F2A891-5E7C-4B8A-9F61-C2D3E4F5A678}.Release|Any CPU.Build.0 = Release|Any CPU {73871EF9-9CF6-479F-BC1C-AD443CE037AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {73871EF9-9CF6-479F-BC1C-AD443CE037AE}.Debug|Any CPU.Build.0 = Debug|Any CPU {73871EF9-9CF6-479F-BC1C-AD443CE037AE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -740,6 +748,10 @@ Global {AF2C8041-3B77-4912-8BC3-A19A18864215}.Debug|Any CPU.Build.0 = Debug|Any CPU {AF2C8041-3B77-4912-8BC3-A19A18864215}.Release|Any CPU.ActiveCfg = Release|Any CPU {AF2C8041-3B77-4912-8BC3-A19A18864215}.Release|Any CPU.Build.0 = Release|Any CPU + {5026A352-2C26-48CD-A4CE-61ABF1B448C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5026A352-2C26-48CD-A4CE-61ABF1B448C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5026A352-2C26-48CD-A4CE-61ABF1B448C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5026A352-2C26-48CD-A4CE-61ABF1B448C8}.Release|Any CPU.Build.0 = Release|Any CPU {12F822A5-1353-4C27-922C-02F2655E9443}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {12F822A5-1353-4C27-922C-02F2655E9443}.Debug|Any CPU.Build.0 = Debug|Any CPU {12F822A5-1353-4C27-922C-02F2655E9443}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/common.tests.props b/common.tests.props index c0a2f74d..0070075b 100644 --- a/common.tests.props +++ b/common.tests.props @@ -34,4 +34,6 @@ + + diff --git a/docs/sqlquery-usage.md b/docs/sqlquery-usage.md new file mode 100644 index 00000000..cafeb0ee --- /dev/null +++ b/docs/sqlquery-usage.md @@ -0,0 +1,322 @@ +# ISqlQuery 使用说明 + +## 概述 + +`ISqlQuery` 是 Bing 数据访问层中的 Sql 查询对象,用于基于实体表达式构建类型安全的 Sql,并提供执行与分页等能力。它内部通过 `ISqlBuilder` 生成 Sql 语句,并继承了执行操作接口 `ISqlQueryOperation` 以及配置接口 `ISqlOptions`,同时实现 `IDisposable` 以管理数据库连接、事务等资源。 + +在典型场景中,`ISqlQuery` 主要用于: + +- 构建复杂查询:多表 Join、动态条件、分组、排序等; +- 实现统一的分页查询(同步/异步); +- 获取调试 Sql,便于排查问题或与 DBA 协作; +- 配置查询行为,例如执行后是否自动清理 Sql 和参数、是否输出调试日志等。 + +--- + +## 接口概览 + +`ISqlQuery` 的核心定义位于 `framework/src/Bing.Data.Sql/Bing/Data/Sql/ISqlQuery.cs`: + +```csharp +public partial interface ISqlQuery : ISqlQueryOperation, ISqlOptions, IDisposable +{ + /// + /// 上下文标识 + /// + string ContextId { get; } + + /// + /// Sql 生成器 + /// + ISqlBuilder SqlBuilder { get; } + + /// + /// 配置 + /// + /// 配置操作 + void Config(Action configAction); + + /// + /// 获取 Sql 生成器 + /// + ISqlBuilder GetBuilder(); + + /// + /// 分页查询 + /// + /// 返回结果类型 + /// 获取列表操作 + /// 分页参数 + /// 执行超时时间。单位:秒 + PagerList PagerQuery( + Func> func, + IPager parameter, + int? timeout = null); + + /// + /// 分页查询(异步) + /// + /// 返回结果类型 + /// 获取列表操作 + /// 分页参数 + /// 执行超时时间。单位:秒 + Task> PagerQueryAsync( + Func>> func, + IPager parameter, + int? timeout = null); + + /// + /// 临时禁用调试日志 + /// + ISqlQuery DisableDebugLog(); +} +``` + +### 关键属性 + +- `ContextId`:当前查询上下文标识,用于日志和诊断,便于跨组件追踪一次完整查询过程。 +- `SqlBuilder`:内部使用的 Sql 生成器实例,通常通过 `GetBuilder()` 获取并间接操作,不建议直接替换。 + +### 重要方法 + +- `Config(Action configAction)`:对当前查询对象进行配置,例如设置执行超时、是否执行后清空等。 +- `GetBuilder()`:获取当前查询关联的 `ISqlBuilder` 实例,通常用于高级场景或自定义执行逻辑。 +- `PagerQuery` / `PagerQueryAsync`:在给定分页参数 `IPager` 的基础上,统一完成分页逻辑,返回 `PagerList`。 +- `DisableDebugLog()`:临时禁用调试日志输出,适合高频调用、对日志量敏感的场景。 + +--- + +## 常用扩展方法 + +`ISqlQuery` 的大部分实际操作能力是通过扩展方法提供的,主要位于: + +- `Bing/Data/Sql/Extensions/Extensions.ISqlQuery.Sql.cs` +- `Bing/Data/Sql/Extensions/Extensions.ISqlQuery.Other.cs` + +### 1. Select / From / Join + +用于构建 `SELECT` 和 `FROM`、`JOIN` 子句。 + +```csharp +// 设置列名 +ISqlQuery Select(bool propertyAsAlias); +ISqlQuery Select(Expression> columns, bool propertyAsAlias = false); +ISqlQuery Select(Expression> column, string columnAlias = null); + +// 移除列 +ISqlQuery RemoveSelect(Expression> columns); +ISqlQuery RemoveSelect(Expression> column); + +// 设置表名 +ISqlQuery From(string alias = null, string schema = null); + +// 连接表 +ISqlQuery Join(string alias = null, string schema = null); +ISqlQuery LeftJoin(string alias = null, string schema = null); +ISqlQuery RightJoin(string alias = null, string schema = null); +``` + +- `propertyAsAlias`:是否将属性名映射为列别名,例如生成 `t.Name AS Name`。 +- `columns`:一组列表达式,如 `x => new object[] { x.Id, x.Name }`。 +- `alias` / `schema`:表别名与架构名。 + +### 2. 条件:Where / Or / On + +用于构建 `WHERE` 和 `JOIN ... ON` 条件。 + +```csharp +ISqlQuery Where( + Expression> expression, + object value, + Operator @operator = Operator.Equal); + +ISqlQuery Where(Expression> expression); + +ISqlQuery Or(params Expression>[] conditions); +ISqlQuery OrIf(Expression> predicate, bool condition); +ISqlQuery OrIf(bool condition, params Expression>[] predicates); +ISqlQuery OrIfNotEmpty(params Expression>[] conditions); + +ISqlQuery On( + Expression> left, + Expression> right, + Operator @operator = Operator.Equal); + +ISqlQuery On(Expression> expression); +``` + +特点: + +- 使用表达式树,字段引用具备编译期类型检查,重构友好; +- `OrIf` / `OrIfNotEmpty` 可根据条件动态添加查询条件,避免大量 if/else; +- `On` 支持按列或按布尔表达式定义连接条件。 + +### 3. 聚合函数 + +```csharp +ISqlQuery Count(Expression> expression, string columnAlias = null); +ISqlQuery Sum(Expression> expression, string columnAlias = null); +ISqlQuery Avg(Expression> expression, string columnAlias = null); +ISqlQuery Max(Expression> expression, string columnAlias = null); +ISqlQuery Min(Expression> expression, string columnAlias = null); +``` + +用途:在 `SELECT` 子句中添加聚合列,例如 `COUNT(o.Id) AS TotalCount`、`SUM(o.Amount)` 等。 + +--- + +## 运行期控制与清理 + +`Extensions.ISqlQuery.Other.cs` 中提供了一些常用的运行期控制与清理方法。 + +### 清理行为 + +```csharp +ISqlQuery ClearAfterExecution(this ISqlQuery sqlQuery, bool value = true); +ISqlQuery Clear(this ISqlQuery sqlQuery); +ISqlQuery ClearSelect(this ISqlQuery sqlQuery); +ISqlQuery ClearFrom(this ISqlQuery sqlQuery); +ISqlQuery ClearJoin(this ISqlQuery sqlQuery); +ISqlQuery ClearWhere(this ISqlQuery sqlQuery); +ISqlQuery ClearGroupBy(this ISqlQuery sqlQuery); +ISqlQuery ClearOrderBy(this ISqlQuery sqlQuery); +ISqlQuery ClearSqlParams(this ISqlQuery sqlQuery); +ISqlQuery ClearPageParams(this ISqlQuery sqlQuery); +``` + +- 默认情况下,执行后会根据配置自动清空 Sql 和参数; +- 可通过 `ClearAfterExecution(false)` 关闭自动清理,以便多次执行相同查询; +- 也可以按子句粒度清理,例如只清空 `Where` 条件重新构造。 + +### 调试与克隆 + +```csharp +ISqlBuilder CloneBuilder(this ISqlQuery sqlQuery); +ISqlBuilder NewBuilder(this ISqlQuery sqlQuery); +string GetDebugSql(this ISqlQuery sqlQuery); +``` + +- `CloneBuilder()`:复制当前 Sql 生成器,保留结构和参数,适合基于当前查询派生出多个变体; +- `NewBuilder()`:创建一个新的 Sql 生成器,适合在同一上下文中重新构造完全不同的 Sql; +- `GetDebugSql()`:获取带参数值的调试 Sql 字符串,常用于日志和问题排查。 + +### 过滤器控制 + +```csharp +ISqlQuery IgnoreFilter(this ISqlQuery sqlQuery) + where TSqlFilter : ISqlFilter; + +ISqlQuery IgnoreDeletedFilter(this ISqlQuery sqlQuery); +``` + +- `IgnoreFilter`:忽略特定 Sql 过滤器(如多租户、审计、自定义过滤器等); +- `IgnoreDeletedFilter()`:忽略逻辑删除过滤器,适用于需要查询已逻辑删除数据的后台/审计场景。 + +--- + +## 典型使用示例 + +> 以下示例中的执行部分(`SomeExecutor.Execute*`)仅为示意,请根据实际项目中 `ISqlQueryOperation` 的实现替换为真实调用方式。 + +### 示例一:简单列表查询 + +```csharp +public async Task> GetPaidOrdersAsync(ISqlQuery sqlQuery) +{ + sqlQuery + .Select(x => new object[] { x.Id, x.OrderNo, x.CustomerName, x.Amount }) + .From("o") + .Where(x => x.Status, OrderStatus.Paid); + + var builder = sqlQuery.GetBuilder(); + var sql = builder.ToDebugSql(); + var parameters = builder.GetParams(); // 伪代码,按实际 ISqlBuilder 实现调整 + + var result = await SomeExecutor.ExecuteQueryAsync(sql, parameters); + return result; +} +``` + +### 示例二:带可选条件的分页查询 + +```csharp +public async Task> QueryOrdersAsync( + ISqlQuery sqlQuery, + OrderQueryParameter parameter) +{ + sqlQuery + .Select(x => new object[] + { + x.Id, x.OrderNo, x.CustomerName, x.Amount, x.Status + }) + .From("o") + .Where(x => x.Status, parameter.Status) + .OrIf( + !string.IsNullOrWhiteSpace(parameter.Keyword), + x => x.OrderNo.Contains(parameter.Keyword) + || x.CustomerName.Contains(parameter.Keyword)); + + var result = await sqlQuery.PagerQueryAsync( + func: async () => + { + var builder = sqlQuery.GetBuilder(); + var sql = builder.ToDebugSql(); + var parameters = builder.GetParams(); + return await SomeExecutor.ExecuteQueryAsync(sql, parameters); + }, + parameter: parameter); + + return result; +} +``` + +### 示例三:多表 Join 查询 + +```csharp +public async Task> GetOrderWithCustomerAsync( + ISqlQuery sqlQuery, + Guid orderId) +{ + sqlQuery + .Select(x => new object[] { x.Id, x.OrderNo, x.Amount }) + .Select(x => new object[] { x.Name, x.Phone }) + .From("o") + .LeftJoin("c") + .On(o => o.CustomerId, c => c.Id) + .Where(o => o.Id, orderId); + + var builder = sqlQuery.GetBuilder(); + var sql = builder.ToDebugSql(); + var parameters = builder.GetParams(); + + var list = await SomeExecutor.ExecuteQueryAsync(sql, parameters); + return list; +} +``` + +### 示例四:忽略逻辑删除过滤器 + +```csharp +public async Task GetDeletedOrderAsync(ISqlQuery sqlQuery, Guid orderId) +{ + sqlQuery + .IgnoreDeletedFilter() + .From("o") + .Where(x => x.Id, orderId); + + var builder = sqlQuery.GetBuilder(); + var sql = builder.ToDebugSql(); + var parameters = builder.GetParams(); + + return await SomeExecutor.ExecuteSingleAsync(sql, parameters); +} +``` + +--- + +## 使用建议 + +1. **优先使用表达式扩展方法**:通过 `Select/From/Where/Join/...` 的表达式重载构建 Sql,避免硬编码列名,提高类型安全和可维护性。 +2. **合理控制清理行为**:默认自动清理适合大多数场景;需要多次复用查询时,可使用 `ClearAfterExecution(false)` 或手动调用 `Clear*` 系列方法。 +3. **善用 GetDebugSql 进行排查**:在开发和问题排查时,输出 `GetDebugSql()` 的结果便于和实际数据库环境对比验证。 +4. **谨慎忽略过滤器**:`IgnoreDeletedFilter()` 等方法仅应在确有需要的管理/审计场景使用,避免破坏业务数据约束。 diff --git a/framework.tests.props b/framework.tests.props index 6b61644f..009c8e9c 100644 --- a/framework.tests.props +++ b/framework.tests.props @@ -1,6 +1,6 @@ - + - net9.0;net8.0;net6.0; + net8.0;net6.0; diff --git a/framework/src/Bing.Aop.AspectCore/Bing.Aop.AspectCore.csproj b/framework/src/Bing.Aop.AspectCore/Bing.Aop.AspectCore.csproj index bedad874..f9497a10 100644 --- a/framework/src/Bing.Aop.AspectCore/Bing.Aop.AspectCore.csproj +++ b/framework/src/Bing.Aop.AspectCore/Bing.Aop.AspectCore.csproj @@ -9,6 +9,7 @@ + diff --git a/framework/src/Bing.Aop.AspectCore/Bing/Aspects/ValidAttribute.cs b/framework/src/Bing.Aop.AspectCore/Bing/Aspects/ValidAttribute.cs new file mode 100644 index 00000000..054270df --- /dev/null +++ b/framework/src/Bing.Aop.AspectCore/Bing/Aspects/ValidAttribute.cs @@ -0,0 +1,49 @@ +using AspectCore.DynamicProxy.Parameters; +using Bing.Aspects; + +namespace Bing.Validation; + +/// +/// 验证拦截器。 +/// 标记在方法参数上,AOP 管道会在方法执行前自动调用 进行校验。 +/// +/// +/// 此特性依赖 AspectCore 参数拦截机制,因此定义在 Bing.Aop.AspectCore 项目中, +/// 但保持 Bing.Validation 命名空间不变,消费方无需更改 using 语句。 +/// +public class ValidAttribute : ParameterInterceptorBase +{ + /// + /// 执行参数拦截逻辑:在方法执行前触发参数验证 + /// + public override async Task Invoke(ParameterAspectContext context, ParameterAspectDelegate next) + { + Validate(context.Parameter); + await next(context); + } + + /// + /// 验证单个参数对象 + /// + private static void Validate(Parameter parameter) + { + if (Bing.Reflection.Reflections.IsGenericCollection(parameter.RawType)) + { + ValidateCollection(parameter); + return; + } + if (parameter.Value is IVerifyModel validation) + validation.Validate(); + } + + /// + /// 验证集合中的每个元素 + /// + private static void ValidateCollection(Parameter parameter) + { + if (parameter.Value is not IEnumerable validations) + return; + foreach (var validation in validations) + validation.Validate(); + } +} diff --git a/framework/src/Bing.AspNetCore/Bing.AspNetCore.csproj b/framework/src/Bing.AspNetCore/Bing.AspNetCore.csproj index 20e53014..6648cba2 100644 --- a/framework/src/Bing.AspNetCore/Bing.AspNetCore.csproj +++ b/framework/src/Bing.AspNetCore/Bing.AspNetCore.csproj @@ -18,6 +18,9 @@ + diff --git a/framework/src/Bing.Auditing.Contracts/Bing/Auditing/DisableAuditingAttribute.cs b/framework/src/Bing.Auditing.Contracts/Bing/Auditing/DisableAuditingAttribute.cs new file mode 100644 index 00000000..2dc646c2 --- /dev/null +++ b/framework/src/Bing.Auditing.Contracts/Bing/Auditing/DisableAuditingAttribute.cs @@ -0,0 +1,13 @@ +namespace Bing.Auditing; + +/// +/// 禁用审计特性。 +/// 标记此特性的类或方法将被审计框架忽略,不会自动填充创建/修改/删除时间及操作人字段。 +/// +/// +/// 定义在 Bing.Auditing.Contracts 中,使领域层可直接引用而无需依赖审计实现。 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)] +public class DisableAuditingAttribute : Attribute +{ +} diff --git a/framework/src/Bing.Auditing/Bing/Auditing/AuditPropertySetter.cs b/framework/src/Bing.Auditing/Bing/Auditing/AuditPropertySetter.cs index d4e49cc0..4447ef8f 100644 --- a/framework/src/Bing.Auditing/Bing/Auditing/AuditPropertySetter.cs +++ b/framework/src/Bing.Auditing/Bing/Auditing/AuditPropertySetter.cs @@ -1,5 +1,6 @@ using Bing.DependencyInjection; using Bing.Extensions; +using Bing.Timing; using Bing.Users; namespace Bing.Auditing; @@ -13,9 +14,11 @@ public class AuditPropertySetter : IAuditPropertySetter, ITransientDependency /// 初始化一个类型的实例 /// /// 当前用户 - public AuditPropertySetter(ICurrentUser currentUser) + /// 时钟(可在测试中替换为 FakeClock) + public AuditPropertySetter(ICurrentUser currentUser, IClock clock) { CurrentUser = currentUser; + Clock = clock; } /// @@ -23,6 +26,11 @@ public AuditPropertySetter(ICurrentUser currentUser) /// protected ICurrentUser CurrentUser { get; } + /// + /// 时钟 + /// + protected IClock Clock { get; } + /// /// 设置创建属性 /// @@ -70,7 +78,7 @@ protected virtual void SetCreationTime(object targetObject) { if (targetObject is not IHasCreationTime objectWithCreationTime) return; - objectWithCreationTime.CreationTime ??= DateTime.Now; + objectWithCreationTime.CreationTime ??= Clock.Now; } /// @@ -151,7 +159,7 @@ protected virtual void SetCreator(object targetObject) protected virtual void SetLastModificationTime(object targetObject) { if (targetObject is IHasModificationTime objectWithModificationTime) - objectWithModificationTime.LastModificationTime = DateTime.Now; + objectWithModificationTime.LastModificationTime = Clock.Now; } /// @@ -214,7 +222,7 @@ protected virtual void SetLastModifier(object targetObject) protected virtual void SetDeletionTime(object targetObject) { if (targetObject is IHasDeletionTime objectWithDeletionTime) - objectWithDeletionTime.DeletionTime = DateTime.Now; + objectWithDeletionTime.DeletionTime = Clock.Now; } /// diff --git a/framework/src/Bing.Auditing/Bing/Auditing/DisableAuditingAttribute.cs b/framework/src/Bing.Auditing/Bing/Auditing/DisableAuditingAttribute.cs index 3f7e7bee..8fdf0066 100644 --- a/framework/src/Bing.Auditing/Bing/Auditing/DisableAuditingAttribute.cs +++ b/framework/src/Bing.Auditing/Bing/Auditing/DisableAuditingAttribute.cs @@ -1,8 +1,10 @@ namespace Bing.Auditing; /// -/// 禁用审计 特性 +/// 禁用审计 特性(已迁移至 Bing.Auditing.Contracts) /// +[Obsolete("DisableAuditingAttribute has been moved to Bing.Auditing.Contracts. " + + "Reference Bing.Auditing.Contracts and use Bing.Auditing.DisableAuditingAttribute from there.", false)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)] public class DisableAuditingAttribute : Attribute { diff --git a/framework/src/Bing.Core/Bing/Core/Modularity/BingCoreModule.cs b/framework/src/Bing.Core/Bing/Core/Modularity/BingCoreModule.cs index 4e0fafa6..ebfc6ab4 100644 --- a/framework/src/Bing.Core/Bing/Core/Modularity/BingCoreModule.cs +++ b/framework/src/Bing.Core/Bing/Core/Modularity/BingCoreModule.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Bing.Logging; +using Bing.Timing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -23,6 +24,8 @@ public class BingCoreModule : BingModule public override IServiceCollection AddServices(IServiceCollection services) { services.TryAddSingleton(); + // 注册系统时钟,测试中可替换为 FakeClock 以控制时间 + services.TryAddSingleton(); return services; } } diff --git a/framework/src/Bing.Core/Bing/Timing/IClock.cs b/framework/src/Bing.Core/Bing/Timing/IClock.cs new file mode 100644 index 00000000..7fae5b49 --- /dev/null +++ b/framework/src/Bing.Core/Bing/Timing/IClock.cs @@ -0,0 +1,24 @@ +namespace Bing.Timing; + +/// +/// 时钟抽象,提供对当前时间的可测试访问。 +/// 在生产环境中使用 ; +/// 在测试中可注入 FakeClock 以精确控制时间,消除测试的非确定性。 +/// +public interface IClock +{ + /// + /// 获取当前本地时间 + /// + DateTime Now { get; } + + /// + /// 获取当前 UTC 时间 + /// + DateTime UtcNow { get; } + + /// + /// 获取当前时间(带时区信息) + /// + DateTimeOffset NowOffset { get; } +} diff --git a/framework/src/Bing.Core/Bing/Timing/SystemClock.cs b/framework/src/Bing.Core/Bing/Timing/SystemClock.cs new file mode 100644 index 00000000..5e61c2eb --- /dev/null +++ b/framework/src/Bing.Core/Bing/Timing/SystemClock.cs @@ -0,0 +1,24 @@ +using Bing.DependencyInjection; + +namespace Bing.Timing; + +/// +/// 基于系统时钟的 实现(生产环境默认实现)。 +/// 直接委托给 。 +/// +public class SystemClock : IClock, ISingletonDependency +{ + /// + /// 系统时钟单例,可用于无 DI 场景 + /// + public static readonly IClock Instance = new SystemClock(); + + /// + public DateTime Now => DateTime.Now; + + /// + public DateTime UtcNow => DateTime.UtcNow; + + /// + public DateTimeOffset NowOffset => DateTimeOffset.Now; +} diff --git a/framework/src/Bing.Ddd.Application.Contracts/references.props b/framework/src/Bing.Ddd.Application.Contracts/references.props index 029d6013..699a76d0 100644 --- a/framework/src/Bing.Ddd.Application.Contracts/references.props +++ b/framework/src/Bing.Ddd.Application.Contracts/references.props @@ -1,5 +1,6 @@  + diff --git a/framework/src/Bing.Ddd.Domain/references.props b/framework/src/Bing.Ddd.Domain/references.props index 64f071b6..8ae45745 100644 --- a/framework/src/Bing.Ddd.Domain/references.props +++ b/framework/src/Bing.Ddd.Domain/references.props @@ -1,6 +1,10 @@  - + + + + + diff --git a/framework/src/Bing.EntityFrameworkCore/Bing.EntityFrameworkCore.csproj b/framework/src/Bing.EntityFrameworkCore/Bing.EntityFrameworkCore.csproj index cf80b6d4..4b57a352 100644 --- a/framework/src/Bing.EntityFrameworkCore/Bing.EntityFrameworkCore.csproj +++ b/framework/src/Bing.EntityFrameworkCore/Bing.EntityFrameworkCore.csproj @@ -24,6 +24,8 @@ + + diff --git a/framework/src/Bing.Logging.Serilog/AssemblyInfo.cs b/framework/src/Bing.Logging.Serilog/AssemblyInfo.cs new file mode 100644 index 00000000..30c8cd58 --- /dev/null +++ b/framework/src/Bing.Logging.Serilog/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Bing.Logging.Serilog.Tests")] diff --git a/framework/src/Bing.Validation/Bing/Validation/ValidAttribute.cs b/framework/src/Bing.Validation/Bing/Validation/ValidAttribute.cs index d0739522..344211f5 100644 --- a/framework/src/Bing.Validation/Bing/Validation/ValidAttribute.cs +++ b/framework/src/Bing.Validation/Bing/Validation/ValidAttribute.cs @@ -1,46 +1,3 @@ -using AspectCore.DynamicProxy.Parameters; -using Bing.Aspects; - -namespace Bing.Validation; - -/// -/// 验证拦截器 -/// -public class ValidAttribute : ParameterInterceptorBase -{ - /// - /// 执行 - /// - public override async Task Invoke(ParameterAspectContext context, ParameterAspectDelegate next) - { - Validate(context.Parameter); - await next(context); - } - - /// - /// 验证 - /// - /// 参数 - private void Validate(Parameter parameter) - { - if (Reflection.Reflections.IsGenericCollection(parameter.RawType)) - { - ValidateCollection(parameter); - return; - } - var validation = parameter.Value as IVerifyModel; - validation?.Validate(); - } - - /// - /// 验证集合 - /// - /// 参数 - private void ValidateCollection(Parameter parameter) - { - if (!(parameter.Value is IEnumerable validations)) - return; - foreach (var validation in validations) - validation.Validate(); - } -} +// ValidAttribute 已迁移至 Bing.Aop.AspectCore 项目,保留在 Bing.Validation 命名空间。 +// 消费方请确保引用 Bing.Aop.AspectCore,using Bing.Validation 语句无需变更。 +// See: Bing.Aop.AspectCore/Bing/Aspects/ValidAttribute.cs diff --git a/framework/src/Bing.Validation/references.props b/framework/src/Bing.Validation/references.props index 18f2757b..a9338dcb 100644 --- a/framework/src/Bing.Validation/references.props +++ b/framework/src/Bing.Validation/references.props @@ -1,6 +1,8 @@  + + + - \ No newline at end of file diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/AspectExceptionPromptTest.cs b/framework/tests/Bing.Aop.AspectCore.Tests/AspectExceptionPromptTest.cs new file mode 100644 index 00000000..f8f690a0 --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/AspectExceptionPromptTest.cs @@ -0,0 +1,75 @@ +using Bing.Exceptions.Prompts; +using Shouldly; +using Xunit; + +namespace Bing.Aop.AspectCore; + +/// +/// 单元测试 +/// +public class AspectExceptionPromptTest +{ + private readonly AspectExceptionPrompt _prompt = new(); + + /// + /// 测试目的:GetPrompt(null) 应返回 null,不抛异常。 + /// + [Fact] + public void GetPrompt_NullException_ShouldReturnNull() + { + // Act + var result = _prompt.GetPrompt(null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetPrompt 传入普通异常(非 AspectInvocationException)应返回空字符串。 + /// + [Fact] + public void GetPrompt_RegularException_ShouldReturnEmpty() + { + // Arrange + var ex = new InvalidOperationException("regular error"); + + // Act + var result = _prompt.GetPrompt(ex); + + // Assert + result.ShouldBe(string.Empty); + } + + /// + /// 测试目的:GetRawException 传入普通异常应原样返回,不做任何转换。 + /// + [Fact] + public void GetRawException_RegularException_ShouldReturnSameException() + { + // Arrange + var ex = new ArgumentException("param error"); + + // Act + var result = _prompt.GetRawException(ex); + + // Assert + ReferenceEquals(result, ex).ShouldBeTrue(); + } + + /// + /// 测试目的:GetRawException 传入嵌套普通异常(非 AspectInvocationException)应返回最外层异常本身。 + /// + [Fact] + public void GetRawException_NestedRegularException_ShouldReturnOuterException() + { + // Arrange + var inner = new Exception("inner"); + var outer = new InvalidOperationException("outer", inner); + + // Act + var result = _prompt.GetRawException(outer); + + // Assert + ReferenceEquals(result, outer).ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/AttributeReflectionTest.cs b/framework/tests/Bing.Aop.AspectCore.Tests/AttributeReflectionTest.cs new file mode 100644 index 00000000..6bdbd6ce --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/AttributeReflectionTest.cs @@ -0,0 +1,136 @@ +using AspectCore.DependencyInjection; +using AspectCore.DynamicProxy; +using AspectCore.DynamicProxy.Parameters; +using Bing.Aspects; +using Shouldly; +using Xunit; + +namespace Bing.Aop.AspectCore; + +/// +/// 反射结构测试:验证 、 +/// +/// 的类型层次结构与元数据约束。 +/// +public class AttributeReflectionTest +{ + // ═══════════════════════════════════════════════════════════ + // AutowiredAttribute + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AutowiredAttribute 应具有 [AttributeUsage(Property | Field)], + /// 确保属性注入标记只能用于属性和字段,不能用于其他目标。 + /// + [Fact] + public void AutowiredAttribute_AttributeUsage_ShouldTargetPropertyAndField() + { + // Arrange & Act + var usage = typeof(AutowiredAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), inherit: true) + .Cast() + .FirstOrDefault(); + + // Assert + usage.ShouldNotBeNull(); + usage.ValidOn.ShouldBe(AttributeTargets.Property | AttributeTargets.Field); + } + + /// + /// 测试目的:AutowiredAttribute 应继承自 AspectCore 的 FromServiceContextAttribute, + /// 确保属性注入行为由 AspectCore DI 容器提供。 + /// + [Fact] + public void AutowiredAttribute_ShouldInheritFromFromServiceContextAttribute() + { + // Assert + typeof(AutowiredAttribute).BaseType.ShouldBe(typeof(FromServiceContextAttribute)); + } + + // ═══════════════════════════════════════════════════════════ + // IgnoreAttribute + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:IgnoreAttribute 应继承自 AspectCore 的 NonAspectAttribute, + /// 确保被标记的成员被 AOP 框架正确排除在拦截链之外。 + /// + [Fact] + public void IgnoreAttribute_ShouldInheritFromNonAspectAttribute() + { + // Assert + typeof(IgnoreAttribute).BaseType.ShouldBe(typeof(NonAspectAttribute)); + } + + // ═══════════════════════════════════════════════════════════ + // InterceptorBase + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:InterceptorBase 应是抽象类,防止被直接实例化。 + /// + [Fact] + public void InterceptorBase_ShouldBeAbstractClass() + { + // Assert + typeof(InterceptorBase).IsAbstract.ShouldBeTrue(); + typeof(InterceptorBase).IsClass.ShouldBeTrue(); + } + + /// + /// 测试目的:InterceptorBase 应继承自 AbstractInterceptorAttribute, + /// 确保所有业务拦截器均走 AspectCore 标准方法拦截流程。 + /// + [Fact] + public void InterceptorBase_ShouldInheritFromAbstractInterceptorAttribute() + { + // Assert + typeof(AbstractInterceptorAttribute) + .IsAssignableFrom(typeof(InterceptorBase)) + .ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // ParameterInterceptorBase + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ParameterInterceptorBase 应是抽象类,防止被直接实例化。 + /// + [Fact] + public void ParameterInterceptorBase_ShouldBeAbstractClass() + { + // Assert + typeof(ParameterInterceptorBase).IsAbstract.ShouldBeTrue(); + typeof(ParameterInterceptorBase).IsClass.ShouldBeTrue(); + } + + /// + /// 测试目的:ParameterInterceptorBase 应继承自 ParameterInterceptorAttribute, + /// 确保参数拦截器基类约束与 AspectCore 参数拦截机制对齐。 + /// + [Fact] + public void ParameterInterceptorBase_ShouldInheritFromParameterInterceptorAttribute() + { + // Assert + typeof(ParameterInterceptorAttribute) + .IsAssignableFrom(typeof(ParameterInterceptorBase)) + .ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // IAopProxy + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:IAopProxy 应是空标记接口(无成员), + /// 仅用于在运行时识别由 AOP 代理包装的服务实例。 + /// + [Fact] + public void IAopProxy_ShouldBeEmptyMarkerInterface() + { + // Assert + typeof(IAopProxy).IsInterface.ShouldBeTrue(); + typeof(IAopProxy).GetMembers().Length.ShouldBe(0); + } +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Bing.Aop.AspectCore.Tests.csproj b/framework/tests/Bing.Aop.AspectCore.Tests/Bing.Aop.AspectCore.Tests.csproj index b5565015..6792a0b3 100644 --- a/framework/tests/Bing.Aop.AspectCore.Tests/Bing.Aop.AspectCore.Tests.csproj +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Bing.Aop.AspectCore.Tests.csproj @@ -1,5 +1,10 @@  + + + net8.0;net6.0; + + diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/NotEmptyExtendedTest.cs b/framework/tests/Bing.Aop.AspectCore.Tests/NotEmptyExtendedTest.cs new file mode 100644 index 00000000..e2c81633 --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/NotEmptyExtendedTest.cs @@ -0,0 +1,81 @@ +using Bing.Aop.AspectCore.Samples; + +namespace Bing.Aop.AspectCore; + +/// +/// 拦截器扩展边界测试 +/// +public class NotEmptyExtendedTest +{ + private readonly ITestService _service; + + /// + /// 测试初始化 + /// + public NotEmptyExtendedTest(ITestService service) + { + _service = service; + } + + // ── null 输入 ────────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时,[NotEmpty] 应抛出 ArgumentNullException, + /// 因为 IsNullOrWhiteSpace(null.SafeString()) → IsNullOrWhiteSpace("") → true。 + /// + [Fact] + public void NotEmpty_WhenNullInput_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service.GetNotEmptyValue(null)); + } + + // ── 空白字符串输入 ───────────────────────────────────────────── + + /// + /// 测试目的:传入仅含空格的字符串时,[NotEmpty] 应抛出 ArgumentNullException + /// (IsNullOrWhiteSpace = true)。 + /// + [Fact] + public void NotEmpty_WhenWhitespaceOnly_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service.GetNotEmptyValue(" ")); + } + + /// + /// 测试目的:传入制表符字符串时,[NotEmpty] 应抛出 ArgumentNullException。 + /// + [Fact] + public void NotEmpty_WhenTabOnly_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service.GetNotEmptyValue("\t")); + } + + /// + /// 测试目的:传入换行符字符串时,[NotEmpty] 应抛出 ArgumentNullException。 + /// + [Fact] + public void NotEmpty_WhenNewlineOnly_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service.GetNotEmptyValue("\n")); + } + + // ── 有效值通过 ──────────────────────────────────────────────── + + /// + /// 测试目的:传入包含前导/尾部空格的有效字符串时(非空白), + /// [NotEmpty] 不应抛出,应返回原始值。 + /// + [Fact] + public void NotEmpty_WhenStringWithContent_ShouldReturnValue() + { + // Arrange & Act + var result = _service.GetNotEmptyValue(" hello "); + + // Assert + result.ShouldBe(" hello "); + } +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/NotNullObjectTest.cs b/framework/tests/Bing.Aop.AspectCore.Tests/NotNullObjectTest.cs new file mode 100644 index 00000000..1388b33f --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/NotNullObjectTest.cs @@ -0,0 +1,97 @@ +using Bing.Aop.AspectCore.Samples; + +namespace Bing.Aop.AspectCore; + +/// +/// 拦截器 object 类型与多参数扩展测试 +/// +public class NotNullObjectTest +{ + private readonly ITestService2 _service2; + + /// + /// 测试初始化 + /// + public NotNullObjectTest(ITestService2 service2) + { + _service2 = service2; + } + + // ── object 类型参数 ──────────────────────────────────────────── + + /// + /// 测试目的:向 object 类型参数传入 null 时,[NotNull] 应抛出 ArgumentNullException。 + /// + [Fact] + public void NotNull_WhenObjectParamIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service2.GetNotNullObject(null)); + } + + /// + /// 测试目的:向 object 类型参数传入非 null 值时,应返回该值,不抛异常。 + /// + [Fact] + public void NotNull_WhenObjectParamHasValue_ShouldReturnValue() + { + // Arrange + var obj = new { Name = "测试对象" }; + + // Act + var result = _service2.GetNotNullObject(obj); + + // Assert + result.ShouldBe(obj); + } + + /// + /// 测试目的:向 object 类型参数传入整数装箱值时,应正常通过不抛异常。 + /// + [Fact] + public void NotNull_WhenObjectParamIsBoxedInt_ShouldReturnValue() + { + // Arrange & Act + var result = _service2.GetNotNullObject(42); + + // Assert + result.ShouldBe(42); + } + + // ── 多参数拦截 ──────────────────────────────────────────────── + + /// + /// 测试目的:第一个参数为空时,多参数方法应抛出 ArgumentNullException + /// (参数 A 先于参数 B 被拦截)。 + /// + [Fact] + public void NotEmpty_WhenFirstParamEmpty_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service2.GetBothNotEmpty("", "valid")); + } + + /// + /// 测试目的:第二个参数为空时,多参数方法应抛出 ArgumentNullException + /// (参数 B 的拦截在 A 通过后触发)。 + /// + [Fact] + public void NotEmpty_WhenSecondParamEmpty_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _service2.GetBothNotEmpty("valid", "")); + } + + /// + /// 测试目的:两个参数均有效时,方法应正常返回拼接结果,不抛异常。 + /// + [Fact] + public void NotEmpty_WhenBothParamsValid_ShouldReturnConcatenatedResult() + { + // Act + var result = _service2.GetBothNotEmpty("Hello", "World"); + + // Assert + result.ShouldBe("Hello,World"); + } +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ITestService2.cs b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ITestService2.cs new file mode 100644 index 00000000..df629135 --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ITestService2.cs @@ -0,0 +1,23 @@ +using Bing.Aspects; +using Bing.DependencyInjection; + +namespace Bing.Aop.AspectCore.Samples; + +/// +/// 扩展测试服务(覆盖 object 类型参数与多参数场景) +/// +public interface ITestService2 : ISingletonDependency +{ + /// + /// 获取 object,值不能为 null + /// + /// 参数 + object GetNotNullObject([NotNull] object value); + + /// + /// 获取值,两个参数均不能为空 + /// + /// 参数A + /// 参数B + string GetBothNotEmpty([NotEmpty] string a, [NotEmpty] string b); +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Samples/IValidTestService.cs b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/IValidTestService.cs new file mode 100644 index 00000000..24a77c0f --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/IValidTestService.cs @@ -0,0 +1,16 @@ +using Bing.DependencyInjection; +using Bing.Validation; + +namespace Bing.Aop.AspectCore.Samples; + +/// +/// 用于验证 拦截行为的测试服务接口。 +/// 参数标注 [Valid] 后,AOP 管道会在方法调用前触发参数的 IVerifyModel.Validate()。 +/// +public interface IValidTestService : ISingletonDependency +{ + /// + /// 接受任意对象参数;若参数实现 IVerifyModel,则 AOP 会自动调用 Validate() + /// + string ProcessObject([Valid] object input); +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Samples/TestService2.cs b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/TestService2.cs new file mode 100644 index 00000000..ed2de08d --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/TestService2.cs @@ -0,0 +1,17 @@ +namespace Bing.Aop.AspectCore.Samples; + +/// +/// 扩展测试服务实现 +/// +public class TestService2 : ITestService2 +{ + /// + /// 获取 object,值不能为 null + /// + public object GetNotNullObject(object value) => value; + + /// + /// 获取值,两个参数均不能为空 + /// + public string GetBothNotEmpty(string a, string b) => $"{a},{b}"; +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestModels.cs b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestModels.cs new file mode 100644 index 00000000..38805d16 --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestModels.cs @@ -0,0 +1,37 @@ +using Bing.Validation; + +namespace Bing.Aop.AspectCore.Samples; + +/// +/// 验证时直接抛出 InvalidOperationException 的测试模型, +/// 用于验证 的异常传播行为。 +/// +public class ThrowingValidModel : IVerifyModel +{ + /// + /// 始终抛出异常,模拟验证失败场景 + /// + public IValidationResult Validate() => + throw new InvalidOperationException("ThrowingValidModel: validation failed by design"); +} + +/// +/// 记录 Validate() 是否被调用的测试模型, +/// 用于验证 是否正确触发验证。 +/// +public class TrackingValidModel : IVerifyModel +{ + /// + /// 是否已被调用过 Validate() + /// + public bool WasValidated { get; private set; } + + /// + /// 标记已验证并返回 null(结果被 ValidAttribute 丢弃,null 安全) + /// + public IValidationResult Validate() + { + WasValidated = true; + return null!; + } +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestService.cs b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestService.cs new file mode 100644 index 00000000..8fdf9dbf --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/Samples/ValidTestService.cs @@ -0,0 +1,11 @@ +namespace Bing.Aop.AspectCore.Samples; + +/// +/// 的默认实现, +/// 方法本体只原样返回字符串,验证逻辑完全由 AOP 拦截器处理。 +/// +public class ValidTestService : IValidTestService +{ + /// + public string ProcessObject(object input) => "processed"; +} diff --git a/framework/tests/Bing.Aop.AspectCore.Tests/ValidAttributeTest.cs b/framework/tests/Bing.Aop.AspectCore.Tests/ValidAttributeTest.cs new file mode 100644 index 00000000..434c8e79 --- /dev/null +++ b/framework/tests/Bing.Aop.AspectCore.Tests/ValidAttributeTest.cs @@ -0,0 +1,76 @@ +using Bing.Aop.AspectCore.Samples; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Aop.AspectCore; + +/// +/// 行为测试。 +/// 依赖 Startup.cs 中配置的 AspectCore DI 管道(EnableParameterAspect)。 +/// +public class ValidAttributeTest +{ + private readonly IValidTestService _service; + + /// + /// 通过 Xunit.DependencyInjection 注入经 AOP 代理的服务 + /// + public ValidAttributeTest(IValidTestService service) + { + _service = service; + } + + /// + /// 测试目的:当参数实现 IVerifyModel 且 Validate() 内部抛出异常时, + /// AOP 管道应将异常向上传播,不吞掉。 + /// + [Fact] + public void ProcessObject_WhenParameterValidateThrows_ShouldPropagateException() + { + // Arrange + var model = new ThrowingValidModel(); + + // Act & Assert + Should.Throw(() => _service.ProcessObject(model)); + } + + /// + /// 测试目的:当参数不实现 IVerifyModel 时(如普通字符串), + /// AOP 管道应直接透传,不抛异常。 + /// + [Fact] + public void ProcessObject_WhenParameterIsNotIVerifyModel_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _service.ProcessObject("just a plain string")); + } + + /// + /// 测试目的:当参数实现 IVerifyModel 且验证通过时, + /// AOP 管道应调用 Validate(),TrackingValidModel 的 WasValidated 应为 true。 + /// + [Fact] + public void ProcessObject_WhenParameterIsValidIVerifyModel_ShouldCallValidate() + { + // Arrange + var model = new TrackingValidModel(); + + // Act + _service.ProcessObject(model); + + // Assert + model.WasValidated.ShouldBeTrue(); + } + + /// + /// 测试目的:ValidAttribute 应继承自 ParameterInterceptorBase, + /// 确保类型层次结构正确,AOP 框架能识别并调用。 + /// + [Fact] + public void ValidAttribute_ShouldInheritFromParameterInterceptorBase() + { + // Assert + typeof(ValidAttribute).BaseType.ShouldBe(typeof(Bing.Aspects.ParameterInterceptorBase)); + } +} diff --git a/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionDescriptorExtensionsTest.cs b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionDescriptorExtensionsTest.cs new file mode 100644 index 00000000..cdb18306 --- /dev/null +++ b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionDescriptorExtensionsTest.cs @@ -0,0 +1,294 @@ +using Bing; +using Bing.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Shouldly; +using Xunit; + +namespace Bing.AspNetCore.Mvc; + +/// +/// 单元测试 +/// 覆盖所有扩展方法:IsControllerAction/IsPageAction/AsControllerActionDescriptor/ +/// AsPageAction/GetMethodInfo/GetReturnType/HasObjectResult +/// +public class ActionDescriptorExtensionsTest +{ + // ── 辅助方法:创建 ControllerActionDescriptor ──────────────── + + private static ControllerActionDescriptor CreateControllerDescriptor( + string methodName = nameof(FakeController.ReturnsString)) + { + return new ControllerActionDescriptor + { + MethodInfo = typeof(FakeController).GetMethod(methodName)! + }; + } + + // ── IsControllerAction ──────────────────────────────────────── + + /// + /// 测试目的:ControllerActionDescriptor 应被识别为控制器操作,返回 true。 + /// + [Fact] + public void IsControllerAction_WithControllerDescriptor_ShouldReturnTrue() + { + // Arrange + var descriptor = CreateControllerDescriptor(); + + // Act & Assert + descriptor.IsControllerAction().ShouldBeTrue(); + } + + /// + /// 测试目的:PageActionDescriptor 不是控制器操作,IsControllerAction 应返回 false。 + /// + [Fact] + public void IsControllerAction_WithPageDescriptor_ShouldReturnFalse() + { + // Arrange + var descriptor = new PageActionDescriptor(); + + // Act & Assert + descriptor.IsControllerAction().ShouldBeFalse(); + } + + /// + /// 测试目的:普通 ActionDescriptor 基类实例不是控制器操作,应返回 false。 + /// + [Fact] + public void IsControllerAction_WithBaseDescriptor_ShouldReturnFalse() + { + // Arrange + var descriptor = new ActionDescriptor(); + + // Act & Assert + descriptor.IsControllerAction().ShouldBeFalse(); + } + + // ── IsPageAction ────────────────────────────────────────────── + + /// + /// 测试目的:PageActionDescriptor 应被识别为 Razor Page 操作,返回 true。 + /// + [Fact] + public void IsPageAction_WithPageDescriptor_ShouldReturnTrue() + { + // Arrange + var descriptor = new PageActionDescriptor(); + + // Act & Assert + descriptor.IsPageAction().ShouldBeTrue(); + } + + /// + /// 测试目的:ControllerActionDescriptor 不是 Razor Page 操作,IsPageAction 应返回 false。 + /// + [Fact] + public void IsPageAction_WithControllerDescriptor_ShouldReturnFalse() + { + // Arrange + var descriptor = CreateControllerDescriptor(); + + // Act & Assert + descriptor.IsPageAction().ShouldBeFalse(); + } + + // ── AsControllerActionDescriptor ────────────────────────────── + + /// + /// 测试目的:对 ControllerActionDescriptor 调用 AsControllerActionDescriptor 应成功, + /// 返回同一引用。 + /// + [Fact] + public void AsControllerActionDescriptor_WithControllerDescriptor_ShouldReturnSameInstance() + { + // Arrange + var descriptor = CreateControllerDescriptor(); + + // Act + var result = descriptor.AsControllerActionDescriptor(); + + // Assert + result.ShouldNotBeNull(); + ReferenceEquals(result, descriptor).ShouldBeTrue(); + } + + /// + /// 测试目的:对 PageActionDescriptor 调用 AsControllerActionDescriptor 应抛出 BingFrameworkException。 + /// + [Fact] + public void AsControllerActionDescriptor_WithPageDescriptor_ShouldThrow() + { + // Arrange + var descriptor = new PageActionDescriptor(); + + // Act & Assert + Should.Throw(() => descriptor.AsControllerActionDescriptor()); + } + + /// + /// 测试目的:对普通 ActionDescriptor 调用 AsControllerActionDescriptor 应抛出 BingFrameworkException。 + /// + [Fact] + public void AsControllerActionDescriptor_WithBaseDescriptor_ShouldThrow() + { + // Arrange + var descriptor = new ActionDescriptor(); + + // Act & Assert + Should.Throw(() => descriptor.AsControllerActionDescriptor()); + } + + // ── AsPageAction ────────────────────────────────────────────── + + /// + /// 测试目的:对 PageActionDescriptor 调用 AsPageAction 应成功,返回同一引用。 + /// + [Fact] + public void AsPageAction_WithPageDescriptor_ShouldReturnSameInstance() + { + // Arrange + var descriptor = new PageActionDescriptor(); + + // Act + var result = descriptor.AsPageAction(); + + // Assert + result.ShouldNotBeNull(); + ReferenceEquals(result, descriptor).ShouldBeTrue(); + } + + /// + /// 测试目的:对 ControllerActionDescriptor 调用 AsPageAction 应抛出 BingFrameworkException。 + /// + [Fact] + public void AsPageAction_WithControllerDescriptor_ShouldThrow() + { + // Arrange + var descriptor = CreateControllerDescriptor(); + + // Act & Assert + Should.Throw(() => descriptor.AsPageAction()); + } + + // ── GetMethodInfo ───────────────────────────────────────────── + + /// + /// 测试目的:GetMethodInfo 应返回 ControllerActionDescriptor.MethodInfo,与直接访问一致。 + /// + [Fact] + public void GetMethodInfo_ShouldReturnMethodInfoFromDescriptor() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsString)); + + // Act + var methodInfo = descriptor.GetMethodInfo(); + + // Assert + methodInfo.ShouldNotBeNull(); + methodInfo.Name.ShouldBe(nameof(FakeController.ReturnsString)); + } + + // ── GetReturnType ───────────────────────────────────────────── + + /// + /// 测试目的:返回 string 类型的方法,GetReturnType 应返回 typeof(string)。 + /// + [Fact] + public void GetReturnType_ForStringMethod_ShouldReturnStringType() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsString)); + + // Act + var returnType = descriptor.GetReturnType(); + + // Assert + returnType.ShouldBe(typeof(string)); + } + + /// + /// 测试目的:返回 IActionResult 类型的方法,GetReturnType 应返回 typeof(IActionResult)。 + /// + [Fact] + public void GetReturnType_ForIActionResultMethod_ShouldReturnIActionResultType() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsIActionResult)); + + // Act + var returnType = descriptor.GetReturnType(); + + // Assert + returnType.ShouldBe(typeof(IActionResult)); + } + + // ── HasObjectResult ─────────────────────────────────────────── + + /// + /// 测试目的:返回 JsonResult 的方法,HasObjectResult 应返回 true。 + /// + [Fact] + public void HasObjectResult_ForJsonResultMethod_ShouldReturnTrue() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsJsonResult)); + + // Act & Assert + descriptor.HasObjectResult().ShouldBeTrue(); + } + + /// + /// 测试目的:返回 ObjectResult 的方法,HasObjectResult 应返回 true。 + /// + [Fact] + public void HasObjectResult_ForObjectResultMethod_ShouldReturnTrue() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsObjectResult)); + + // Act & Assert + descriptor.HasObjectResult().ShouldBeTrue(); + } + + /// + /// 测试目的:返回 string(非 ActionResult)的方法,HasObjectResult 应返回 false。 + /// + [Fact] + public void HasObjectResult_ForStringMethod_ShouldReturnFalse() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsString)); + + // Act & Assert + descriptor.HasObjectResult().ShouldBeFalse(); + } + + /// + /// 测试目的:返回 void 的方法,HasObjectResult 应返回 false。 + /// + [Fact] + public void HasObjectResult_ForVoidMethod_ShouldReturnFalse() + { + // Arrange + var descriptor = CreateControllerDescriptor(nameof(FakeController.ReturnsVoid)); + + // Act & Assert + descriptor.HasObjectResult().ShouldBeFalse(); + } +} + +// ── 测试用辅助 Controller ───────────────────────────────────────────────── + +internal class FakeController +{ + public string ReturnsString() => string.Empty; + public void ReturnsVoid() { } + public IActionResult ReturnsIActionResult() => null!; + public JsonResult ReturnsJsonResult() => null!; + public ObjectResult ReturnsObjectResult() => null!; +} diff --git a/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionResultHelperTest.cs b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionResultHelperTest.cs new file mode 100644 index 00000000..7b17a5b7 --- /dev/null +++ b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/ActionResultHelperTest.cs @@ -0,0 +1,151 @@ +using Bing.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Shouldly; +using Xunit; + +namespace Bing.AspNetCore.Mvc; + +/// +/// 单元测试 +/// +public class ActionResultHelperTest +{ + // ═══════════════════════════════════════════════════════════ + // ObjectResultTypes 初始值 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:静态初始化后 ObjectResultTypes 应包含三个预定义类型, + /// 确保框架内置的对象结果类型映射不被意外修改。 + /// + [Fact] + public void ObjectResultTypes_ShouldContainThreeDefaultTypes() + { + // Assert + ActionResultHelper.ObjectResultTypes.ShouldNotBeNull(); + ActionResultHelper.ObjectResultTypes.ShouldContain(typeof(JsonResult)); + ActionResultHelper.ObjectResultTypes.ShouldContain(typeof(ObjectResult)); + ActionResultHelper.ObjectResultTypes.ShouldContain(typeof(NoContentResult)); + } + + // ═══════════════════════════════════════════════════════════ + // IsObjectResult — 非 IActionResult 返回类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:string 类型不是 IActionResult,IsObjectResult 应返回 true, + /// 确保普通 POCO 返回类型被识别为对象结果(需要序列化输出)。 + /// + [Fact] + public void IsObjectResult_StringReturnType_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(string)).ShouldBeTrue(); + } + + /// + /// 测试目的:int 类型不是 IActionResult,IsObjectResult 应返回 true。 + /// + [Fact] + public void IsObjectResult_IntReturnType_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(int)).ShouldBeTrue(); + } + + /// + /// 测试目的:Task<string> 应被解包为 string,仍返回 true, + /// 确保异步方法返回类型可正常处理。 + /// + [Fact] + public void IsObjectResult_TaskOfString_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(Task)).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // IsObjectResult — IActionResult 子类 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:JsonResult(ObjectResultTypes 成员)应返回 true, + /// 确保框架认为 JsonResult 是"对象结果"类型。 + /// + [Fact] + public void IsObjectResult_JsonResult_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(JsonResult)).ShouldBeTrue(); + } + + /// + /// 测试目的:ObjectResult(ObjectResultTypes 成员)应返回 true。 + /// + [Fact] + public void IsObjectResult_ObjectResult_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(ObjectResult)).ShouldBeTrue(); + } + + /// + /// 测试目的:NoContentResult(ObjectResultTypes 成员)应返回 true。 + /// + [Fact] + public void IsObjectResult_NoContentResult_ShouldBeTrue() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(NoContentResult)).ShouldBeTrue(); + } + + /// + /// 测试目的:ContentResult 不在 ObjectResultTypes 中,应返回 false, + /// 确保非对象结果类型被正确排除。 + /// + [Fact] + public void IsObjectResult_ContentResult_ShouldBeFalse() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(ContentResult)).ShouldBeFalse(); + } + + /// + /// 测试目的:RedirectResult 不在 ObjectResultTypes 中,应返回 false。 + /// + [Fact] + public void IsObjectResult_RedirectResult_ShouldBeFalse() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(RedirectResult)).ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // IsObjectResult — excludeTypes 参数 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入 excludeTypes 包含 string 时,string 应被排除并返回 false, + /// 确保 excludeTypes 参数能正确覆盖默认对象结果判断。 + /// + [Fact] + public void IsObjectResult_WithExcludeType_ShouldReturnFalse() + { + // Act & Assert + ActionResultHelper.IsObjectResult(typeof(string), typeof(string)).ShouldBeFalse(); + } + + /// + /// 测试目的:excludeTypes 为基类时,子类也应被排除, + /// 确保 excludeTypes 使用 IsAssignableFrom 语义。 + /// + [Fact] + public void IsObjectResult_ExcludeBaseType_SubClassAlsoExcluded() + { + // Act — 排除 IActionResult,则 ObjectResult 也应被排除 + var result = ActionResultHelper.IsObjectResult(typeof(ObjectResult), typeof(IActionResult)); + + // Assert + result.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/Validation/ModelStateValidatorTest.cs b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/Validation/ModelStateValidatorTest.cs new file mode 100644 index 00000000..e3b59a7e --- /dev/null +++ b/framework/tests/Bing.AspNetCore.Mvc.Tests/Bing/AspNetCore/Mvc/Validation/ModelStateValidatorTest.cs @@ -0,0 +1,136 @@ +using System.ComponentModel.DataAnnotations; +using Bing.AspNetCore.Mvc.Validation; +using Bing.Validation; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.AspNetCore.Mvc.Validation; + +/// +/// 单元测试 +/// +public class ModelStateValidatorTest +{ + private readonly ModelStateValidator _validator = new(); + + // ═══════════════════════════════════════════════════════════ + // AddErrors + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ModelState 有效时,AddErrors 不应向 validationResult 添加任何错误, + /// 确保有效请求不会被意外标记为失败。 + /// + [Fact] + public void AddErrors_WhenModelStateIsValid_ShouldAddNoErrors() + { + // Arrange + var modelState = new ModelStateDictionary(); + var result = new ValidationResultCollection(); + + // Act + _validator.AddErrors(result, modelState); + + // Assert + result.IsValid.ShouldBeTrue(); + result.Count.ShouldBe(0); + } + + /// + /// 测试目的:ModelState 包含一个错误时,AddErrors 应将该错误添加到 validationResult, + /// 确保字段级验证错误可以被正确收集。 + /// + [Fact] + public void AddErrors_WhenModelStateHasOneError_ShouldAddOneError() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("Name", "Name is required"); + var result = new ValidationResultCollection(); + + // Act + _validator.AddErrors(result, modelState); + + // Assert + result.Count.ShouldBe(1); + result.IsValid.ShouldBeFalse(); + result.First().ErrorMessage.ShouldBe("Name is required"); + result.First().MemberNames.ShouldContain("Name"); + } + + /// + /// 测试目的:ModelState 包含多个字段错误时,AddErrors 应全部收集, + /// 确保批量验证场景下所有错误不丢失。 + /// + [Fact] + public void AddErrors_WhenModelStateHasMultipleErrors_ShouldAddAll() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("Name", "Name required"); + modelState.AddModelError("Email", "Email invalid"); + var result = new ValidationResultCollection(); + + // Act + _validator.AddErrors(result, modelState); + + // Assert + result.Count.ShouldBe(2); + } + + /// + /// 测试目的:同一字段有多个错误时,AddErrors 应逐条收集, + /// 确保单字段多验证规则场景下所有错误均被记录。 + /// + [Fact] + public void AddErrors_WhenSameFieldHasMultipleErrors_ShouldAddEachError() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("Name", "Name is required"); + modelState.AddModelError("Name", "Name must be at least 2 characters"); + var result = new ValidationResultCollection(); + + // Act + _validator.AddErrors(result, modelState); + + // Assert + result.Count.ShouldBe(2); + result.All(r => r.MemberNames.Contains("Name")).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // Validate + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ModelState 有效时,Validate 应不抛异常, + /// 确保正常请求不受验证器影响。 + /// + [Fact] + public void Validate_WhenModelStateIsValid_ShouldNotThrow() + { + // Arrange + var modelState = new ModelStateDictionary(); + + // Act & Assert + Should.NotThrow(() => _validator.Validate(modelState)); + } + + /// + /// 测试目的:ModelState 包含错误时,Validate 当前实现不抛异常(早返回), + /// 确保调用方不会因 Validate 本身崩溃。 + /// + [Fact] + public void Validate_WhenModelStateHasErrors_ShouldNotThrow() + { + // Arrange + var modelState = new ModelStateDictionary(); + modelState.AddModelError("Field", "Error message"); + + // Act & Assert(当前实现在 IsValid 为 false 时直接 return,不抛异常) + Should.NotThrow(() => _validator.Validate(modelState)); + } +} diff --git a/framework/tests/Bing.Auditing.Tests/AuditInitializersTest.cs b/framework/tests/Bing.Auditing.Tests/AuditInitializersTest.cs new file mode 100644 index 00000000..97bfa958 --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/AuditInitializersTest.cs @@ -0,0 +1,382 @@ +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +// ========================================================================= +// CreationAuditedInitializer Tests +// ========================================================================= +// 注意:辅助实体类(GuidCreationEntity / NullableGuidCreationEntity 等) +// 已在 AuditPropertySetterTest.cs 中声明(同项目/同命名空间),此处直接复用。 +// ========================================================================= + +/// +/// 测试目的:验证 CreationAuditedInitializer.Init 的各属性初始化行为。 +/// +public class CreationAuditedInitializerTest +{ + private static readonly string UserId = Guid.NewGuid().ToString(); + private static readonly string UserName = "Alice"; + private static readonly DateTime FixedTime = new(2025, 6, 1, 12, 0, 0); + + // ----------------------------------------------------------------- + // null 实体不抛异常 + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体为 null 时,Init 应直接返回,不抛异常。 + /// + [Fact] + public void Init_NullEntity_ShouldNotThrow() + { + Should.NotThrow(() => CreationAuditedInitializer.Init(null, UserId, UserName)); + } + + // ----------------------------------------------------------------- + // IHasCreationTime + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 IHasCreationTime 时,Init 应将 CreationTime 设置为 DateTime.Now 附近。 + /// + [Fact] + public void Init_EntityWithCreationTime_ShouldSetCreationTimeToNow() + { + var entity = new CreationOnlyEntity(); + var before = DateTime.Now; + CreationAuditedInitializer.Init(entity, UserId, UserName); + var after = DateTime.Now; + + entity.CreationTime.ShouldNotBeNull(); + entity.CreationTime.Value.ShouldBeInRange(before, after); + } + + /// + /// 测试目的:传入 dateTime 参数时,Init 应使用指定时间,而非 DateTime.Now。 + /// + [Fact] + public void Init_WithDateTime_ShouldUseProvidedTime() + { + var entity = new CreationOnlyEntity(); + CreationAuditedInitializer.Init(entity, UserId, UserName, FixedTime); + + entity.CreationTime.ShouldBe(FixedTime); + } + + // ----------------------------------------------------------------- + // IHasCreator + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 IHasCreator 时,Init 应设置 Creator 为传入的 userName。 + /// + [Fact] + public void Init_EntityWithCreator_ShouldSetCreatorName() + { + var entity = new NamedCreationEntity(); + CreationAuditedInitializer.Init(entity, UserId, "Bob"); + + entity.Creator.ShouldBe("Bob"); + } + + /// + /// 测试目的:userName 为 null/空时,Creator 不应被赋值(保持 null)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Init_EmptyUserName_ShouldNotSetCreator(string userName) + { + var entity = new NamedCreationEntity(); + CreationAuditedInitializer.Init(entity, UserId, userName); + + entity.Creator.ShouldBeNull(); + } + + // ----------------------------------------------------------------- + // ICreationAuditedObject + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 ICreationAuditedObject<Guid> 时,Init 应将 CreatorId 解析为 Guid。 + /// + [Fact] + public void Init_GuidCreationEntity_ShouldSetCreatorId() + { + var guidUserId = Guid.NewGuid(); + var entity = new GuidCreationEntity(); + CreationAuditedInitializer.Init(entity, guidUserId.ToString(), UserName); + + entity.CreatorId.ShouldBe(guidUserId); + } + + // ----------------------------------------------------------------- + // ICreationAuditedObject + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 ICreationAuditedObject<Guid?> 时,可解析 Guid? CreatorId。 + /// + [Fact] + public void Init_NullableGuidCreationEntity_ShouldSetCreatorId() + { + var guidUserId = Guid.NewGuid(); + var entity = new NullableGuidCreationEntity(); + CreationAuditedInitializer.Init(entity, guidUserId.ToString(), UserName); + + entity.CreatorId.ShouldNotBeNull(); + entity.CreatorId.Value.ShouldBe(guidUserId); + } + + /// + /// 测试目的:userId 为 null 时,NullableGuid CreatorId 不应被赋值。 + /// + [Fact] + public void Init_NullUserId_ShouldNotSetCreatorId() + { + var entity = new NullableGuidCreationEntity(); + CreationAuditedInitializer.Init(entity, null, UserName); + + entity.CreatorId.ShouldBeNull(); + } + + // ----------------------------------------------------------------- + // ICreationAuditedObject + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 ICreationAuditedObject<string> 时,CreatorId 应为传入字符串。 + /// + [Fact] + public void Init_StringCreationEntity_ShouldSetCreatorId() + { + var entity = new StringCreationEntity(); + CreationAuditedInitializer.Init(entity, "str-user-001", UserName); + + entity.CreatorId.ShouldBe("str-user-001"); + } + + // ----------------------------------------------------------------- + // ICreationAuditedObject + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 ICreationAuditedObject<int> 时,CreatorId 应被解析为 int。 + /// + [Fact] + public void Init_IntCreationEntity_ShouldSetCreatorId() + { + var entity = new IntCreationEntity(); + CreationAuditedInitializer.Init(entity, "42", UserName); + + entity.CreatorId.ShouldBe(42); + } + + // ----------------------------------------------------------------- + // ICreationAuditedObject + // ----------------------------------------------------------------- + + /// + /// 测试目的:实体实现 ICreationAuditedObject<long> 时,CreatorId 应被解析为 long。 + /// + [Fact] + public void Init_LongCreationEntity_ShouldSetCreatorId() + { + var entity = new LongCreationEntity(); + CreationAuditedInitializer.Init(entity, "9999999999", UserName); + + entity.CreatorId.ShouldBe(9999999999L); + } +} + +// ========================================================================= +// DeletionAuditedInitializer Tests +// ========================================================================= + +/// +/// 测试目的:验证 DeletionAuditedInitializer.Init 的各属性初始化行为。 +/// +public class DeletionAuditedInitializerTest +{ + private static readonly string UserId = Guid.NewGuid().ToString(); + private static readonly string UserName = "Admin"; + + /// + /// 测试目的:实体为 null 时不抛异常。 + /// + [Fact] + public void Init_NullEntity_ShouldNotThrow() + { + Should.NotThrow(() => DeletionAuditedInitializer.Init(null, UserId, UserName)); + } + + /// + /// 测试目的:实体实现 IDeletionAuditedObject 时,DeletionTime 应被设置为 DateTime.Now 附近。 + /// + [Fact] + public void Init_EntityWithDeletionTime_ShouldSetDeletionTimeToNow() + { + var entity = new DeletionEntity(); + var before = DateTime.Now; + DeletionAuditedInitializer.Init(entity, UserId, UserName); + var after = DateTime.Now; + + entity.DeletionTime.ShouldNotBeNull(); + entity.DeletionTime.Value.ShouldBeInRange(before, after); + } + + /// + /// 测试目的:实体实现 IHasDeleter 时,Deleter 应被设置为传入的 userName。 + /// + [Fact] + public void Init_EntityWithDeleter_ShouldSetDeleterName() + { + var entity = new NamedDeletionEntity(); + DeletionAuditedInitializer.Init(entity, UserId, "Carol"); + + entity.Deleter.ShouldBe("Carol"); + } + + /// + /// 测试目的:userName 为 null/空时,Deleter 不应被赋值。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void Init_EmptyUserName_ShouldNotSetDeleter(string userName) + { + var entity = new NamedDeletionEntity(); + DeletionAuditedInitializer.Init(entity, UserId, userName); + + entity.Deleter.ShouldBeNull(); + } + + /// + /// 测试目的:实体实现 IDeletionAuditedObject<Guid?> 时,DeleterId 应被解析并赋值。 + /// + [Fact] + public void Init_NullableGuidDeletionEntity_ShouldSetDeleterId() + { + var guidId = Guid.NewGuid(); + var entity = new DeletionEntity(); + DeletionAuditedInitializer.Init(entity, guidId.ToString(), UserName); + + entity.DeleterId.ShouldNotBeNull(); + entity.DeleterId.Value.ShouldBe(guidId); + } + + /// + /// 测试目的:userId 为 null 时,DeleterId 不应被赋值。 + /// + [Fact] + public void Init_NullUserId_ShouldNotSetDeleterId() + { + var entity = new DeletionEntity(); + DeletionAuditedInitializer.Init(entity, null, UserName); + + entity.DeleterId.ShouldBeNull(); + } +} + +// ========================================================================= +// ModificationAuditedInitializer Tests +// ========================================================================= + +/// +/// 测试目的:验证 ModificationAuditedInitializer.Init 的各属性初始化行为。 +/// +public class ModificationAuditedInitializerTest +{ + private static readonly string UserId = Guid.NewGuid().ToString(); + private static readonly string UserName = "Editor"; + private static readonly DateTime FixedTime = new(2025, 9, 1, 8, 0, 0); + + /// + /// 测试目的:实体为 null 时不抛异常。 + /// + [Fact] + public void Init_NullEntity_ShouldNotThrow() + { + Should.NotThrow(() => ModificationAuditedInitializer.Init(null, UserId, UserName)); + } + + /// + /// 测试目的:LastModificationTime 应被设置为 DateTime.Now 附近。 + /// + [Fact] + public void Init_EntityWithModificationTime_ShouldSetLastModificationTime() + { + var entity = new ModificationEntity(); + var before = DateTime.Now; + ModificationAuditedInitializer.Init(entity, UserId, UserName); + var after = DateTime.Now; + + entity.LastModificationTime.ShouldNotBeNull(); + entity.LastModificationTime.Value.ShouldBeInRange(before, after); + } + + /// + /// 测试目的:传入 dateTime 参数时,LastModificationTime 应使用指定时间。 + /// + [Fact] + public void Init_WithDateTime_ShouldUseProvidedTime() + { + var entity = new ModificationEntity(); + ModificationAuditedInitializer.Init(entity, UserId, UserName, FixedTime); + + entity.LastModificationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:LastModifier 应被设置为传入的 userName。 + /// + [Fact] + public void Init_EntityWithModifier_ShouldSetLastModifier() + { + var entity = new NamedModificationEntity(); + ModificationAuditedInitializer.Init(entity, UserId, "Dave"); + + entity.LastModifier.ShouldBe("Dave"); + } + + /// + /// 测试目的:userName 为 null/空时,LastModifier 不应被赋值。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void Init_EmptyUserName_ShouldNotSetLastModifier(string userName) + { + var entity = new NamedModificationEntity(); + ModificationAuditedInitializer.Init(entity, UserId, userName); + + entity.LastModifier.ShouldBeNull(); + } + + /// + /// 测试目的:实体实现 IModificationAuditedObject<Guid?> 时,LastModifierId 应被解析并赋值。 + /// + [Fact] + public void Init_NullableGuidModificationEntity_ShouldSetLastModifierId() + { + var guidId = Guid.NewGuid(); + var entity = new ModificationEntity(); + ModificationAuditedInitializer.Init(entity, guidId.ToString(), UserName); + + entity.LastModifierId.ShouldNotBeNull(); + entity.LastModifierId.Value.ShouldBe(guidId); + } + + /// + /// 测试目的:userId 为 null 时,LastModifierId 不应被赋值。 + /// + [Fact] + public void Init_NullUserId_ShouldNotSetLastModifierId() + { + var entity = new ModificationEntity(); + ModificationAuditedInitializer.Init(entity, null, UserName); + + entity.LastModifierId.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.Auditing.Tests/AuditLogModelsTest.cs b/framework/tests/Bing.Auditing.Tests/AuditLogModelsTest.cs new file mode 100644 index 00000000..3c9f2a6b --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/AuditLogModelsTest.cs @@ -0,0 +1,430 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +// ========================================================================= +// AuditLogActionInfo Tests +// ========================================================================= + +/// +/// 测试目的:验证 AuditLogActionInfo 属性读写行为。 +/// +public class AuditLogActionInfoTest +{ + /// + /// 测试目的:所有属性可正常读写,默认值均为 null/0。 + /// + [Fact] + public void Properties_DefaultValues_ShouldBeNullOrZero() + { + var info = new AuditLogActionInfo(); + info.ServiceName.ShouldBeNull(); + info.MethodName.ShouldBeNull(); + info.Parameters.ShouldBeNull(); + info.ExecutionTime.ShouldBe(default); + info.ExecutionDuration.ShouldBe(0); + } + + /// + /// 测试目的:为所有属性赋值后可正常读取,值不丢失。 + /// + [Fact] + public void Properties_SetValues_ShouldBeRetrievable() + { + var now = new DateTime(2025, 1, 1, 12, 0, 0); + var info = new AuditLogActionInfo + { + ServiceName = "UserAppService", + MethodName = "CreateAsync", + Parameters = "{ \"name\": \"Alice\" }", + ExecutionTime = now, + ExecutionDuration = 42 + }; + + info.ServiceName.ShouldBe("UserAppService"); + info.MethodName.ShouldBe("CreateAsync"); + info.Parameters.ShouldBe("{ \"name\": \"Alice\" }"); + info.ExecutionTime.ShouldBe(now); + info.ExecutionDuration.ShouldBe(42); + } +} + +// ========================================================================= +// AuditLogInfo Tests +// ========================================================================= + +/// +/// 测试目的:验证 AuditLogInfo 构造初始化、属性读写及 ToString 输出。 +/// +public class AuditLogInfoTest +{ + /// + /// 测试目的:构造后各集合字段均应被初始化为空列表,不为 null。 + /// + [Fact] + public void Constructor_ShouldInitializeAllCollections() + { + var info = new AuditLogInfo(); + info.Actions.ShouldNotBeNull(); + info.Actions.ShouldBeEmpty(); + info.Exceptions.ShouldNotBeNull(); + info.Exceptions.ShouldBeEmpty(); + info.EntityChanges.ShouldNotBeNull(); + info.EntityChanges.ShouldBeEmpty(); + info.Comments.ShouldNotBeNull(); + info.Comments.ShouldBeEmpty(); + } + + /// + /// 测试目的:属性赋值后可正常读取。 + /// + [Fact] + public void Properties_SetValues_ShouldBeRetrievable() + { + var info = new AuditLogInfo + { + ApplicationName = "MyApp", + UserId = "u-001", + UserName = "Alice", + TenantId = "t-001", + TenantName = "Tenant1", + HttpMethod = "POST", + HttpStatusCode = 200, + Url = "/api/users", + ClientIpAddress = "127.0.0.1", + CorrelationId = "corr-001", + ExecutionDuration = 150 + }; + + info.ApplicationName.ShouldBe("MyApp"); + info.UserId.ShouldBe("u-001"); + info.UserName.ShouldBe("Alice"); + info.TenantId.ShouldBe("t-001"); + info.TenantName.ShouldBe("Tenant1"); + info.HttpMethod.ShouldBe("POST"); + info.HttpStatusCode.ShouldBe(200); + info.Url.ShouldBe("/api/users"); + info.ClientIpAddress.ShouldBe("127.0.0.1"); + info.CorrelationId.ShouldBe("corr-001"); + info.ExecutionDuration.ShouldBe(150); + } + + /// + /// 测试目的:ToString() 在无操作/异常/实体变更时不抛异常,且包含基本字段。 + /// + [Fact] + public void ToString_WithBasicFields_ShouldNotThrowAndContainUrl() + { + var info = new AuditLogInfo + { + HttpMethod = "GET", + HttpStatusCode = 200, + Url = "/api/health", + UserName = "Alice", + UserId = "u-001", + ClientIpAddress = "192.168.1.1", + ExecutionDuration = 10 + }; + + var result = Should.NotThrow(() => info.ToString()); + result.ShouldContain("/api/health"); + result.ShouldContain("GET"); + result.ShouldContain("Alice"); + } + + /// + /// 测试目的:ToString() 包含 Actions 时应输出 ServiceName.MethodName 行。 + /// + [Fact] + public void ToString_WithActions_ShouldContainServiceAndMethodName() + { + var info = new AuditLogInfo + { + HttpMethod = "POST", + Url = "/api/orders" + }; + info.Actions.Add(new AuditLogActionInfo + { + ServiceName = "OrderService", + MethodName = "PlaceOrder", + Parameters = "{}", + ExecutionDuration = 20 + }); + + var result = info.ToString(); + result.ShouldContain("OrderService"); + result.ShouldContain("PlaceOrder"); + } + + /// + /// 测试目的:ToString() 包含 Exceptions 时应输出异常消息。 + /// + [Fact] + public void ToString_WithExceptions_ShouldContainExceptionMessage() + { + var info = new AuditLogInfo { HttpMethod = "DELETE", Url = "/api/users/1" }; + info.Exceptions.Add(new InvalidOperationException("Entity not found")); + + var result = info.ToString(); + result.ShouldContain("Entity not found"); + } + + /// + /// 测试目的:HttpStatusCode 为 null 时 ToString() 应输出 "---",不抛异常。 + /// + [Fact] + public void ToString_NullStatusCode_ShouldOutputPlaceholder() + { + var info = new AuditLogInfo { HttpMethod = "GET", Url = "/" }; + // HttpStatusCode defaults to null + var result = Should.NotThrow(() => info.ToString()); + result.ShouldContain("---"); + } +} + +// ========================================================================= +// EntityPropertyChangeInfo Tests +// ========================================================================= + +/// +/// 测试目的:验证 EntityPropertyChangeInfo 属性读写。 +/// +public class EntityPropertyChangeInfoTest +{ + /// + /// 测试目的:属性赋值后可正常读取,默认均为 null。 + /// + [Fact] + public void Properties_SetAndGet_ShouldWork() + { + var change = new EntityPropertyChangeInfo + { + PropertyName = "Name", + PropertyTypeFullName = "System.String", + OriginalValue = "OldName", + NewValue = "NewName" + }; + + change.PropertyName.ShouldBe("Name"); + change.PropertyTypeFullName.ShouldBe("System.String"); + change.OriginalValue.ShouldBe("OldName"); + change.NewValue.ShouldBe("NewName"); + } + + /// + /// 测试目的:默认构造时所有属性为 null。 + /// + [Fact] + public void Constructor_DefaultValues_ShouldBeNull() + { + var change = new EntityPropertyChangeInfo(); + change.PropertyName.ShouldBeNull(); + change.PropertyTypeFullName.ShouldBeNull(); + change.OriginalValue.ShouldBeNull(); + change.NewValue.ShouldBeNull(); + } +} + +// ========================================================================= +// EntityChangeInfo Tests +// ========================================================================= + +/// +/// 测试目的:验证 EntityChangeInfo 属性读写及 Merge 逻辑。 +/// +public class EntityChangeInfoTest +{ + private static EntityChangeInfo CreateWithChanges(params EntityPropertyChangeInfo[] props) + { + var e = new EntityChangeInfo + { + EntityId = "1", + EntityTypeFullName = "Bing.Order", + EntityTenantId = "t-001", + PropertyChanges = new List(props) + }; + return e; + } + + /// + /// 测试目的:属性赋值后可正常读取。 + /// + [Fact] + public void Properties_SetAndGet_ShouldWork() + { + var now = new DateTime(2025, 1, 1); + var e = new EntityChangeInfo + { + ChangeTime = now, + EntityId = "42", + EntityTypeFullName = "Bing.Domain.Order", + EntityTenantId = "tenant-001", + PropertyChanges = new List() + }; + + e.ChangeTime.ShouldBe(now); + e.EntityId.ShouldBe("42"); + e.EntityTypeFullName.ShouldBe("Bing.Domain.Order"); + e.EntityTenantId.ShouldBe("tenant-001"); + e.PropertyChanges.ShouldNotBeNull(); + } + + /// + /// 测试目的:Merge 合并另一个变更时,新增属性应被添加。 + /// + [Fact] + public void Merge_WithNewProperty_ShouldAddToPropertyChanges() + { + // Arrange + var target = CreateWithChanges( + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = "A", NewValue = "B" }); + var source = CreateWithChanges( + new EntityPropertyChangeInfo { PropertyName = "Age", OriginalValue = "20", NewValue = "21" }); + + // Act + target.Merge(source); + + // Assert + target.PropertyChanges.Count.ShouldBe(2); + target.PropertyChanges.ShouldContain(p => p.PropertyName == "Age"); + } + + /// + /// 测试目的:Merge 合并同名属性时,应更新 NewValue 而不增加条目。 + /// + [Fact] + public void Merge_WithExistingProperty_ShouldUpdateNewValue() + { + // Arrange + var target = CreateWithChanges( + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = "A", NewValue = "B" }); + var source = CreateWithChanges( + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = "B", NewValue = "C" }); + + // Act + target.Merge(source); + + // Assert + target.PropertyChanges.Count.ShouldBe(1); + target.PropertyChanges[0].NewValue.ShouldBe("C"); + target.PropertyChanges[0].OriginalValue.ShouldBe("A"); // 原始值不变 + } + + /// + /// 测试目的:Merge 空属性列表时不抛异常,原集合保持不变。 + /// + [Fact] + public void Merge_EmptySource_ShouldNotChangeTarget() + { + var target = CreateWithChanges( + new EntityPropertyChangeInfo { PropertyName = "Name", NewValue = "X" }); + var emptySource = new EntityChangeInfo { PropertyChanges = new List() }; + + target.Merge(emptySource); + + target.PropertyChanges.Count.ShouldBe(1); + } +} + +// ========================================================================= +// DisableAuditingAttribute Tests +// ========================================================================= + +/// +/// 测试目的:验证 DisableAuditingAttribute 特性的声明元数据正确性。 +/// +public class DisableAuditingAttributeTest +{ + /// + /// 测试目的:DisableAuditingAttribute 应继承自 Attribute。 + /// + [Fact] + public void DisableAuditingAttribute_ShouldBeAttribute() + { +#pragma warning disable CS0618 + var attr = new DisableAuditingAttribute(); +#pragma warning restore CS0618 + attr.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:DisableAuditingAttribute 应标记 [Obsolete]。 + /// + [Fact] + public void DisableAuditingAttribute_ShouldBeMarkedObsolete() + { + var obsolete = typeof(DisableAuditingAttribute) + .GetCustomAttributes(typeof(ObsoleteAttribute), false); + obsolete.ShouldNotBeEmpty(); + } + + /// + /// 测试目的:AttributeUsage 应允许 Class、Method、Property 三个目标。 + /// + [Fact] + public void DisableAuditingAttribute_AttributeUsage_ShouldAllowClassMethodProperty() + { + var usage = (AttributeUsageAttribute)typeof(DisableAuditingAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false)[0]; + + (usage.ValidOn & AttributeTargets.Class).ShouldBe(AttributeTargets.Class); + (usage.ValidOn & AttributeTargets.Method).ShouldBe(AttributeTargets.Method); + (usage.ValidOn & AttributeTargets.Property).ShouldBe(AttributeTargets.Property); + } +} + +// ========================================================================= +// SimpleLogAuditingStore Tests +// ========================================================================= + +/// +/// 测试目的:验证 SimpleLogAuditingStore 使用 NullLogger 且 SaveAsync 不抛异常。 +/// +public class SimpleLogAuditingStoreTest +{ + /// + /// 测试目的:默认构造后 Logger 应为 NullLogger 实例。 + /// + [Fact] + public void Constructor_ShouldUseNullLogger() + { + var store = new SimpleLogAuditingStore(); + store.Logger.ShouldNotBeNull(); + store.Logger.ShouldBeSameAs(NullLogger.Instance); + } + + /// + /// 测试目的:SaveAsync 对合法的 AuditLogInfo 不应抛出任何异常。 + /// + [Fact] + public async Task SaveAsync_ValidAuditLogInfo_ShouldNotThrow() + { + // Arrange + var store = new SimpleLogAuditingStore(); + var info = new AuditLogInfo + { + HttpMethod = "GET", + Url = "/api/test", + ExecutionDuration = 5 + }; + + // Act & Assert + await Should.NotThrowAsync(() => store.SaveAsync(info)); + } + + /// + /// 测试目的:SaveAsync 返回已完成的 Task(源码 Task.FromResult(0))。 + /// + [Fact] + public async Task SaveAsync_ShouldReturnCompletedTask() + { + var store = new SimpleLogAuditingStore(); + var info = new AuditLogInfo { HttpMethod = "POST", Url = "/api/orders" }; + + var task = store.SaveAsync(info); + task.IsCompleted.ShouldBeTrue(); + await task; // no exception + } +} diff --git a/framework/tests/Bing.Auditing.Tests/AuditPropertySetterTest.cs b/framework/tests/Bing.Auditing.Tests/AuditPropertySetterTest.cs new file mode 100644 index 00000000..bcb5ac98 --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/AuditPropertySetterTest.cs @@ -0,0 +1,514 @@ +using Bing.Auditing; +using Bing.Test.Shared.Identity; +using Bing.Test.Shared.Timing; +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +// ─── 测试用实体模型 ─────────────────────────────────────────────── + +/// 简单实体:仅含创建时间 +internal class CreationOnlyEntity : IHasCreationTime +{ + public DateTime? CreationTime { get; set; } +} + +/// 实体:Guid 创建人 + 创建时间 +internal class GuidCreationEntity : ICreationAuditedObject +{ + public DateTime? CreationTime { get; set; } + public Guid CreatorId { get; set; } +} + +/// 实体:可空 Guid 创建人 +internal class NullableGuidCreationEntity : ICreationAuditedObject +{ + public DateTime? CreationTime { get; set; } + public Guid? CreatorId { get; set; } +} + +/// 实体:int 创建人 +internal class IntCreationEntity : ICreationAuditedObject +{ + public DateTime? CreationTime { get; set; } + public int CreatorId { get; set; } +} + +/// 实体:string 创建人 +internal class StringCreationEntity : ICreationAuditedObject +{ + public DateTime? CreationTime { get; set; } + public string CreatorId { get; set; } +} + +/// 实体:long 创建人 +internal class LongCreationEntity : ICreationAuditedObject +{ + public DateTime? CreationTime { get; set; } + public long CreatorId { get; set; } +} + +/// 实体:含创建人名 +internal class NamedCreationEntity : ICreationAuditedObject, IHasCreator +{ + public DateTime? CreationTime { get; set; } + public Guid? CreatorId { get; set; } + public string Creator { get; set; } +} + +/// 实体:含修改时间 +internal class ModificationEntity : IModificationAuditedObject +{ + public DateTime? LastModificationTime { get; set; } + public Guid? LastModifierId { get; set; } +} + +/// 实体:含修改人名 +internal class NamedModificationEntity : IModificationAuditedObject, IHasModifier +{ + public DateTime? LastModificationTime { get; set; } + public Guid? LastModifierId { get; set; } + public string LastModifier { get; set; } +} + +/// 实体:软删除 +internal class DeletionEntity : IDeletionAuditedObject +{ + public bool IsDeleted { get; set; } + public DateTime? DeletionTime { get; set; } + public Guid? DeleterId { get; set; } +} + +/// 实体:含删除人名 +internal class NamedDeletionEntity : IDeletionAuditedObject, IHasDeleter +{ + public bool IsDeleted { get; set; } + public DateTime? DeletionTime { get; set; } + public Guid? DeleterId { get; set; } + public string Deleter { get; set; } +} + +// ─── 测试类 ────────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class AuditPropertySetterTest +{ + /// 固定时间:2025-06-01 12:00:00 + private static readonly DateTime FixedTime = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Local); + + private static AuditPropertySetter CreateSetter( + string userId = "user-001", + string userName = "张三", + bool isAuthenticated = true) + { + var clock = new FakeClock(FixedTime); + var user = isAuthenticated + ? FakeCurrentUser.AsAuthenticated(userId, userName) + : FakeCurrentUser.AsAnonymous(); + return new AuditPropertySetter(user, clock); + } + + // ── SetCreationProperties ───────────────────────────────────── + + /// + /// 测试目的:SetCreationProperties 应将 CreationTime 设置为时钟的当前时间。 + /// + [Fact] + public void SetCreationProperties_WhenCreationTimeIsNull_ShouldSetToClockNow() + { + // Arrange + var setter = CreateSetter(); + var entity = new CreationOnlyEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:CreationTime 已存在时,SetCreationProperties 不应覆盖原有值(幂等性)。 + /// + [Fact] + public void SetCreationProperties_WhenCreationTimeAlreadySet_ShouldNotOverwrite() + { + // Arrange + var existingTime = new DateTime(2020, 1, 1); + var setter = CreateSetter(); + var entity = new CreationOnlyEntity { CreationTime = existingTime }; + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreationTime.ShouldBe(existingTime); + } + + /// + /// 测试目的:SetCreationProperties 应填充 Guid 类型的 CreatorId。 + /// + [Fact] + public void SetCreationProperties_WithAuthenticatedUser_ShouldSetGuidCreatorId() + { + // Arrange + var userId = Guid.NewGuid().ToString(); + var setter = CreateSetter(userId: userId); + var entity = new GuidCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe(Guid.Parse(userId)); + } + + /// + /// 测试目的:CreatorId(Guid)已有值时不应被覆盖。 + /// + [Fact] + public void SetCreationProperties_WhenGuidCreatorIdAlreadySet_ShouldNotOverwrite() + { + // Arrange + var existingId = Guid.NewGuid(); + var setter = CreateSetter(userId: Guid.NewGuid().ToString()); + var entity = new GuidCreationEntity { CreatorId = existingId }; + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe(existingId); + } + + /// + /// 测试目的:SetCreationProperties 应填充 int 类型的 CreatorId。 + /// + [Fact] + public void SetCreationProperties_WithAuthenticatedUser_ShouldSetIntCreatorId() + { + // Arrange + var setter = CreateSetter(userId: "42"); + var entity = new IntCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe(42); + } + + /// + /// 测试目的:SetCreationProperties 应填充 string 类型的 CreatorId。 + /// + [Fact] + public void SetCreationProperties_WithAuthenticatedUser_ShouldSetStringCreatorId() + { + // Arrange + var setter = CreateSetter(userId: "user-abc"); + var entity = new StringCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe("user-abc"); + } + + /// + /// 测试目的:SetCreationProperties 应填充 long 类型的 CreatorId。 + /// + [Fact] + public void SetCreationProperties_WithAuthenticatedUser_ShouldSetLongCreatorId() + { + // Arrange + var setter = CreateSetter(userId: "9876543210"); + var entity = new LongCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe(9876543210L); + } + + /// + /// 测试目的:未认证用户时,CreatorId 不应被填充(保持默认值)。 + /// + [Fact] + public void SetCreationProperties_WhenUserNotAuthenticated_ShouldNotSetCreatorId() + { + // Arrange + var setter = CreateSetter(isAuthenticated: false); + var entity = new GuidCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreatorId.ShouldBe(Guid.Empty); + } + + /// + /// 测试目的:未认证用户时,CreationTime 仍应被填充(创建时间不依赖用户认证)。 + /// + [Fact] + public void SetCreationProperties_WhenUserNotAuthenticated_ShouldStillSetCreationTime() + { + // Arrange + var setter = CreateSetter(isAuthenticated: false); + var entity = new CreationOnlyEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.CreationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:SetCreationProperties 应填充 Creator(用户名)字段。 + /// + [Fact] + public void SetCreationProperties_WithAuthenticatedUser_ShouldSetCreatorName() + { + // Arrange + var setter = CreateSetter(userId: "user-001", userName: "李四"); + var entity = new NamedCreationEntity(); + + // Act + setter.SetCreationProperties(entity); + + // Assert + entity.Creator.ShouldBe("李四"); + } + + /// + /// 测试目的:传入 null 时,SetCreationProperties 应不抛异常,静默处理。 + /// + [Fact] + public void SetCreationProperties_WhenTargetIsNull_ShouldNotThrow() + { + // Arrange + var setter = CreateSetter(); + + // Act & Assert + Should.NotThrow(() => setter.SetCreationProperties(null)); + } + + // ── SetModificationProperties ───────────────────────────────── + + /// + /// 测试目的:SetModificationProperties 应始终更新 LastModificationTime(包括有旧值的情况)。 + /// + [Fact] + public void SetModificationProperties_ShouldAlwaysOverwriteLastModificationTime() + { + // Arrange + var setter = CreateSetter(); + var entity = new ModificationEntity { LastModificationTime = new DateTime(2010, 1, 1) }; + + // Act + setter.SetModificationProperties(entity); + + // Assert + entity.LastModificationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:SetModificationProperties 应填充 LastModifierId(Guid?)。 + /// + [Fact] + public void SetModificationProperties_WithAuthenticatedUser_ShouldSetLastModifierId() + { + // Arrange + var userId = Guid.NewGuid().ToString(); + var setter = CreateSetter(userId: userId); + var entity = new ModificationEntity(); + + // Act + setter.SetModificationProperties(entity); + + // Assert + entity.LastModifierId.ShouldBe(Guid.Parse(userId)); + } + + /// + /// 测试目的:SetModificationProperties 应始终覆盖 LastModifierId(不做幂等保护)。 + /// + [Fact] + public void SetModificationProperties_ShouldOverwriteExistingLastModifierId() + { + // Arrange + var newUserId = Guid.NewGuid().ToString(); + var setter = CreateSetter(userId: newUserId); + var entity = new ModificationEntity { LastModifierId = Guid.NewGuid() }; + + // Act + setter.SetModificationProperties(entity); + + // Assert + entity.LastModifierId.ShouldBe(Guid.Parse(newUserId)); + } + + /// + /// 测试目的:SetModificationProperties 应填充 LastModifier(用户名)字段。 + /// + [Fact] + public void SetModificationProperties_WithAuthenticatedUser_ShouldSetLastModifierName() + { + // Arrange + var setter = CreateSetter(userId: "user-001", userName: "王五"); + var entity = new NamedModificationEntity(); + + // Act + setter.SetModificationProperties(entity); + + // Assert + entity.LastModifier.ShouldBe("王五"); + } + + /// + /// 测试目的:传入 null 时,SetModificationProperties 应不抛异常。 + /// + [Fact] + public void SetModificationProperties_WhenTargetIsNull_ShouldNotThrow() + { + // Arrange + var setter = CreateSetter(); + + // Act & Assert + Should.NotThrow(() => setter.SetModificationProperties(null)); + } + + // ── SetDeletionProperties ───────────────────────────────────── + + /// + /// 测试目的:SetDeletionProperties 应将 DeletionTime 设置为时钟当前时间。 + /// + [Fact] + public void SetDeletionProperties_ShouldSetDeletionTimeToClockNow() + { + // Arrange + var setter = CreateSetter(); + var entity = new DeletionEntity(); + + // Act + setter.SetDeletionProperties(entity); + + // Assert + entity.DeletionTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:SetDeletionProperties 应填充 DeleterId(Guid?)。 + /// + [Fact] + public void SetDeletionProperties_WithAuthenticatedUser_ShouldSetDeleterId() + { + // Arrange + var userId = Guid.NewGuid().ToString(); + var setter = CreateSetter(userId: userId); + var entity = new DeletionEntity(); + + // Act + setter.SetDeletionProperties(entity); + + // Assert + entity.DeleterId.ShouldBe(Guid.Parse(userId)); + } + + /// + /// 测试目的:DeleterId 已有值时,SetDeletionProperties 不应覆盖(幂等性)。 + /// + [Fact] + public void SetDeletionProperties_WhenDeleterIdAlreadySet_ShouldNotOverwrite() + { + // Arrange + var existingId = Guid.NewGuid(); + var setter = CreateSetter(userId: Guid.NewGuid().ToString()); + var entity = new DeletionEntity { DeleterId = existingId }; + + // Act + setter.SetDeletionProperties(entity); + + // Assert + entity.DeleterId.ShouldBe(existingId); + } + + /// + /// 测试目的:未认证用户删除时,DeleterId 不应被填充。 + /// + [Fact] + public void SetDeletionProperties_WhenUserNotAuthenticated_ShouldNotSetDeleterId() + { + // Arrange + var setter = CreateSetter(isAuthenticated: false); + var entity = new DeletionEntity(); + + // Act + setter.SetDeletionProperties(entity); + + // Assert + entity.DeleterId.ShouldBeNull(); + } + + /// + /// 测试目的:SetDeletionProperties 应填充 Deleter(用户名)字段。 + /// + [Fact] + public void SetDeletionProperties_WithAuthenticatedUser_ShouldSetDeleterName() + { + // Arrange + var setter = CreateSetter(userId: "user-001", userName: "赵六"); + var entity = new NamedDeletionEntity(); + + // Act + setter.SetDeletionProperties(entity); + + // Assert + entity.Deleter.ShouldBe("赵六"); + } + + /// + /// 测试目的:传入 null 时,SetDeletionProperties 应不抛异常。 + /// + [Fact] + public void SetDeletionProperties_WhenTargetIsNull_ShouldNotThrow() + { + // Arrange + var setter = CreateSetter(); + + // Act & Assert + Should.NotThrow(() => setter.SetDeletionProperties(null)); + } + + // ── FakeClock 时间推进场景 ──────────────────────────────────── + + /// + /// 测试目的:使用 FakeClock.Advance 推进时间后,后续调用应获得新的时间戳。 + /// 验证 FakeClock 可以模拟时序相关的审计场景(如创建后修改)。 + /// + [Fact] + public void SetModificationProperties_AfterClockAdvance_ShouldUseNewTime() + { + // Arrange + var clock = new FakeClock(FixedTime); + var user = FakeCurrentUser.AsAuthenticated("user-001", "张三"); + var setter = new AuditPropertySetter(user, clock); + var entity = new NamedModificationEntity(); + + // 先用初始时间设置一次创建属性(模拟创建行为) + clock.Advance(TimeSpan.FromHours(2)); + var expectedModTime = FixedTime.AddHours(2); + + // Act + setter.SetModificationProperties(entity); + + // Assert + entity.LastModificationTime.ShouldBe(expectedModTime); + } +} diff --git a/framework/tests/Bing.Auditing.Tests/Bing.Auditing.Tests.csproj b/framework/tests/Bing.Auditing.Tests/Bing.Auditing.Tests.csproj index b09a1211..85984679 100644 --- a/framework/tests/Bing.Auditing.Tests/Bing.Auditing.Tests.csproj +++ b/framework/tests/Bing.Auditing.Tests/Bing.Auditing.Tests.csproj @@ -7,5 +7,7 @@ + + diff --git a/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogInfoAndEntityChangeTest.cs b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogInfoAndEntityChangeTest.cs new file mode 100644 index 00000000..823f47fb --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogInfoAndEntityChangeTest.cs @@ -0,0 +1,385 @@ +using Bing.Auditing; +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +/// +/// 、 +/// 、 +/// 单元测试。 +/// +public class AuditLogInfoAndEntityChangeTest +{ + // ═══════════════════════════════════════════════════════════ + // AuditLogInfo — 默认构造与集合初始化 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后所有 List 集合属性不为 null,保证调用方直接 Add 不会 NullReferenceException。 + /// + [Fact] + public void AuditLogInfo_DefaultCtor_AllListsShouldNotBeNull() + { + // Arrange & Act + var info = new AuditLogInfo(); + + // Assert + info.Actions.ShouldNotBeNull(); + info.Exceptions.ShouldNotBeNull(); + info.EntityChanges.ShouldNotBeNull(); + info.Comments.ShouldNotBeNull(); + } + + /// + /// 测试目的:默认构造后所有 List 集合应为空(不含任何元素)。 + /// + [Fact] + public void AuditLogInfo_DefaultCtor_AllListsShouldBeEmpty() + { + // Arrange & Act + var info = new AuditLogInfo(); + + // Assert + info.Actions.ShouldBeEmpty(); + info.Exceptions.ShouldBeEmpty(); + info.EntityChanges.ShouldBeEmpty(); + info.Comments.ShouldBeEmpty(); + } + + /// + /// 测试目的:ToString() 在无 Actions/Exceptions/EntityChanges 时,应包含 HTTP 基础信息行,不抛异常。 + /// + [Fact] + public void AuditLogInfo_ToString_WithBasicInfoOnly_ShouldContainHttpInfo() + { + // Arrange + var info = new AuditLogInfo + { + HttpMethod = "GET", + HttpStatusCode = 200, + Url = "/api/orders", + UserName = "alice", + UserId = "u-001", + ClientIpAddress = "192.168.1.1", + ExecutionDuration = 120 + }; + + // Act + var result = info.ToString(); + + // Assert + result.ShouldContain("GET"); + result.ShouldContain("200"); + result.ShouldContain("/api/orders"); + result.ShouldContain("alice"); + result.ShouldContain("u-001"); + result.ShouldContain("192.168.1.1"); + result.ShouldContain("120"); + } + + /// + /// 测试目的:ToString() 在含有 Actions 时,应输出 ServiceName、MethodName 和执行耗时。 + /// + [Fact] + public void AuditLogInfo_ToString_WithActions_ShouldContainActionInfo() + { + // Arrange + var info = new AuditLogInfo(); + info.Actions.Add(new AuditLogActionInfo + { + ServiceName = "OrderAppService", + MethodName = "CreateOrder", + ExecutionDuration = 55, + Parameters = "{\"orderId\":\"o-001\"}" + }); + + // Act + var result = info.ToString(); + + // Assert + result.ShouldContain("OrderAppService"); + result.ShouldContain("CreateOrder"); + result.ShouldContain("55"); + result.ShouldContain("{\"orderId\":\"o-001\"}"); + } + + /// + /// 测试目的:ToString() 在含有 Exceptions 时,应包含异常消息。 + /// + [Fact] + public void AuditLogInfo_ToString_WithExceptions_ShouldContainExceptionMessage() + { + // Arrange + var info = new AuditLogInfo(); + info.Exceptions.Add(new InvalidOperationException("order not found")); + + // Act + var result = info.ToString(); + + // Assert + result.ShouldContain("order not found"); + } + + /// + /// 测试目的:ToString() 在含有 EntityChanges 时,应包含实体类型全名和实体 ID。 + /// + [Fact] + public void AuditLogInfo_ToString_WithEntityChanges_ShouldContainEntityInfo() + { + // Arrange + var info = new AuditLogInfo(); + info.EntityChanges.Add(new EntityChangeInfo + { + EntityTypeFullName = "Bing.Domain.Orders.Order", + EntityId = "order-abc", + PropertyChanges = new List + { + new EntityPropertyChangeInfo + { + PropertyName = "Status", + OriginalValue = "Created", + NewValue = "Completed" + } + } + }); + + // Act + var result = info.ToString(); + + // Assert + result.ShouldContain("Bing.Domain.Orders.Order"); + result.ShouldContain("order-abc"); + result.ShouldContain("Status"); + result.ShouldContain("Created"); + result.ShouldContain("Completed"); + } + + /// + /// 测试目的:HttpStatusCode 为 null 时 ToString() 应使用占位符,不抛 NullReferenceException。 + /// + [Fact] + public void AuditLogInfo_ToString_NullHttpStatusCode_ShouldUsePlaceholder() + { + // Arrange + var info = new AuditLogInfo { HttpStatusCode = null }; + + // Act & Assert + Should.NotThrow(() => info.ToString()); + info.ToString().ShouldContain("---"); + } + + // ═══════════════════════════════════════════════════════════ + // AuditLogActionInfo — DTO 属性读写 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AuditLogActionInfo 所有属性可读写,默认值均为 null/0。 + /// + [Fact] + public void AuditLogActionInfo_Properties_ShouldBeReadWritable() + { + // Arrange + var action = new AuditLogActionInfo(); + + // Act + action.ServiceName = "ProductService"; + action.MethodName = "GetById"; + action.Parameters = "{\"id\":1}"; + action.ExecutionTime = new DateTime(2026, 1, 1, 12, 0, 0); + action.ExecutionDuration = 30; + + // Assert + action.ServiceName.ShouldBe("ProductService"); + action.MethodName.ShouldBe("GetById"); + action.Parameters.ShouldBe("{\"id\":1}"); + action.ExecutionDuration.ShouldBe(30); + } + + // ═══════════════════════════════════════════════════════════ + // EntityChangeInfo.Merge — 合并逻辑 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Merge 时若传入的属性变更在目标中不存在,应将其添加进 PropertyChanges。 + /// + [Fact] + public void EntityChangeInfo_Merge_NewProperty_ShouldBeAdded() + { + // Arrange + var target = new EntityChangeInfo + { + PropertyChanges = new List() + }; + var incoming = new EntityChangeInfo + { + PropertyChanges = new List + { + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = null, NewValue = "Alice" } + } + }; + + // Act + target.Merge(incoming); + + // Assert + target.PropertyChanges.Count.ShouldBe(1); + target.PropertyChanges[0].PropertyName.ShouldBe("Name"); + target.PropertyChanges[0].NewValue.ShouldBe("Alice"); + } + + /// + /// 测试目的:Merge 时若传入的属性变更在目标中已存在(同名),应更新 NewValue,而非重复添加。 + /// + [Fact] + public void EntityChangeInfo_Merge_ExistingProperty_ShouldUpdateNewValue() + { + // Arrange + var existing = new EntityPropertyChangeInfo + { + PropertyName = "Status", + OriginalValue = "Draft", + NewValue = "Pending" + }; + var target = new EntityChangeInfo + { + PropertyChanges = new List { existing } + }; + var incoming = new EntityChangeInfo + { + PropertyChanges = new List + { + new EntityPropertyChangeInfo { PropertyName = "Status", OriginalValue = "Pending", NewValue = "Completed" } + } + }; + + // Act + target.Merge(incoming); + + // Assert + target.PropertyChanges.Count.ShouldBe(1); + target.PropertyChanges[0].NewValue.ShouldBe("Completed"); + } + + /// + /// 测试目的:Merge 时同时含有新属性和已有属性:新属性被追加,已有属性 NewValue 被更新。 + /// + [Fact] + public void EntityChangeInfo_Merge_MixedProperties_ShouldAddAndUpdate() + { + // Arrange + var target = new EntityChangeInfo + { + PropertyChanges = new List + { + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = "Alice", NewValue = "Bob" } + } + }; + var incoming = new EntityChangeInfo + { + PropertyChanges = new List + { + new EntityPropertyChangeInfo { PropertyName = "Name", OriginalValue = "Bob", NewValue = "Charlie" }, + new EntityPropertyChangeInfo { PropertyName = "Age", OriginalValue = null, NewValue = "30" } + } + }; + + // Act + target.Merge(incoming); + + // Assert + target.PropertyChanges.Count.ShouldBe(2); + target.PropertyChanges.First(p => p.PropertyName == "Name").NewValue.ShouldBe("Charlie"); + target.PropertyChanges.First(p => p.PropertyName == "Age").NewValue.ShouldBe("30"); + } + + // ═══════════════════════════════════════════════════════════ + // EntityPropertyChangeInfo — DTO 属性读写 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:EntityPropertyChangeInfo 所有属性可读写,默认值均为 null。 + /// + [Fact] + public void EntityPropertyChangeInfo_Properties_ShouldBeReadWritable() + { + // Arrange + var prop = new EntityPropertyChangeInfo(); + + // Act + prop.PropertyName = "Email"; + prop.PropertyTypeFullName = "System.String"; + prop.OriginalValue = "old@example.com"; + prop.NewValue = "new@example.com"; + + // Assert + prop.PropertyName.ShouldBe("Email"); + prop.PropertyTypeFullName.ShouldBe("System.String"); + prop.OriginalValue.ShouldBe("old@example.com"); + prop.NewValue.ShouldBe("new@example.com"); + } + + // ═══════════════════════════════════════════════════════════ + // AuditedPropertyConst — 默认常量值 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:验证 AuditedPropertyConst 各默认属性名称值正确,以免意外修改破坏数据库字段映射。 + /// + [Fact] + public void AuditedPropertyConst_DefaultValues_ShouldMatchExpected() + { + // Assert + AuditedPropertyConst.Creator.ShouldBe("Creator"); + AuditedPropertyConst.CreatorId.ShouldBe("CreatorId"); + AuditedPropertyConst.CreationTime.ShouldBe("CreationTime"); + AuditedPropertyConst.Modifier.ShouldBe("LastModifier"); + AuditedPropertyConst.ModifierId.ShouldBe("LastModifierId"); + AuditedPropertyConst.ModificationTime.ShouldBe("LastModificationTime"); + AuditedPropertyConst.Version.ShouldBe("Version"); + } +} + +/// +/// (来自 Bing.Auditing.Contracts)单元测试 +/// +public class EntityChangeTypeTest +{ + // ═══════════════════════════════════════════════════════════ + // 枚举值验证 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:枚举各成员的整数值应符合规范(0=Created, 1=Updated, 2=Deleted), + /// 防止序列化/数据库映射时因数值变动导致历史数据错误。 + /// + [Fact] + public void EntityChangeType_Values_ShouldMatchExpected() + { + // Assert + ((int)EntityChangeType.Created).ShouldBe(0); + ((int)EntityChangeType.Updated).ShouldBe(1); + ((int)EntityChangeType.Deleted).ShouldBe(2); + } + + /// + /// 测试目的:枚举成员个数应为 3,防止因新增/删除成员而导致使用方意外受影响(变更感知)。 + /// + [Fact] + public void EntityChangeType_Count_ShouldBeThree() + { + // Assert + Enum.GetValues(typeof(EntityChangeType)).Length.ShouldBe(3); + } + + /// + /// 测试目的:EntityChangeType.Created 应为默认值(0), + /// 确保未显式赋值时行为可预测。 + /// + [Fact] + public void EntityChangeType_Default_ShouldBeCreated() + { + // Assert + default(EntityChangeType).ShouldBe(EntityChangeType.Created); + } +} diff --git a/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogScopeContributorSimpleStoreTest.cs b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogScopeContributorSimpleStoreTest.cs new file mode 100644 index 00000000..d81bb178 --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditLogScopeContributorSimpleStoreTest.cs @@ -0,0 +1,269 @@ +using Bing.Auditing; +using Microsoft.Extensions.Logging; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +/// +/// 、 +/// 、 +/// 单元测试。 +/// +public class AuditLogScopeContributorSimpleStoreTest +{ + // ═══════════════════════════════════════════════════════════ + // AuditLogScope + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造器应将传入的 AuditLogInfo 正确赋给 Log 属性,确保作用域持有日志引用。 + /// + [Fact] + public void AuditLogScope_Constructor_ShouldSetLogProperty() + { + // Arrange + var logInfo = new AuditLogInfo(); + + // Act + var scope = new AuditLogScope(logInfo); + + // Assert + scope.Log.ShouldBeSameAs(logInfo); + } + + /// + /// 测试目的:AuditLogScope.Log 持有的 AuditLogInfo 应与原始实例完全相同(引用相等), + /// 防止误复制导致修改不可见。 + /// + [Fact] + public void AuditLogScope_Log_ShouldBeReferenceEqual() + { + // Arrange + var logInfo = new AuditLogInfo(); + logInfo.UserId = "u-001"; + + // Act + var scope = new AuditLogScope(logInfo); + + // Assert + scope.Log.UserId.ShouldBe("u-001"); + ReferenceEquals(scope.Log, logInfo).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // AuditLogContributionContext + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造器应将 ServiceProvider 和 AuditInfo 正确赋值, + /// 确保 Contributor 可访问这两个核心属性。 + /// + [Fact] + public void AuditLogContributionContext_Constructor_ShouldSetBothProperties() + { + // Arrange + var mockSp = new Mock(); + var logInfo = new AuditLogInfo(); + + // Act + var context = new AuditLogContributionContext(mockSp.Object, logInfo); + + // Assert + context.ServiceProvider.ShouldBeSameAs(mockSp.Object); + context.AuditInfo.ShouldBeSameAs(logInfo); + } + + /// + /// 测试目的:ServiceProvider 属性为只读,多次读取返回同一引用,不会被意外置换。 + /// + [Fact] + public void AuditLogContributionContext_ServiceProvider_ShouldBeImmutable() + { + // Arrange + var mockSp = new Mock(); + var context = new AuditLogContributionContext(mockSp.Object, new AuditLogInfo()); + + // Act & Assert — 两次读取结果一致 + context.ServiceProvider.ShouldBeSameAs(mockSp.Object); + context.ServiceProvider.ShouldBeSameAs(mockSp.Object); + } + + // ═══════════════════════════════════════════════════════════ + // AuditLogContributor — 虚方法默认实现 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:PreContribute 和 PostContribute 的默认实现不应抛出任何异常, + /// 子类可安全地选择性覆盖其中任一方法。 + /// + [Fact] + public void AuditLogContributor_DefaultMethods_ShouldNotThrow() + { + // Arrange + var mockSp = new Mock(); + var context = new AuditLogContributionContext(mockSp.Object, new AuditLogInfo()); + var contributor = new NoOpAuditLogContributor(); + + // Act & Assert + Should.NotThrow(() => contributor.PreContribute(context)); + Should.NotThrow(() => contributor.PostContribute(context)); + } + + /// + /// 测试目的:子类覆盖 PreContribute 时,应仅执行子类逻辑, + /// 验证虚方法可被正确重写。 + /// + [Fact] + public void AuditLogContributor_OverridePreContribute_ShouldBeInvoked() + { + // Arrange + var mockSp = new Mock(); + var logInfo = new AuditLogInfo(); + var context = new AuditLogContributionContext(mockSp.Object, logInfo); + var contributor = new TrackingAuditLogContributor(); + + // Act + contributor.PreContribute(context); + + // Assert + contributor.PreContributeCallCount.ShouldBe(1); + contributor.PostContributeCallCount.ShouldBe(0); + } + + /// + /// 测试目的:子类覆盖 PostContribute 时,应仅执行子类逻辑, + /// 验证 PreContribute 和 PostContribute 是独立的扩展点。 + /// + [Fact] + public void AuditLogContributor_OverridePostContribute_ShouldBeInvoked() + { + // Arrange + var mockSp = new Mock(); + var context = new AuditLogContributionContext(mockSp.Object, new AuditLogInfo()); + var contributor = new TrackingAuditLogContributor(); + + // Act + contributor.PostContribute(context); + + // Assert + contributor.PostContributeCallCount.ShouldBe(1); + contributor.PreContributeCallCount.ShouldBe(0); + } + + // ═══════════════════════════════════════════════════════════ + // DisableAuditingAttribute + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:DisableAuditingAttribute 应可应用到类和方法上,不抛出异常。 + /// + [Fact] + public void DisableAuditingAttribute_AppliedToClass_ShouldBeRetrievable() + { + // Arrange & Act + var attrs = typeof(SampleAuditDisabledClass).GetCustomAttributes(typeof(DisableAuditingAttribute), false); + + // Assert + attrs.ShouldNotBeNull(); + attrs.Length.ShouldBe(1); + } + + /// + /// 测试目的:DisableAuditingAttribute 标记了 Obsolete,通过反射仍可正确读取。 + /// + [Fact] + public void DisableAuditingAttribute_ShouldBeObsolete() + { + // Arrange & Act + var obsolete = typeof(DisableAuditingAttribute).GetCustomAttributes(typeof(ObsoleteAttribute), false); + + // Assert + obsolete.ShouldNotBeEmpty(); + } + + // ═══════════════════════════════════════════════════════════ + // SimpleLogAuditingStore + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Logger 为 NullLogger 实例,确保不依赖 DI 时也不会 NRE。 + /// + [Fact] + public async Task SimpleLogAuditingStore_Default_LoggerShouldNotBeNull() + { + // Arrange & Act + var store = new SimpleLogAuditingStore(); + + // Assert + store.Logger.ShouldNotBeNull(); + // 可安全调用 SaveAsync,NullLogger 不会抛异常 + await Should.NotThrowAsync(async () => await store.SaveAsync(new AuditLogInfo())); + } + + /// + /// 测试目的:注入真实 Logger 后,SaveAsync 应调用 Logger.LogInformation 一次, + /// 确保审计信息被写入日志。 + /// + [Fact] + public async Task SimpleLogAuditingStore_SaveAsync_ShouldCallLogInformationOnce() + { + // Arrange + var mockLogger = new Mock>(); + var store = new SimpleLogAuditingStore { Logger = mockLogger.Object }; + var auditInfo = new AuditLogInfo { UserId = "u-test", UserName = "tester" }; + + // Act + await store.SaveAsync(auditInfo); + + // Assert + mockLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// 测试目的:Logger 属性是可注入的(公开 setter),赋值后读取应返回新实例。 + /// + [Fact] + public void SimpleLogAuditingStore_Logger_ShouldBeMutableViaSetter() + { + // Arrange + var store = new SimpleLogAuditingStore(); + var mockLogger = new Mock>(); + + // Act + store.Logger = mockLogger.Object; + + // Assert + store.Logger.ShouldBeSameAs(mockLogger.Object); + } + + // ═══════════════════════════════════════════════════════════ + // 内部辅助类型 + // ═══════════════════════════════════════════════════════════ + + /// 空实现的 AuditLogContributor,用于测试默认虚方法不抛异常。 + private class NoOpAuditLogContributor : AuditLogContributor { } + + /// 记录调用次数的 AuditLogContributor,用于验证方法覆盖。 + private class TrackingAuditLogContributor : AuditLogContributor + { + public int PreContributeCallCount { get; private set; } + public int PostContributeCallCount { get; private set; } + + public override void PreContribute(AuditLogContributionContext context) => PreContributeCallCount++; + public override void PostContribute(AuditLogContributionContext context) => PostContributeCallCount++; + } + +#pragma warning disable CS0618 + [DisableAuditing] + private class SampleAuditDisabledClass { } +#pragma warning restore CS0618 +} diff --git a/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditedInitializerTest.cs b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditedInitializerTest.cs new file mode 100644 index 00000000..9cca94f3 --- /dev/null +++ b/framework/tests/Bing.Auditing.Tests/Bing/Auditing/AuditedInitializerTest.cs @@ -0,0 +1,451 @@ +using Bing.Auditing; +using Shouldly; +using Xunit; + +namespace Bing.Auditing.Tests; + +/// +/// 、 +/// 单元测试。 +/// 直接调用 static Init 工厂方法,不依赖 DI。 +/// +public class AuditedInitializerTest +{ + // ──── 辅助实体定义 ──────────────────────────────────────────── + + private class CreationTimeOnly : IHasCreationTime + { + public DateTime? CreationTime { get; set; } + } + + private class FullCreationGuid : ICreationAuditedObject, IHasCreator + { + public DateTime? CreationTime { get; set; } + public Guid? CreatorId { get; set; } + public string Creator { get; set; } + } + + private class FullCreationInt : ICreationAuditedObject, IHasCreator + { + public DateTime? CreationTime { get; set; } + public int CreatorId { get; set; } + public string Creator { get; set; } + } + + private class FullCreationString : ICreationAuditedObject, IHasCreator + { + public DateTime? CreationTime { get; set; } + public string CreatorId { get; set; } + public string Creator { get; set; } + } + + private class FullModificationGuid : IModificationAuditedObject, IHasModifier + { + public DateTime? LastModificationTime { get; set; } + public Guid? LastModifierId { get; set; } + public string LastModifier { get; set; } + } + + private class FullModificationString : IModificationAuditedObject + { + public DateTime? LastModificationTime { get; set; } + public string LastModifierId { get; set; } + } + + private class FullDeletionGuid : IDeletionAuditedObject, IHasDeleter + { + public bool IsDeleted { get; set; } + public DateTime? DeletionTime { get; set; } + public Guid? DeleterId { get; set; } + public string Deleter { get; set; } + } + + private class FullDeletionString : IDeletionAuditedObject + { + public bool IsDeleted { get; set; } + public DateTime? DeletionTime { get; set; } + public string DeleterId { get; set; } + } + + /// 固定时间:用于断言 dateTime 参数覆盖 + private static readonly DateTime FixedTime = new DateTime(2025, 8, 1, 10, 0, 0, DateTimeKind.Local); + + // ════════════════════════════════════════════════════════════════ + // CreationAuditedInitializer + // ════════════════════════════════════════════════════════════════ + + // ── null 实体 ───────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时,Init 应静默处理,不抛异常。 + /// + [Fact] + public void CreationInit_WhenEntityIsNull_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => CreationAuditedInitializer.Init(null, "u1", "张三")); + } + + // ── 创建时间 ────────────────────────────────────────────────── + + /// + /// 测试目的:传入显式 dateTime 参数时,CreationTime 应等于该指定时间。 + /// + [Fact] + public void CreationInit_WithExplicitDateTime_ShouldSetCreationTimeToSpecifiedValue() + { + // Arrange + var entity = new CreationTimeOnly(); + + // Act + CreationAuditedInitializer.Init(entity, "u1", "张三", FixedTime); + + // Assert + entity.CreationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:不传 dateTime 时,CreationTime 应被设置为非 null(DateTime.Now 附近)。 + /// + [Fact] + public void CreationInit_WithoutDateTime_ShouldSetCreationTimeToNonNull() + { + // Arrange + var before = DateTime.Now.AddSeconds(-1); + var entity = new CreationTimeOnly(); + + // Act + CreationAuditedInitializer.Init(entity, "u1", "张三"); + + // Assert + entity.CreationTime.ShouldNotBeNull(); + entity.CreationTime.Value.ShouldBeGreaterThan(before); + } + + // ── CreatorId 多类型 ────────────────────────────────────────── + + /// + /// 测试目的:userId 为有效 Guid 字符串时,应正确填充 Guid? 类型的 CreatorId。 + /// + [Fact] + public void CreationInit_WithGuidUserId_ShouldSetNullableGuidCreatorId() + { + // Arrange + var userId = Guid.NewGuid(); + var entity = new FullCreationGuid(); + + // Act + CreationAuditedInitializer.Init(entity, userId.ToString(), "张三", FixedTime); + + // Assert + entity.CreatorId.ShouldBe(userId); + } + + /// + /// 测试目的:userId 为整数字符串时,应正确填充 int 类型的 CreatorId。 + /// + [Fact] + public void CreationInit_WithIntUserId_ShouldSetIntCreatorId() + { + // Arrange + var entity = new FullCreationInt(); + + // Act + CreationAuditedInitializer.Init(entity, "100", "张三", FixedTime); + + // Assert + entity.CreatorId.ShouldBe(100); + } + + /// + /// 测试目的:userId 为任意字符串时,应正确填充 string 类型的 CreatorId。 + /// + [Fact] + public void CreationInit_WithStringUserId_ShouldSetStringCreatorId() + { + // Arrange + var entity = new FullCreationString(); + + // Act + CreationAuditedInitializer.Init(entity, "user-abc", "张三", FixedTime); + + // Assert + entity.CreatorId.ShouldBe("user-abc"); + } + + // ── Creator 用户名 ──────────────────────────────────────────── + + /// + /// 测试目的:userName 不为空时,Creator 字段应被正确填充。 + /// + [Fact] + public void CreationInit_WithUserName_ShouldSetCreatorName() + { + // Arrange + var entity = new FullCreationGuid(); + + // Act + CreationAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "李四", FixedTime); + + // Assert + entity.Creator.ShouldBe("李四"); + } + + /// + /// 测试目的:userName 为空时,Creator 不应被修改(保持 null/默认)。 + /// + [Fact] + public void CreationInit_WhenUserNameEmpty_ShouldNotSetCreatorName() + { + // Arrange + var entity = new FullCreationGuid(); + + // Act + CreationAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "", FixedTime); + + // Assert + entity.Creator.ShouldBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:userId 为空/null 时,CreatorId 不应被填充(保持默认值)。 + /// + [Fact] + public void CreationInit_WhenUserIdEmpty_ShouldNotSetCreatorId() + { + // Arrange + var entity = new FullCreationGuid(); + + // Act + CreationAuditedInitializer.Init(entity, "", "张三", FixedTime); + + // Assert + entity.CreatorId.ShouldBeNull(); + } + + // ════════════════════════════════════════════════════════════════ + // ModificationAuditedInitializer + // ════════════════════════════════════════════════════════════════ + + // ── null 实体 ───────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时,Init 应静默处理,不抛异常。 + /// + [Fact] + public void ModificationInit_WhenEntityIsNull_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => ModificationAuditedInitializer.Init(null, "u1", "张三")); + } + + // ── 修改时间 ────────────────────────────────────────────────── + + /// + /// 测试目的:传入显式 dateTime 参数时,LastModificationTime 应等于该值。 + /// + [Fact] + public void ModificationInit_WithExplicitDateTime_ShouldSetLastModificationTimeToSpecifiedValue() + { + // Arrange + var entity = new FullModificationGuid(); + + // Act + ModificationAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "张三", FixedTime); + + // Assert + entity.LastModificationTime.ShouldBe(FixedTime); + } + + /// + /// 测试目的:不传 dateTime 时,LastModificationTime 应被设置为非 null(DateTime.Now 附近)。 + /// + [Fact] + public void ModificationInit_WithoutDateTime_ShouldSetLastModificationTimeToNonNull() + { + // Arrange + var before = DateTime.Now.AddSeconds(-1); + var entity = new FullModificationGuid(); + + // Act + ModificationAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "张三"); + + // Assert + entity.LastModificationTime.ShouldNotBeNull(); + entity.LastModificationTime.Value.ShouldBeGreaterThan(before); + } + + // ── LastModifierId 多类型 ───────────────────────────────────── + + /// + /// 测试目的:userId 为 Guid 字符串时,应正确填充 Guid? 类型的 LastModifierId。 + /// + [Fact] + public void ModificationInit_WithGuidUserId_ShouldSetNullableGuidLastModifierId() + { + // Arrange + var userId = Guid.NewGuid(); + var entity = new FullModificationGuid(); + + // Act + ModificationAuditedInitializer.Init(entity, userId.ToString(), "张三", FixedTime); + + // Assert + entity.LastModifierId.ShouldBe(userId); + } + + /// + /// 测试目的:userId 为任意字符串时,应正确填充 string 类型的 LastModifierId。 + /// + [Fact] + public void ModificationInit_WithStringUserId_ShouldSetStringLastModifierId() + { + // Arrange + var entity = new FullModificationString(); + + // Act + ModificationAuditedInitializer.Init(entity, "mod-user", "张三", FixedTime); + + // Assert + entity.LastModifierId.ShouldBe("mod-user"); + } + + // ── LastModifier 用户名 ─────────────────────────────────────── + + /// + /// 测试目的:userName 不为空时,LastModifier 字段应被正确填充。 + /// + [Fact] + public void ModificationInit_WithUserName_ShouldSetLastModifierName() + { + // Arrange + var entity = new FullModificationGuid(); + + // Act + ModificationAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "王五", FixedTime); + + // Assert + entity.LastModifier.ShouldBe("王五"); + } + + /// + /// 测试目的:userId 为空时,LastModifierId 不应被填充(保持默认值)。 + /// + [Fact] + public void ModificationInit_WhenUserIdEmpty_ShouldNotSetLastModifierId() + { + // Arrange + var entity = new FullModificationGuid(); + + // Act + ModificationAuditedInitializer.Init(entity, "", "张三", FixedTime); + + // Assert + entity.LastModifierId.ShouldBeNull(); + } + + // ════════════════════════════════════════════════════════════════ + // DeletionAuditedInitializer + // ════════════════════════════════════════════════════════════════ + + // ── null 实体 ───────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时,Init 应静默处理,不抛异常。 + /// + [Fact] + public void DeletionInit_WhenEntityIsNull_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => DeletionAuditedInitializer.Init(null, "u1", "张三")); + } + + // ── 删除时间 ────────────────────────────────────────────────── + + /// + /// 测试目的:Init 后,DeletionTime 应被设置为非 null(内部使用 DateTime.Now)。 + /// + [Fact] + public void DeletionInit_ShouldSetDeletionTimeToNonNull() + { + // Arrange + var before = DateTime.Now.AddSeconds(-1); + var entity = new FullDeletionGuid(); + + // Act + DeletionAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "张三"); + + // Assert + entity.DeletionTime.ShouldNotBeNull(); + entity.DeletionTime.Value.ShouldBeGreaterThan(before); + } + + // ── DeleterId 多类型 ────────────────────────────────────────── + + /// + /// 测试目的:userId 为 Guid 字符串时,应正确填充 Guid? 类型的 DeleterId。 + /// + [Fact] + public void DeletionInit_WithGuidUserId_ShouldSetNullableGuidDeleterId() + { + // Arrange + var userId = Guid.NewGuid(); + var entity = new FullDeletionGuid(); + + // Act + DeletionAuditedInitializer.Init(entity, userId.ToString(), "张三"); + + // Assert + entity.DeleterId.ShouldBe(userId); + } + + /// + /// 测试目的:userId 为任意字符串时,应正确填充 string 类型的 DeleterId。 + /// + [Fact] + public void DeletionInit_WithStringUserId_ShouldSetStringDeleterId() + { + // Arrange + var entity = new FullDeletionString(); + + // Act + DeletionAuditedInitializer.Init(entity, "del-user", "张三"); + + // Assert + entity.DeleterId.ShouldBe("del-user"); + } + + // ── Deleter 用户名 ──────────────────────────────────────────── + + /// + /// 测试目的:userName 不为空时,Deleter 字段应被正确填充。 + /// + [Fact] + public void DeletionInit_WithUserName_ShouldSetDeleterName() + { + // Arrange + var entity = new FullDeletionGuid(); + + // Act + DeletionAuditedInitializer.Init(entity, Guid.NewGuid().ToString(), "赵六"); + + // Assert + entity.Deleter.ShouldBe("赵六"); + } + + /// + /// 测试目的:userId 为空时,DeleterId 不应被填充(保持默认值)。 + /// + [Fact] + public void DeletionInit_WhenUserIdEmpty_ShouldNotSetDeleterId() + { + // Arrange + var entity = new FullDeletionGuid(); + + // Act + DeletionAuditedInitializer.Init(entity, "", "张三"); + + // Assert + entity.DeleterId.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.AutoMapper.Tests/Bing/AutoMapper/AutoMapperConfigurationTest.cs b/framework/tests/Bing.AutoMapper.Tests/Bing/AutoMapper/AutoMapperConfigurationTest.cs new file mode 100644 index 00000000..616cf5b1 --- /dev/null +++ b/framework/tests/Bing.AutoMapper.Tests/Bing/AutoMapper/AutoMapperConfigurationTest.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using AutoMapper; +using Bing.ObjectMapping; +using Bing.Reflection; + +namespace Bing.AutoMapper; + +/// +/// AutoMapper 配置验证测试 +/// +public class AutoMapperConfigurationTest +{ + /// + /// 创建配置好的 MapperConfiguration(包含所有 IObjectMapperProfile 实现) + /// + private static MapperConfiguration CreateConfiguration() + { + var allAssemblyFinder = new AppDomainAllAssemblyFinder(); + var profileTypeFinder = new MapperProfileTypeFinder(allAssemblyFinder); + var instances = profileTypeFinder + .FindAll() + .Select(t => Reflections.CreateInstance(t)) + .ToList(); + + var config = new MapperConfiguration(cfg => + { + foreach (var instance in instances) + { + Debug.WriteLine($"Profile: {instance.GetType().FullName}"); + instance.CreateMap(); + cfg.AddProfile(instance as Profile); + } + }); + return config; + } + + #region Profile 发现 + + /// + /// 测试目的:MapperProfileTypeFinder 应在当前 AppDomain 中发现 TestMapperConfiguration。 + /// + [Fact] + public void ProfileTypeFinder_ShouldDiscoverTestMapperConfiguration() + { + // Arrange + var allAssemblyFinder = new AppDomainAllAssemblyFinder(); + var profileTypeFinder = new MapperProfileTypeFinder(allAssemblyFinder); + + // Act + var profileTypes = profileTypeFinder.FindAll(); + + // Assert + profileTypes.ShouldNotBeNull(); + profileTypes.ShouldContain(typeof(TestMapperConfiguration)); + } + + /// + /// 测试目的:发现的所有 Profile 类型均实现了 IObjectMapperProfile 接口。 + /// + [Fact] + public void DiscoveredProfiles_ShouldAllImplementIObjectMapperProfile() + { + // Arrange + var allAssemblyFinder = new AppDomainAllAssemblyFinder(); + var profileTypeFinder = new MapperProfileTypeFinder(allAssemblyFinder); + + // Act + var profileTypes = profileTypeFinder.FindAll(); + + // Assert + foreach (var type in profileTypes) + { + type.IsAssignableTo(typeof(IObjectMapperProfile)).ShouldBeTrue( + $"{type.FullName} 应实现 IObjectMapperProfile 接口"); + } + } + + #endregion + + #region 配置有效性验证 + + /// + /// 测试目的:AssertConfigurationIsValid 不抛出异常,表明所有注册的 Profile 映射配置均有效。 + /// + [Fact] + public void AssertConfigurationIsValid_ShouldNotThrow() + { + // Arrange + var config = CreateConfiguration(); + + // Act & Assert + Should.NotThrow(() => config.AssertConfigurationIsValid()); + } + + /// + /// 测试目的:创建 AutoMapperObjectMapper 实例不应抛出异常(包含所有 Profile)。 + /// + [Fact] + public void CreateAutoMapperObjectMapper_ShouldNotThrow() + { + // Arrange + var allAssemblyFinder = new AppDomainAllAssemblyFinder(); + var profileTypeFinder = new MapperProfileTypeFinder(allAssemblyFinder); + var instances = profileTypeFinder + .FindAll() + .Select(t => Reflections.CreateInstance(t)) + .ToList(); + var config = new MapperConfiguration(cfg => + { + foreach (var instance in instances) + { + instance.CreateMap(); + cfg.AddProfile(instance as Profile); + } + }); + + // Act & Assert + Should.NotThrow(() => new AutoMapperObjectMapper(config, instances)); + } + + #endregion + + #region 自定义 Profile 映射正确性 + + /// + /// 测试目的:TestMapperConfiguration 中 Sample → Sample4 的自定义映射(StringValue + "-1")应正确执行。 + /// + [Fact] + public void CustomMapping_SampleToSample4_ShouldApplyTransform() + { + // Arrange + var config = CreateConfiguration(); + var mapper = config.CreateMapper(); + + var source = new Sample { StringValue = "hello" }; + + // Act + var target = mapper.Map(source); + + // Assert + target.ShouldNotBeNull(); + target.StringValue.ShouldBe("hello-1"); + } + + /// + /// 测试目的:TestMapperConfiguration 中 AutoMapperSourceSample → AutoMapperTargetSample 的映射应正确执行。 + /// + [Fact] + public void CustomMapping_SourceToTarget_ShouldApplyTransform() + { + // Arrange + var config = CreateConfiguration(); + var mapper = config.CreateMapper(); + + var source = new AutoMapperSourceSample { SourceStringValue = "666" }; + + // Act + var target = mapper.Map(source); + + // Assert + target.ShouldNotBeNull(); + target.TargetSampleValue.ShouldBe("666-001"); + } + + /// + /// 测试目的:源属性值为 null 时,映射目标应保持 null 而非抛出异常。 + /// + [Fact] + public void CustomMapping_NullSourceValue_ShouldMapToNullTarget() + { + // Arrange + var config = CreateConfiguration(); + var mapper = config.CreateMapper(); + + var source = new AutoMapperSourceSample { SourceStringValue = null }; + + // Act + var target = mapper.Map(source); + + // Assert + target.ShouldNotBeNull(); + target.TargetSampleValue.ShouldBe("-001"); + } + + #endregion + + #region IObjectMapperProfile 接口方法 + + /// + /// 测试目的:TestMapperConfiguration.CreateMap() 可被独立调用而不抛出异常。 + /// + [Fact] + public void TestMapperConfiguration_CreateMap_ShouldNotThrow() + { + // Arrange + var profile = new TestMapperConfiguration(); + + // Act & Assert + Should.NotThrow(() => profile.CreateMap()); + } + + /// + /// 测试目的:TestMapperConfiguration 同时实现 Profile 和 IObjectMapperProfile 接口。 + /// + [Fact] + public void TestMapperConfiguration_ShouldImplementBothInterfaces() + { + // Arrange & Act + var profile = new TestMapperConfiguration(); + + // Assert + profile.ShouldBeAssignableTo(); + profile.ShouldBeAssignableTo(); + } + + #endregion +} diff --git a/framework/tests/Bing.Caching.Tests/Bing.Caching.Tests.csproj b/framework/tests/Bing.Caching.Tests/Bing.Caching.Tests.csproj new file mode 100644 index 00000000..9e627262 --- /dev/null +++ b/framework/tests/Bing.Caching.Tests/Bing.Caching.Tests.csproj @@ -0,0 +1,12 @@ + + + + + false + + + + + + + diff --git a/framework/tests/Bing.Caching.Tests/CacheAttributeExtensionsTest.cs b/framework/tests/Bing.Caching.Tests/CacheAttributeExtensionsTest.cs new file mode 100644 index 00000000..2680e7c4 --- /dev/null +++ b/framework/tests/Bing.Caching.Tests/CacheAttributeExtensionsTest.cs @@ -0,0 +1,158 @@ +using System; +using System.Linq; +using Bing.Caching; +using Shouldly; +using Xunit; + +namespace Bing.Caching.Tests; + +// ───────────────────────────────────────────────────────────────────────────── +// 测试辅助类型 +// ───────────────────────────────────────────────────────────────────────────── + +/// 带 CacheNameAttribute 的缓存项 +[CacheName("my-custom-cache")] +public class AnnotatedCacheItem { } + +/// 无 CacheNameAttribute,FullName 为 "Bing.Caching.Tests.UserCacheItem" +public class UserCacheItem { } + +/// 无 CacheNameAttribute,FullName 不含 "CacheItem" 后缀 +public class OrderData { } + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class CacheNameAttributeTest +{ + // ═══════════════════════════════════════════════════════════ + // 构造 & 名称验证 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造时传入有效名称,Name 属性应正确读取。 + /// + [Fact] + public void Constructor_WithValidName_ShouldSetName() + { + // Arrange & Act + var attr = new CacheNameAttribute("user-cache"); + + // Assert + attr.Name.ShouldBe("user-cache"); + } + + /// + /// 测试目的:构造时传入 null 名称,应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WithNullName_ShouldThrowArgumentNullException() + { + Should.Throw(() => new CacheNameAttribute(null!)); + } + + // ═══════════════════════════════════════════════════════════ + // GetCacheName — 有 Attribute + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetCacheName<T> 对有 [CacheName] 特性的类型,应返回特性指定的名称。 + /// + [Fact] + public void GetCacheName_WhenAttributePresent_ShouldReturnAttributeName() + { + var name = CacheNameAttribute.GetCacheName(); + name.ShouldBe("my-custom-cache"); + } + + /// + /// 测试目的:GetCacheName(Type) 对有特性的类型,应返回特性指定名称。 + /// + [Fact] + public void GetCacheName_ByType_WhenAttributePresent_ShouldReturnAttributeName() + { + var name = CacheNameAttribute.GetCacheName(typeof(AnnotatedCacheItem)); + name.ShouldBe("my-custom-cache"); + } + + // ═══════════════════════════════════════════════════════════ + // GetCacheName — 无 Attribute,按 FullName 剥除 "CacheItem" 后缀 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:无 [CacheName] 的类型,名称应为 FullName 去除 "CacheItem" 后缀。 + /// + [Fact] + public void GetCacheName_WhenNoAttribute_ShouldUseFallbackWithoutSuffix() + { + // UserCacheItem → FullName 去除 "CacheItem" → "Bing.Caching.Tests.User" + var name = CacheNameAttribute.GetCacheName(); + name.ShouldBe("Bing.Caching.Tests.User"); + } + + /// + /// 测试目的:无 [CacheName] 且 FullName 不含 "CacheItem" 后缀,应直接返回完整 FullName。 + /// + [Fact] + public void GetCacheName_WhenNoAttribute_AndNoSuffix_ShouldReturnFullName() + { + var name = CacheNameAttribute.GetCacheName(); + name.ShouldBe("Bing.Caching.Tests.OrderData"); + } +} + +/// +/// 单元测试 +/// +public class CacheKeyExtensionsTest +{ + // ═══════════════════════════════════════════════════════════ + // Validate — 正常路径 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:有效 CacheKey 调用 Validate() 不应抛任何异常。 + /// + [Fact] + public void Validate_WithValidKey_ShouldNotThrow() + { + var key = new CacheKey("user:1"); + Should.NotThrow(() => key.Validate()); + } + + // ═══════════════════════════════════════════════════════════ + // Validate — 异常路径 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:null CacheKey 调用 Validate() 应抛出 ArgumentNullException。 + /// + [Fact] + public void Validate_WithNullCacheKey_ShouldThrowArgumentNullException() + { + CacheKey key = null; + Should.Throw(() => key.Validate()); + } + + /// + /// 测试目的:Key 为空字符串的 CacheKey 调用 Validate() 应抛出 ArgumentNullException。 + /// + [Fact] + public void Validate_WithEmptyKey_ShouldThrowArgumentNullException() + { + var key = new CacheKey(string.Empty); + Should.Throw(() => key.Validate()); + } + + /// + /// 测试目的:Key 为纯空格的 CacheKey 调用 Validate() 应抛出 ArgumentNullException。 + /// + [Fact] + public void Validate_WithWhitespaceKey_ShouldThrowArgumentNullException() + { + var key = new CacheKey(" "); + Should.Throw(() => key.Validate()); + } +} diff --git a/framework/tests/Bing.Caching.Tests/CacheKeyAndExtensionsTest.cs b/framework/tests/Bing.Caching.Tests/CacheKeyAndExtensionsTest.cs new file mode 100644 index 00000000..86b28a8e --- /dev/null +++ b/framework/tests/Bing.Caching.Tests/CacheKeyAndExtensionsTest.cs @@ -0,0 +1,158 @@ +using Shouldly; +using Xunit; + +namespace Bing.Caching.Tests; + +/// +/// ToString / Prefix 行为补充测试,以及 +/// 边界测试, +/// / 类型层次测试 +/// +public class CacheKeyAndExtensionsTest +{ + // ═══════════════════════════════════════════════════════════ + // CacheKey — ToString / Prefix 组合 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:无参构造后 Key / Prefix 均为空,ToString() 返回空字符串, + /// 确保默认状态不携带意外值。 + /// + [Fact] + public void CacheKey_DefaultConstructor_KeyAndPrefixShouldBeEmpty() + { + // Act + var key = new CacheKey(); + + // Assert + key.Key.ShouldBe(string.Empty); + key.Prefix.ShouldBeNull(); + key.ToString().ShouldBe(string.Empty); + } + + /// + /// 测试目的:直接对 Key 属性赋值后,ToString() 应返回 Prefix+Key, + /// 确保 setter 路径与构造器路径行为一致。 + /// + [Fact] + public void CacheKey_SetKey_ToStringShouldReturnPrefixPlusKey() + { + // Arrange + var key = new CacheKey { Key = "my-key", Prefix = "ns:" }; + + // Assert + key.ToString().ShouldBe("ns:my-key"); + key.Key.ShouldBe("ns:my-key"); + } + + /// + /// 测试目的:仅设置 Key 不设置 Prefix 时,ToString() 应只返回 Key, + /// 确保 Prefix 为 null 时不拼接额外字符。 + /// + [Fact] + public void CacheKey_WithKeyOnly_NoPrefix_ToStringShouldBeKeyOnly() + { + // Arrange + var key = new CacheKey("order:{0}", 123); + + // Assert — Prefix 为 null,ToString() = "" + "order:123" + key.ToString().ShouldBe("order:123"); + } + + // ═══════════════════════════════════════════════════════════ + // CacheKeyExtensions.Validate + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Validate(null) 应抛出 ArgumentNullException, + /// 防止 null CacheKey 传入缓存操作导致运行时错误。 + /// + [Fact] + public void Validate_NullCacheKey_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + ((CacheKey)null).Validate()); + } + + /// + /// 测试目的:Key 为空白字符串时,Validate 应抛出 ArgumentNullException, + /// 防止空键被写入缓存存储层。 + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Validate_EmptyOrWhitespaceKey_ShouldThrowArgumentNullException(string rawKey) + { + // Arrange + var key = new CacheKey { Key = rawKey }; + + // Act & Assert + Should.Throw(() => key.Validate()); + } + + /// + /// 测试目的:Key 有效时,Validate 应不抛异常, + /// 确保正常场景不被拦截。 + /// + [Fact] + public void Validate_ValidKey_ShouldNotThrow() + { + // Arrange + var key = new CacheKey("valid-key"); + + // Act & Assert + Should.NotThrow(() => key.Validate()); + } + + /// + /// 测试目的:Key 由 Prefix 拼接后非空时,Validate 应不抛异常, + /// 确保前缀参与组合后的键依然被认为有效。 + /// + [Fact] + public void Validate_KeyWithPrefix_ShouldNotThrow() + { + // Arrange — Prefix 拼入后 Key getter 返回 "ns:item" + var key = new CacheKey { Key = "item", Prefix = "ns:" }; + + // Act & Assert + Should.NotThrow(() => key.Validate()); + } + + // ═══════════════════════════════════════════════════════════ + // ILocalCache / IRedisCache — 类型层次 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ILocalCache 应继承自 ICache, + /// 确保本地缓存实现可被 ICache 接口统一引用。 + /// + [Fact] + public void ILocalCache_ShouldExtendICache() + { + // Assert + typeof(ICache).IsAssignableFrom(typeof(ILocalCache)).ShouldBeTrue(); + } + + /// + /// 测试目的:IRedisCache 应继承自 ICache, + /// 确保 Redis 缓存实现可被 ICache 接口统一引用。 + /// + [Fact] + public void IRedisCache_ShouldExtendICache() + { + // Assert + typeof(ICache).IsAssignableFrom(typeof(IRedisCache)).ShouldBeTrue(); + } + + /// + /// 测试目的:ILocalCache 与 IRedisCache 是不同类型, + /// 确保两者可在 DI 容器中分别注册和解析。 + /// + [Fact] + public void ILocalCache_And_IRedisCache_ShouldBeDifferentTypes() + { + // Assert + typeof(ILocalCache).ShouldNotBe(typeof(IRedisCache)); + } +} diff --git a/framework/tests/Bing.Caching.Tests/CachingTests.cs b/framework/tests/Bing.Caching.Tests/CachingTests.cs new file mode 100644 index 00000000..cd54c19e --- /dev/null +++ b/framework/tests/Bing.Caching.Tests/CachingTests.cs @@ -0,0 +1,241 @@ +using Bing.Caching; +using Shouldly; +using Xunit; + +namespace Bing.Caching.Tests; + +/// +/// 单元测试 +/// +public class CacheKeyTest +{ + /// + /// 测试目的:CacheKey 字符串构造后 Key 应等于传入字符串。 + /// + [Fact] + public void CacheKey_WithSimpleString_ShouldReturnSameKey() + { + // Arrange & Act + var key = new CacheKey("user:profile"); + + // Assert + key.Key.ShouldBe("user:profile"); + } + + /// + /// 测试目的:CacheKey 使用格式化参数时,Key 应正确替换占位符。 + /// + [Fact] + public void CacheKey_WithFormatParameters_ShouldFormatCorrectly() + { + // Arrange & Act + var key = new CacheKey("user:{0}:orders:{1}", "u-001", 42); + + // Assert + key.Key.ShouldBe("user:u-001:orders:42"); + } + + /// + /// 测试目的:设置 Prefix 后,ToString()/Key 应返回 Prefix + Key 的组合。 + /// + [Fact] + public void CacheKey_WithPrefix_ShouldPrependPrefixToKey() + { + // Arrange & Act + var key = new CacheKey("profile") + { + Prefix = "dev:" + }; + + // Assert + key.Key.ShouldBe("dev:profile"); + key.ToString().ShouldBe("dev:profile"); + } + + /// + /// 测试目的:无 Prefix 时,ToString 应直接返回键值,不含多余字符。 + /// + [Fact] + public void CacheKey_WithoutPrefix_ShouldReturnKeyOnly() + { + // Arrange & Act + var key = new CacheKey("simple-key"); + + // Assert + key.ToString().ShouldBe("simple-key"); + } +} + +/// +/// 单元测试。 +/// NullCache 是一个纯内存空实现,用于测试和不需要实际缓存的场景。 +/// +public class NullCacheTest +{ + private readonly ILocalCache _cache = NullCache.Instance; + + // ── Exists ───────────────────────────────────────────────────── + + /// + /// 测试目的:NullCache.Exists 对任何键都应返回 false(空缓存永远不存在数据)。 + /// + [Fact] + public void Exists_WithAnyKey_ShouldReturnFalse() + { + _cache.Exists("any_key").ShouldBeFalse(); + _cache.Exists(new CacheKey("any_key")).ShouldBeFalse(); + } + + // ── Get ──────────────────────────────────────────────────────── + + /// + /// 测试目的:NullCache.Get{T}() 应返回类型 T 的默认值,不抛异常。 + /// + [Fact] + public void Get_WithAnyKey_ShouldReturnDefault() + { + _cache.Get("key").ShouldBeNull(); + _cache.Get("key").ShouldBe(0); + _cache.Get("key").ShouldBe(default(DateTime)); + } + + /// + /// 测试目的:NullCache.Get{T} 带有 dataRetriever 时应直接调用 retriever 并返回其值。 + /// + [Fact] + public void Get_WithDataRetriever_ShouldInvokeRetriever() + { + // Arrange + var callCount = 0; + Func retriever = () => { callCount++; return "fresh-value"; }; + + // Act + var result = _cache.Get("key", retriever); + + // Assert + result.ShouldBe("fresh-value"); + callCount.ShouldBe(1); + } + + /// + /// 测试目的:NullCache.Get{T} dataRetriever 为 null 时应返回默认值,不抛异常。 + /// + [Fact] + public void Get_WithNullRetriever_ShouldReturnDefault() + { + // Act & Assert + Should.NotThrow(() => + { + var result = _cache.Get("key", (Func)null); + result.ShouldBeNull(); + }); + } + + // ── TrySet ───────────────────────────────────────────────────── + + /// + /// 测试目的:NullCache.TrySet 应始终返回 false(数据不被真正存储)。 + /// + [Fact] + public void TrySet_WithAnyValue_ShouldReturnFalse() + { + _cache.TrySet("key", "value").ShouldBeFalse(); + _cache.TrySet(new CacheKey("key"), 42).ShouldBeFalse(); + } + + // ── Remove ───────────────────────────────────────────────────── + + /// + /// 测试目的:NullCache.Remove 不抛异常(幂等,安全调用)。 + /// + [Fact] + public void Remove_WithAnyKey_ShouldNotThrow() + { + Should.NotThrow(() => _cache.Remove("nonexistent_key")); + Should.NotThrow(() => _cache.Remove(new CacheKey("nonexistent_key"))); + } + + // ── Async ───────────────────────────────────────────────────── + + /// + /// 测试目的:ExistsAsync 对任何键都应返回 false。 + /// + [Fact] + public async Task ExistsAsync_WithAnyKey_ShouldReturnFalse() + { + (await _cache.ExistsAsync("key")).ShouldBeFalse(); + (await _cache.ExistsAsync(new CacheKey("key"))).ShouldBeFalse(); + } + + /// + /// 测试目的:GetAsync{T}() 应返回默认值。 + /// + [Fact] + public async Task GetAsync_WithAnyKey_ShouldReturnDefault() + { + (await _cache.GetAsync("key")).ShouldBeNull(); + (await _cache.GetAsync("key")).ShouldBe(0); + } + + /// + /// 测试目的:GetAsync{T}() 带有 dataRetriever 时应直接调用并返回其值。 + /// + [Fact] + public async Task GetAsync_WithDataRetriever_ShouldInvokeRetriever() + { + // Arrange + var callCount = 0; + Func> retriever = () => { callCount++; return Task.FromResult("async-value"); }; + + // Act + var result = await _cache.GetAsync("key", retriever); + + // Assert + result.ShouldBe("async-value"); + callCount.ShouldBe(1); + } + + // ── Instance 单例 ───────────────────────────────────────────── + + /// + /// 测试目的:NullCache.Instance 应为单例,多次访问返回同一引用。 + /// + [Fact] + public void Instance_ShouldBeSingleton() + { + ReferenceEquals(NullCache.Instance, NullCache.Instance).ShouldBeTrue(); + } +} + +/// +/// 单元测试 +/// +public class CacheOptionsTest +{ + /// + /// 测试目的:默认 CacheOptions 的 Expiration 应为 null(无强制过期时间)。 + /// + [Fact] + public void Default_ExpirationShouldBeNull() + { + // Arrange & Act + var options = new CacheOptions(); + + // Assert + options.Expiration.ShouldBeNull(); + } + + /// + /// 测试目的:设置 Expiration 后应能正确读取。 + /// + [Fact] + public void Expiration_WhenSet_ShouldBeReadable() + { + // Arrange + var expiry = TimeSpan.FromMinutes(30); + var options = new CacheOptions { Expiration = expiry }; + + // Assert + options.Expiration.ShouldBe(expiry); + } +} diff --git a/framework/tests/Bing.Caching.Tests/NullCacheAndOptionsTest.cs b/framework/tests/Bing.Caching.Tests/NullCacheAndOptionsTest.cs new file mode 100644 index 00000000..185356c2 --- /dev/null +++ b/framework/tests/Bing.Caching.Tests/NullCacheAndOptionsTest.cs @@ -0,0 +1,636 @@ +using Bing.Caching; +using Shouldly; +using Xunit; + +namespace Bing.Caching.Tests; + +/// +/// 单元测试 — 验证空缓存所有方法的默认行为(不抛异常、返回默认值/false/空集合) +/// +public class NullCacheTest +{ + private readonly ILocalCache _cache = NullCache.Instance; + private readonly CacheKey _key = new("test:key"); + + /// + /// 测试目的:NullCache.Instance 为静态单例,不应为 null。 + /// + [Fact] + public void Instance_ShouldNotBeNull() + { + // Assert + NullCache.Instance.ShouldNotBeNull(); + } + + /// + /// 测试目的:多次获取 Instance 应返回同一引用。 + /// + [Fact] + public void Instance_ShouldBeSameReference() + { + // Assert + NullCache.Instance.ShouldBeSameAs(NullCache.Instance); + } + + /// + /// 测试目的:Exists(CacheKey) 始终返回 false,表明缓存为空。 + /// + [Fact] + public void Exists_WithCacheKey_ShouldReturnFalse() + { + // Act & Assert + _cache.Exists(_key).ShouldBeFalse(); + } + + /// + /// 测试目的:Exists(string) 始终返回 false。 + /// + [Fact] + public void Exists_WithStringKey_ShouldReturnFalse() + { + // Act & Assert + _cache.Exists("test:key").ShouldBeFalse(); + } + + /// + /// 测试目的:ExistsAsync(CacheKey) 始终返回 false。 + /// + [Fact] + public async Task ExistsAsync_WithCacheKey_ShouldReturnFalse() + { + // Act & Assert + (await _cache.ExistsAsync(_key)).ShouldBeFalse(); + } + + /// + /// 测试目的:ExistsAsync(string) 始终返回 false。 + /// + [Fact] + public async Task ExistsAsync_WithStringKey_ShouldReturnFalse() + { + // Act & Assert + (await _cache.ExistsAsync("test:key")).ShouldBeFalse(); + } + + /// + /// 测试目的:Get<T>(CacheKey) 始终返回类型默认值(引用类型为 null,值类型为 0/false 等)。 + /// + [Fact] + public void Get_WithCacheKey_ShouldReturnDefault() + { + // Act + var result = _cache.Get(_key); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:Get<T>(string) 始终返回类型默认值。 + /// + [Fact] + public void Get_WithStringKey_ShouldReturnDefault() + { + // Act + var result = _cache.Get("test:key"); + + // Assert + result.ShouldBe(0); + } + + /// + /// 测试目的:Get<T>(CacheKey, Func) 当 dataRetriever 不为 null 时,应调用并返回其结果。 + /// + [Fact] + public void Get_WithCacheKeyAndRetriever_ShouldCallRetriever() + { + // Act + var result = _cache.Get(_key, () => "hello"); + + // Assert + result.ShouldBe("hello"); + } + + /// + /// 测试目的:Get<T>(CacheKey, null) 当 dataRetriever 为 null 时,应返回默认值,不抛异常。 + /// + [Fact] + public void Get_WithCacheKeyAndNullRetriever_ShouldReturnDefault() + { + // Act + var result = _cache.Get(_key, (Func)null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:Get<T>(string, Func) 当 dataRetriever 不为 null 时,应调用并返回其结果。 + /// + [Fact] + public void Get_WithStringKeyAndRetriever_ShouldCallRetriever() + { + // Act + var result = _cache.Get("test:key", () => 42); + + // Assert + result.ShouldBe(42); + } + + /// + /// 测试目的:Get<T>(string, null) 当 dataRetriever 为 null 时,应返回默认值。 + /// + [Fact] + public void Get_WithStringKeyAndNullRetriever_ShouldReturnDefault() + { + // Act + var result = _cache.Get("test:key", (Func)null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetAsync(string, Type) 始终返回 null(Task 结果为 null)。 + /// + [Fact] + public async Task GetAsync_WithStringKeyAndType_ShouldReturnNull() + { + // Act + var result = await _cache.GetAsync("test:key", typeof(string)); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetAsync<T>(CacheKey) 始终返回默认值。 + /// + [Fact] + public async Task GetAsync_WithCacheKey_ShouldReturnDefault() + { + // Act + var result = await _cache.GetAsync(_key); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetAsync<T>(string) 始终返回默认值。 + /// + [Fact] + public async Task GetAsync_WithStringKey_ShouldReturnDefault() + { + // Act + var result = await _cache.GetAsync("test:key"); + + // Assert + result.ShouldBe(0); + } + + /// + /// 测试目的:GetAsync<T>(CacheKey, Func<Task<T>>) 当 dataRetriever 不为 null 时,应调用并返回其结果。 + /// + [Fact] + public async Task GetAsync_WithCacheKeyAndRetriever_ShouldCallRetriever() + { + // Act + var result = await _cache.GetAsync(_key, () => Task.FromResult("async-value")); + + // Assert + result.ShouldBe("async-value"); + } + + /// + /// 测试目的:GetAsync<T>(CacheKey, null) 当 dataRetriever 为 null 时,应返回默认值。 + /// + [Fact] + public async Task GetAsync_WithCacheKeyAndNullRetriever_ShouldReturnDefault() + { + // Act + var result = await _cache.GetAsync(_key, (Func>)null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetAsync<T>(string, Func<Task<T>>) 当 dataRetriever 不为 null 时,应调用并返回其结果。 + /// + [Fact] + public async Task GetAsync_WithStringKeyAndRetriever_ShouldCallRetriever() + { + // Act + var result = await _cache.GetAsync("test:key", () => Task.FromResult(99)); + + // Assert + result.ShouldBe(99); + } + + /// + /// 测试目的:GetAll<T>(IEnumerable<CacheKey>) 始终返回空列表,不抛异常。 + /// + [Fact] + public void GetAll_WithCacheKeys_ShouldReturnEmptyList() + { + // Act + var result = _cache.GetAll(new[] { _key }); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetAll<T>(IEnumerable<string>) 始终返回空列表。 + /// + [Fact] + public void GetAll_WithStringKeys_ShouldReturnEmptyList() + { + // Act + var result = _cache.GetAll(new[] { "k1", "k2" }); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetAllAsync<T>(IEnumerable<CacheKey>) 始终返回空列表。 + /// + [Fact] + public async Task GetAllAsync_WithCacheKeys_ShouldReturnEmptyList() + { + // Act + var result = await _cache.GetAllAsync(new[] { _key }); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetByPrefix<T> 始终返回空列表。 + /// + [Fact] + public void GetByPrefix_ShouldReturnEmptyList() + { + // Act + var result = _cache.GetByPrefix("prefix:"); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetByPrefixAsync<T> 始终返回空列表。 + /// + [Fact] + public async Task GetByPrefixAsync_ShouldReturnEmptyList() + { + // Act + var result = await _cache.GetByPrefixAsync("prefix:"); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:TrySet<T>(CacheKey, value) 始终返回 false(不存储任何数据)。 + /// + [Fact] + public void TrySet_WithCacheKey_ShouldReturnFalse() + { + // Act & Assert + _cache.TrySet(_key, "value").ShouldBeFalse(); + } + + /// + /// 测试目的:TrySet<T>(string, value) 始终返回 false。 + /// + [Fact] + public void TrySet_WithStringKey_ShouldReturnFalse() + { + // Act & Assert + _cache.TrySet("test:key", "value").ShouldBeFalse(); + } + + /// + /// 测试目的:TrySetAsync<T>(CacheKey, value) 始终返回 false。 + /// + [Fact] + public async Task TrySetAsync_WithCacheKey_ShouldReturnFalse() + { + // Act & Assert + (await _cache.TrySetAsync(_key, "value")).ShouldBeFalse(); + } + + /// + /// 测试目的:TrySetAsync<T>(string, value) 始终返回 false。 + /// + [Fact] + public async Task TrySetAsync_WithStringKey_ShouldReturnFalse() + { + // Act & Assert + (await _cache.TrySetAsync("test:key", "value")).ShouldBeFalse(); + } + + /// + /// 测试目的:Set<T>(CacheKey) 不应抛任何异常(空操作)。 + /// + [Fact] + public void Set_WithCacheKey_ShouldNotThrow() + { + // Act & Assert(调用不抛异常即通过) + Should.NotThrow(() => _cache.Set(_key, "value")); + } + + /// + /// 测试目的:Set<T>(string) 不应抛任何异常。 + /// + [Fact] + public void Set_WithStringKey_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.Set("test:key", "value")); + } + + /// + /// 测试目的:SetAsync<T>(CacheKey) 不应抛任何异常,并返回 CompletedTask。 + /// + [Fact] + public async Task SetAsync_WithCacheKey_ShouldNotThrow() + { + // Act & Assert + await Should.NotThrowAsync(() => _cache.SetAsync(_key, "value")); + } + + /// + /// 测试目的:SetAll<T>(IDictionary<CacheKey, T>) 不应抛任何异常。 + /// + [Fact] + public void SetAll_WithCacheKeys_ShouldNotThrow() + { + // Arrange + var items = new Dictionary { [_key] = "value" }; + + // Act & Assert + Should.NotThrow(() => _cache.SetAll(items)); + } + + /// + /// 测试目的:SetAll<T>(IDictionary<string, T>) 不应抛任何异常。 + /// + [Fact] + public void SetAll_WithStringKeys_ShouldNotThrow() + { + // Arrange + var items = new Dictionary { ["k1"] = "v1" }; + + // Act & Assert + Should.NotThrow(() => _cache.SetAll(items)); + } + + /// + /// 测试目的:Remove(CacheKey) 不应抛任何异常。 + /// + [Fact] + public void Remove_WithCacheKey_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.Remove(_key)); + } + + /// + /// 测试目的:Remove(string) 不应抛任何异常。 + /// + [Fact] + public void Remove_WithStringKey_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.Remove("test:key")); + } + + /// + /// 测试目的:RemoveAll(IEnumerable<CacheKey>) 不应抛任何异常。 + /// + [Fact] + public void RemoveAll_WithCacheKeys_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.RemoveAll(new[] { _key })); + } + + /// + /// 测试目的:RemoveAll(IEnumerable<string>) 不应抛任何异常。 + /// + [Fact] + public void RemoveAll_WithStringKeys_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.RemoveAll(new[] { "k1", "k2" })); + } + + /// + /// 测试目的:RemoveByPrefix 不应抛任何异常(空操作)。 + /// + [Fact] + public void RemoveByPrefix_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.RemoveByPrefix("prefix:")); + } + + /// + /// 测试目的:RemoveByPrefixAsync 不应抛任何异常。 + /// + [Fact] + public async Task RemoveByPrefixAsync_ShouldNotThrow() + { + // Act & Assert + await Should.NotThrowAsync(() => _cache.RemoveByPrefixAsync("prefix:")); + } + + /// + /// 测试目的:RemoveByPattern 不应抛任何异常(空操作)。 + /// + [Fact] + public void RemoveByPattern_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.RemoveByPattern("*")); + } + + /// + /// 测试目的:RemoveByPatternAsync 不应抛任何异常。 + /// + [Fact] + public async Task RemoveByPatternAsync_ShouldNotThrow() + { + // Act & Assert + await Should.NotThrowAsync(() => _cache.RemoveByPatternAsync("*")); + } + + /// + /// 测试目的:Clear() 不应抛任何异常(空操作)。 + /// + [Fact] + public void Clear_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _cache.Clear()); + } + + /// + /// 测试目的:ClearAsync() 不应抛任何异常,返回 CompletedTask。 + /// + [Fact] + public async Task ClearAsync_ShouldNotThrow() + { + // Act & Assert + await Should.NotThrowAsync(() => _cache.ClearAsync()); + } +} + +/// +/// 单元测试 — 验证默认值与属性赋值行为 +/// +public class CacheOptionsTest +{ + /// + /// 测试目的:CacheOptions 默认构造后,Expiration 应为 null(不设过期时间)。 + /// + [Fact] + public void Default_Expiration_ShouldBeNull() + { + // Act + var options = new CacheOptions(); + + // Assert + options.Expiration.ShouldBeNull(); + } + + /// + /// 测试目的:设置 Expiration 后,应能正确读回。 + /// + [Fact] + public void SetExpiration_ShouldBeReadBack() + { + // Arrange + var expiration = TimeSpan.FromHours(8); + + // Act + var options = new CacheOptions { Expiration = expiration }; + + // Assert + options.Expiration.ShouldBe(expiration); + } + + /// + /// 测试目的:Expiration 可被设置为零(立即过期语义)。 + /// + [Fact] + public void SetExpiration_ToZero_ShouldBeReadBack() + { + // Act + var options = new CacheOptions { Expiration = TimeSpan.Zero }; + + // Assert + options.Expiration.ShouldBe(TimeSpan.Zero); + } +} + +/// +/// 单元测试 — 验证构造、Name 属性及 GetCacheName 静态方法逻辑 +/// +public class CacheNameAttributeTest +{ + /// + /// 测试目的:构造 CacheNameAttribute 并传入有效名称,Name 属性应等于传入值。 + /// + [Fact] + public void Constructor_WithValidName_ShouldStoreName() + { + // Act + var attr = new CacheNameAttribute("user-cache"); + + // Assert + attr.Name.ShouldBe("user-cache"); + } + + /// + /// 测试目的:构造 CacheNameAttribute 时传入 null,应抛出异常,Guard 会阻止无效状态。 + /// + [Fact] + public void Constructor_WithNullName_ShouldThrow() + { + // Act & Assert + Should.Throw(() => new CacheNameAttribute(null)); + } + + /// + /// 测试目的:GetCacheName<TCacheItem> 泛型版本应与 GetCacheName(Type) 非泛型版本返回相同结果。 + /// + [Fact] + public void GetCacheName_GenericAndNonGeneric_ShouldBeEqual() + { + // Act + var fromGeneric = CacheNameAttribute.GetCacheName(); + var fromType = CacheNameAttribute.GetCacheName(typeof(AnnotatedCacheItem)); + + // Assert + fromGeneric.ShouldBe(fromType); + } + + /// + /// 测试目的:有 [CacheNameAttribute] 标注的类型,GetCacheName 应返回特性中指定的名称。 + /// + [Fact] + public void GetCacheName_WhenAttributePresent_ShouldReturnAttributeName() + { + // Act + var name = CacheNameAttribute.GetCacheName(); + + // Assert + name.ShouldBe("custom-name"); + } + + /// + /// 测试目的:无 [CacheNameAttribute] 标注的类型,GetCacheName 应返回 FullName 去除 "CacheItem" 后缀的结果。 + /// + [Fact] + public void GetCacheName_WhenNoAttribute_ShouldReturnFullNameWithoutCacheItemSuffix() + { + // Act + var name = CacheNameAttribute.GetCacheName(); + + // Assert + // FullName = "Bing.Caching.Tests.UnannotatedCacheItem", 去掉 "CacheItem" 后缀 + name.ShouldBe(typeof(UnannotatedCacheItem).FullName!.Replace("CacheItem", "")); + } + + /// + /// 测试目的:无后缀的普通类(不以 "CacheItem" 结尾),GetCacheName 应返回完整 FullName。 + /// + [Fact] + public void GetCacheName_WhenNoAttributeAndNoSuffix_ShouldReturnFullName() + { + // Act + var name = CacheNameAttribute.GetCacheName(); + + // Assert + name.ShouldBe(typeof(PlainEntity).FullName); + } +} + +// ─── 测试用辅助类型 ─────────────────────────────────────────────────── + +[CacheName("custom-name")] +internal class AnnotatedCacheItem { } + +internal class UnannotatedCacheItem { } + +internal class PlainEntity { } diff --git a/framework/tests/Bing.Core.Tests/Collections/TypeListTest.cs b/framework/tests/Bing.Core.Tests/Collections/TypeListTest.cs new file mode 100644 index 00000000..fd7dcd6e --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Collections/TypeListTest.cs @@ -0,0 +1,368 @@ +using Bing.Collections; +using Shouldly; + +namespace Bing.Tests.Collections; + +/// +/// TypeList 类型列表 测试 +/// +public class TypeListTest +{ + // ==================== 辅助接口/类 ==================== + + private interface IAnimal { } + private class Dog : IAnimal { } + private class Cat : IAnimal { } + private class Fish : IAnimal { } + + // ==================== Count / IsReadOnly ==================== + + /// + /// 测试目的:新建空列表,Count 应为 0,IsReadOnly 应为 false。 + /// + [Fact] + public void Count_EmptyList_IsZero() + { + // Arrange & Act + var list = new TypeList(); + + // Assert + list.Count.ShouldBe(0); + list.IsReadOnly.ShouldBeFalse(); + } + + // ==================== Add (泛型) ==================== + + /// + /// 测试目的:通过泛型 Add<T> 添加合法类型后,Count 应增加,Contains<T> 应返回 true。 + /// + [Fact] + public void Add_Generic_ValidType_IncreasesCountAndContains() + { + // Arrange + var list = new TypeList(); + + // Act + list.Add(); + + // Assert + list.Count.ShouldBe(1); + list.Contains().ShouldBeTrue(); + } + + // ==================== Add (Type) ==================== + + /// + /// 测试目的:通过 Add(Type) 添加合法类型,与泛型 Add 效果相同。 + /// + [Fact] + public void Add_Type_ValidType_Works() + { + // Arrange + var list = new TypeList(); + + // Act + list.Add(typeof(Cat)); + + // Assert + list.Contains(typeof(Cat)).ShouldBeTrue(); + } + + /// + /// 测试目的:通过 Add(Type) 添加不兼容类型,应抛出 ArgumentException。 + /// + [Fact] + public void Add_Type_IncompatibleType_ThrowsArgumentException() + { + // Arrange + var list = new TypeList(); + + // Act & Assert + Should.Throw(() => list.Add(typeof(string))); + } + + // ==================== TryAdd ==================== + + /// + /// 测试目的:TryAdd 新类型应返回 true 并添加成功。 + /// + [Fact] + public void TryAdd_NewType_ReturnsTrueAndAdds() + { + // Arrange + var list = new TypeList(); + + // Act + var result = list.TryAdd(); + + // Assert + result.ShouldBeTrue(); + list.Contains().ShouldBeTrue(); + } + + /// + /// 测试目的:TryAdd 已存在类型应返回 false,不重复添加。 + /// + [Fact] + public void TryAdd_DuplicateType_ReturnsFalseAndDoesNotDuplicate() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act + var result = list.TryAdd(); + + // Assert + result.ShouldBeFalse(); + list.Count.ShouldBe(1); + } + + // ==================== Contains ==================== + + /// + /// 测试目的:未添加的类型,Contains<T> 应返回 false。 + /// + [Fact] + public void Contains_NotAdded_ReturnsFalse() + { + // Arrange + var list = new TypeList(); + + // Act & Assert + list.Contains().ShouldBeFalse(); + list.Contains(typeof(Dog)).ShouldBeFalse(); + } + + // ==================== Remove ==================== + + /// + /// 测试目的:泛型 Remove<T> 应从列表中删除该类型。 + /// + [Fact] + public void Remove_Generic_RemovesType() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act + list.Remove(); + + // Assert + list.Contains().ShouldBeFalse(); + list.Count.ShouldBe(0); + } + + /// + /// 测试目的:Remove(Type) 删除已添加类型应返回 true;删除不存在类型应返回 false。 + /// + [Fact] + public void Remove_Type_ExistingReturnsTrue_MissingReturnsFalse() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act & Assert + list.Remove(typeof(Dog)).ShouldBeTrue(); + list.Remove(typeof(Cat)).ShouldBeFalse(); + } + + // ==================== RemoveAt ==================== + + /// + /// 测试目的:RemoveAt(0) 应移除第一个元素。 + /// + [Fact] + public void RemoveAt_Index_RemovesCorrectElement() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + + // Act + list.RemoveAt(0); + + // Assert + list.Count.ShouldBe(1); + list.Contains().ShouldBeFalse(); + list.Contains().ShouldBeTrue(); + } + + // ==================== Clear ==================== + + /// + /// 测试目的:Clear 后 Count 应变为 0,所有元素都消失。 + /// + [Fact] + public void Clear_RemovesAllTypes() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + + // Act + list.Clear(); + + // Assert + list.Count.ShouldBe(0); + list.Contains().ShouldBeFalse(); + } + + // ==================== IndexOf ==================== + + /// + /// 测试目的:IndexOf 返回正确的索引位置;未找到返回 -1。 + /// + [Fact] + public void IndexOf_ReturnsCorrectIndex() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + + // Act & Assert + list.IndexOf(typeof(Dog)).ShouldBe(0); + list.IndexOf(typeof(Cat)).ShouldBe(1); + list.IndexOf(typeof(Fish)).ShouldBe(-1); + } + + // ==================== Indexer ==================== + + /// + /// 测试目的:通过索引器可以正确读取类型。 + /// + [Fact] + public void Indexer_Get_ReturnsCorrectType() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + + // Act & Assert + list[0].ShouldBe(typeof(Dog)); + list[1].ShouldBe(typeof(Cat)); + } + + /// + /// 测试目的:通过索引器设置合法类型应成功;设置不兼容类型应抛出 ArgumentException。 + /// + [Fact] + public void Indexer_Set_ValidType_Succeeds_InvalidType_Throws() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act: 用兼容类型替换 + list[0] = typeof(Cat); + list[0].ShouldBe(typeof(Cat)); + + // Assert: 不兼容类型抛出 + Should.Throw(() => list[0] = typeof(int)); + } + + // ==================== Insert ==================== + + /// + /// 测试目的:Insert 在指定位置插入类型,后续元素向后移动。 + /// + [Fact] + public void Insert_AtIndex_ShiftsElements() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act + list.Insert(0, typeof(Dog)); + + // Assert + list[0].ShouldBe(typeof(Dog)); + list[1].ShouldBe(typeof(Cat)); + } + + /// + /// 测试目的:Insert 不兼容类型应抛出 ArgumentException。 + /// + [Fact] + public void Insert_IncompatibleType_ThrowsArgumentException() + { + // Arrange + var list = new TypeList(); + list.Add(); + + // Act & Assert + Should.Throw(() => list.Insert(0, typeof(string))); + } + + // ==================== CopyTo ==================== + + /// + /// 测试目的:CopyTo 将所有元素复制到目标数组。 + /// + [Fact] + public void CopyTo_CopiesAllTypesToArray() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + var array = new Type[2]; + + // Act + list.CopyTo(array, 0); + + // Assert + array[0].ShouldBe(typeof(Dog)); + array[1].ShouldBe(typeof(Cat)); + } + + // ==================== IEnumerable ==================== + + /// + /// 测试目的:通过 foreach 可以枚举所有类型,顺序与添加顺序一致。 + /// + [Fact] + public void GetEnumerator_EnumeratesAllTypesInOrder() + { + // Arrange + var list = new TypeList(); + list.Add(); + list.Add(); + list.Add(); + + // Act + var result = list.ToList(); + + // Assert + result.Count.ShouldBe(3); + result[0].ShouldBe(typeof(Dog)); + result[1].ShouldBe(typeof(Cat)); + result[2].ShouldBe(typeof(Fish)); + } + + // ==================== 非泛型 TypeList ==================== + + /// + /// 测试目的:非泛型 TypeList 继承自 TypeList<object>,可以添加任意类型。 + /// + [Fact] + public void TypeList_NonGeneric_AcceptsAnyType() + { + // Arrange & Act + var list = new TypeList(); + list.Add(typeof(string)); + list.Add(typeof(int)); + list.Add(typeof(Dog)); + + // Assert + list.Count.ShouldBe(3); + list.Contains(typeof(string)).ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Core.Tests/CoreEnumsAndHealthTest.cs b/framework/tests/Bing.Core.Tests/CoreEnumsAndHealthTest.cs new file mode 100644 index 00000000..ee63aa84 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/CoreEnumsAndHealthTest.cs @@ -0,0 +1,306 @@ +using Bing.Core.Enums; +using Bing.Core.Modularity; +using Bing.Exceptions; +using Bing.Exceptions.Prompts; +using Bing.Monitoring.Health; +using Bing.Trees; + +namespace Bing.Tests; + +/// +/// 、 +/// 枚举,以及 +/// 结构体, +/// 静态工具类 的单元测试。 +/// +public class CoreEnumsAndHealthTest +{ + // ═══════════════════════════════════════════════════════════ + // LoadMode + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:枚举包含 Sync、Async、OnlyRootAsync 三个成员, + /// 防止意外增删导致树形组件加载策略错乱。 + /// + [Fact] + public void LoadMode_Count_ShouldBeThree() + { + Enum.GetValues(typeof(LoadMode)).Length.ShouldBe(3); + } + + /// + /// 测试目的:LoadMode 各成员可被正常声明与比较,枚举解析不抛异常。 + /// + [Fact] + public void LoadMode_Values_ShouldContainExpectedMembers() + { + // Assert + Enum.IsDefined(typeof(LoadMode), LoadMode.Sync).ShouldBeTrue(); + Enum.IsDefined(typeof(LoadMode), LoadMode.Async).ShouldBeTrue(); + Enum.IsDefined(typeof(LoadMode), LoadMode.OnlyRootAsync).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // LoadOperation + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:枚举值 FirstLoad=1、LoadChild=2、Search=3, + /// 确保与前端或配置中的整数约定一致。 + /// + [Fact] + public void LoadOperation_Values_ShouldMatchExpected() + { + ((int)LoadOperation.FirstLoad).ShouldBe(1); + ((int)LoadOperation.LoadChild).ShouldBe(2); + ((int)LoadOperation.Search).ShouldBe(3); + } + + /// + /// 测试目的:枚举共有 3 个成员,防止意外变更。 + /// + [Fact] + public void LoadOperation_Count_ShouldBeThree() + { + Enum.GetValues(typeof(LoadOperation)).Length.ShouldBe(3); + } + + // ═══════════════════════════════════════════════════════════ + // ModuleLevel + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ModuleLevel 各级别的整数值应符合规范(Core=1, Framework=10, Application=20, Business=30), + /// 数值越小优先级越高,确保模块启动顺序正确。 + /// + [Fact] + public void ModuleLevel_Values_ShouldMatchExpected() + { + ((int)ModuleLevel.Core).ShouldBe(1); + ((int)ModuleLevel.Framework).ShouldBe(10); + ((int)ModuleLevel.Application).ShouldBe(20); + ((int)ModuleLevel.Business).ShouldBe(30); + } + + /// + /// 测试目的:ModuleLevel 共 4 个成员,防止误增/误删导致模块加载顺序错乱。 + /// + [Fact] + public void ModuleLevel_Count_ShouldBeFour() + { + Enum.GetValues(typeof(ModuleLevel)).Length.ShouldBe(4); + } + + // ═══════════════════════════════════════════════════════════ + // EnvironmentType + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:枚举值 Development=1、Test=2、Preview=3、Prod=4, + /// 确保环境配置切换时数值映射正确。 + /// + [Fact] + public void EnvironmentType_Values_ShouldMatchExpected() + { + ((byte)EnvironmentType.Development).ShouldBe((byte)1); + ((byte)EnvironmentType.Test).ShouldBe((byte)2); + ((byte)EnvironmentType.Preview).ShouldBe((byte)3); + ((byte)EnvironmentType.Prod).ShouldBe((byte)4); + } + + /// + /// 测试目的:EnvironmentType 共 4 个成员,防止遗漏环境类型导致判断逻辑缺陷。 + /// + [Fact] + public void EnvironmentType_Count_ShouldBeFour() + { + Enum.GetValues(typeof(EnvironmentType)).Length.ShouldBe(4); + } + + // ═══════════════════════════════════════════════════════════ + // BusHealthStatus + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:BusHealthStatus 各状态值应符合规范(Unhealthy=0, Degraded=1, Healthy=2), + /// 确保健康检查端点返回的整数与状态语义一致。 + /// + [Fact] + public void BusHealthStatus_Values_ShouldMatchExpected() + { + ((int)BusHealthStatus.Unhealthy).ShouldBe(0); + ((int)BusHealthStatus.Degraded).ShouldBe(1); + ((int)BusHealthStatus.Healthy).ShouldBe(2); + } + + /// + /// 测试目的:BusHealthStatus 默认值应为 Unhealthy(0), + /// 防止未初始化时误判为健康状态。 + /// + [Fact] + public void BusHealthStatus_Default_ShouldBeUnhealthy() + { + default(BusHealthStatus).ShouldBe(BusHealthStatus.Unhealthy); + } + + // ═══════════════════════════════════════════════════════════ + // BusHealthResult + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:BusHealthResult.Healthy() 应创建状态为 Healthy 的结果, + /// 描述与 Data 按传入值正确赋值。 + /// + [Fact] + public void BusHealthResult_Healthy_ShouldSetStatusAndDescription() + { + // Act + var result = BusHealthResult.Healthy("all systems go"); + + // Assert + result.Status.ShouldBe(BusHealthStatus.Healthy); + result.Description.ShouldBe("all systems go"); + result.Exception.ShouldBeNull(); + result.Data.ShouldNotBeNull(); + result.Data.Count.ShouldBe(0); + } + + /// + /// 测试目的:BusHealthResult.Degraded() 应创建状态为 Degraded 的结果, + /// 异常信息被正确保留。 + /// + [Fact] + public void BusHealthResult_Degraded_ShouldSetStatusAndException() + { + // Arrange + var ex = new InvalidOperationException("degraded cause"); + + // Act + var result = BusHealthResult.Degraded("partial failure", ex); + + // Assert + result.Status.ShouldBe(BusHealthStatus.Degraded); + result.Description.ShouldBe("partial failure"); + result.Exception.ShouldBeSameAs(ex); + } + + /// + /// 测试目的:BusHealthResult.Unhealthy() 应创建状态为 Unhealthy 的结果, + /// 无参调用时 Description 和 Exception 均为 null。 + /// + [Fact] + public void BusHealthResult_Unhealthy_DefaultArgs_ShouldHaveNullDescriptionAndException() + { + // Act + var result = BusHealthResult.Unhealthy(); + + // Assert + result.Status.ShouldBe(BusHealthStatus.Unhealthy); + result.Description.ShouldBeNull(); + result.Exception.ShouldBeNull(); + } + + /// + /// 测试目的:BusHealthResult.Healthy() 可传入自定义 Data 字典, + /// 结果中 Data 应与传入引用相同。 + /// + [Fact] + public void BusHealthResult_Healthy_WithData_ShouldReturnData() + { + // Arrange + var data = new Dictionary { { "version", "1.0.0" } }; + + // Act + var result = BusHealthResult.Healthy(data: data); + + // Assert + result.Data.ShouldContainKey("version"); + result.Data["version"].ShouldBe("1.0.0"); + } + + // ═══════════════════════════════════════════════════════════ + // ExceptionPrompt + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 exception 为 null 时,GetPrompt 应返回 null, + /// 防止调用方收到误导性的错误信息。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_NullException_ShouldReturnNull() + { + // Act & Assert + ExceptionPrompt.GetPrompt(null, false).ShouldBeNull(); + ExceptionPrompt.GetPrompt(null, true).ShouldBeNull(); + } + + /// + /// 测试目的:非生产环境下,GetPrompt 应返回异常的 Message 文本(原始信息直接呈现)。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_NonProduction_ShouldReturnExceptionMessage() + { + // Arrange + var ex = new InvalidOperationException("raw error detail"); + + // Act + var prompt = ExceptionPrompt.GetPrompt(ex, false); + + // Assert + prompt.ShouldBe("raw error detail"); + } + + /// + /// 测试目的:生产环境下,GetPrompt 对普通异常应返回系统通用错误文案(不暴露内部细节)。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_ProductionMode_ShouldReturnSystemError() + { + // Arrange + var ex = new InvalidOperationException("internal detail"); + + // Act + var prompt = ExceptionPrompt.GetPrompt(ex, true); + + // Assert — 生产环境返回通用文案,不应等于原始异常消息 + prompt.ShouldNotBe("internal detail"); + prompt.ShouldNotBeNullOrEmpty(); + } + + /// + /// 测试目的:AddPrompt(null) 应抛出 ArgumentNullException,保护静态列表的完整性。 + /// + [Fact] + public void ExceptionPrompt_AddPrompt_NullPrompt_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => ExceptionPrompt.AddPrompt(null)); + } + + /// + /// 测试目的:GetException(null) 应返回 null,确保安全调用无异常。 + /// + [Fact] + public void ExceptionPrompt_GetException_NullInput_ShouldReturnNull() + { + // Act & Assert + ExceptionPrompt.GetException(null).ShouldBeNull(); + } + + /// + /// 测试目的:GetException(exception) 当无注册的 Prompt 时,应原样返回传入的异常对象。 + /// + [Fact] + public void ExceptionPrompt_GetException_WithNoPrompts_ShouldReturnSameException() + { + // Arrange + var ex = new ArgumentException("test"); + + // Act + var result = ExceptionPrompt.GetException(ex); + + // Assert + result.ShouldBeSameAs(ex); + } +} diff --git a/framework/tests/Bing.Core.Tests/DependencyInjection/LazyServiceProviderTest.cs b/framework/tests/Bing.Core.Tests/DependencyInjection/LazyServiceProviderTest.cs new file mode 100644 index 00000000..04fb907c --- /dev/null +++ b/framework/tests/Bing.Core.Tests/DependencyInjection/LazyServiceProviderTest.cs @@ -0,0 +1,217 @@ +using Bing.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Bing.Tests.DependencyInjection; + +/// +/// LazyServiceProvider 延迟加载服务提供程序测试 +/// +public class LazyServiceProviderTest +{ + // ==================== 辅助 ==================== + + private static (IServiceProvider root, IServiceScope scope) BuildScope( + Action configure) + { + var services = new ServiceCollection(); + configure(services); + var root = services.BuildServiceProvider(); + var scope = root.CreateScope(); + return (root, scope); + } + + private static LazyServiceProvider CreateProvider(IServiceProvider sp) => + new LazyServiceProvider(sp); + + // ==================== LazyGetService ==================== + + /// + /// 测试目的:服务未注册时,LazyGetService<T> 应返回 null,不抛异常。 + /// + [Fact] + public void LazyGetService_Unregistered_ReturnsNull() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + + // Act + var result = lazy.LazyGetService(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:服务已注册时,LazyGetService<T> 应返回正确实例。 + /// + [Fact] + public void LazyGetService_Registered_ReturnsInstance() + { + // Arrange + var (root, _) = BuildScope(s => s.AddSingleton()); + var lazy = CreateProvider(root); + + // Act + var result = lazy.LazyGetService(); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:连续两次调用 LazyGetService<T> 应返回缓存的相同实例(幂等性)。 + /// + [Fact] + public void LazyGetService_CalledTwice_ReturnsCachedInstance() + { + // Arrange + var (root, _) = BuildScope(s => s.AddTransient()); + var lazy = CreateProvider(root); + + // Act + var first = lazy.LazyGetService(); + var second = lazy.LazyGetService(); + + // Assert:LazyServiceProvider 内部有 ConcurrentDictionary 缓存,两次返回同一实例 + first.ShouldBeSameAs(second); + } + + // ==================== LazyGetService with default ==================== + + /// + /// 测试目的:服务未注册时,传入 defaultValue 的重载应返回 defaultValue。 + /// + [Fact] + public void LazyGetService_WithDefault_UnregisteredService_ReturnsDefault() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + var fallback = new MyService(); + + // Act + var result = lazy.LazyGetService(fallback); + + // Assert + result.ShouldBeSameAs(fallback); + } + + /// + /// 测试目的:服务已注册时,传入 defaultValue 的重载应返回注册的服务(不是默认值)。 + /// + [Fact] + public void LazyGetService_WithDefault_RegisteredService_ReturnsService() + { + // Arrange + var (root, _) = BuildScope(s => s.AddSingleton()); + var lazy = CreateProvider(root); + var fallback = new MyService(); + + // Act + var result = lazy.LazyGetService(fallback); + + // Assert + result.ShouldNotBeSameAs(fallback); + result.ShouldBeAssignableTo(); + } + + // ==================== LazyGetService with factory ==================== + + /// + /// 测试目的:使用工厂委托的重载,应通过工厂创建实例。 + /// + [Fact] + public void LazyGetService_WithFactory_UsesFactoryToCreateInstance() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + var expected = new MyService(); + + // Act + var result = lazy.LazyGetService(_ => expected); + + // Assert + result.ShouldBeSameAs(expected); + } + + /// + /// 测试目的:工厂委托重载调用两次应返回缓存的同一实例。 + /// + [Fact] + public void LazyGetService_WithFactory_CalledTwice_ReturnsCachedInstance() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + var callCount = 0; + + // Act + var first = lazy.LazyGetService(_ => { callCount++; return new MyService(); }); + var second = lazy.LazyGetService(_ => { callCount++; return new MyService(); }); + + // Assert:工厂只被调用一次(Lazy<> 缓存) + callCount.ShouldBe(1); + first.ShouldBeSameAs(second); + } + + // ==================== LazyGetRequiredService ==================== + + /// + /// 测试目的:服务已注册时,LazyGetRequiredService<T> 返回正确实例。 + /// + [Fact] + public void LazyGetRequiredService_Registered_ReturnsInstance() + { + // Arrange + var (root, _) = BuildScope(s => s.AddSingleton()); + var lazy = CreateProvider(root); + + // Act + var result = lazy.LazyGetRequiredService(); + + // Assert + result.ShouldNotBeNull(); + } + + /// + /// 测试目的:服务未注册时,LazyGetRequiredService<T> 应抛出异常。 + /// + [Fact] + public void LazyGetRequiredService_Unregistered_ThrowsException() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + + // Act & Assert + Should.Throw(() => lazy.LazyGetRequiredService()); + } + + // ==================== IServiceProvider 缓存 ==================== + + /// + /// 测试目的:IServiceProvider 自身被缓存,通过 LazyGetService 可以取回。 + /// + [Fact] + public void LazyGetService_IServiceProvider_ReturnsCachedProvider() + { + // Arrange + var (root, _) = BuildScope(_ => { }); + var lazy = CreateProvider(root); + + // Act + var sp = lazy.LazyGetService(); + + // Assert + sp.ShouldBeSameAs(root); + } + + // ==================== 辅助类型 ==================== + + private interface IMyService { } + private class MyService : IMyService { } +} diff --git a/framework/tests/Bing.Core.Tests/DependencyInjection/ObjectAccessorTest.cs b/framework/tests/Bing.Core.Tests/DependencyInjection/ObjectAccessorTest.cs new file mode 100644 index 00000000..62e5a19f --- /dev/null +++ b/framework/tests/Bing.Core.Tests/DependencyInjection/ObjectAccessorTest.cs @@ -0,0 +1,122 @@ +using Bing.DependencyInjection; +using Shouldly; + +namespace Bing.Tests.DependencyInjection; + +/// +/// ObjectAccessor<T> 对象访问器测试 +/// +public class ObjectAccessorTest +{ + // ==================== 默认构造函数 ==================== + + /// + /// 测试目的:无参构造函数创建后,Value 应为类型默认值(引用类型为 null)。 + /// + [Fact] + public void DefaultConstructor_ReferenceType_ValueIsNull() + { + // Arrange & Act + var accessor = new ObjectAccessor(); + + // Assert + accessor.Value.ShouldBeNull(); + } + + /// + /// 测试目的:无参构造函数创建后,值类型 Value 应为默认值(int 为 0)。 + /// + [Fact] + public void DefaultConstructor_ValueType_ValueIsDefault() + { + // Arrange & Act + var accessor = new ObjectAccessor(); + + // Assert + accessor.Value.ShouldBe(0); + } + + // ==================== 带参构造函数 ==================== + + /// + /// 测试目的:通过带参构造函数传入值,Value 应与传入值相同。 + /// + [Fact] + public void ParameterizedConstructor_SetsValueCorrectly() + { + // Arrange + const string expected = "test-value"; + + // Act + var accessor = new ObjectAccessor(expected); + + // Assert + accessor.Value.ShouldBe(expected); + } + + /// + /// 测试目的:通过带参构造函数传入对象实例,Value 引用相同对象。 + /// + [Fact] + public void ParameterizedConstructor_ObjectInstance_SameReference() + { + // Arrange + var obj = new List { 1, 2, 3 }; + + // Act + var accessor = new ObjectAccessor>(obj); + + // Assert + accessor.Value.ShouldBeSameAs(obj); + } + + // ==================== Value 属性可写 ==================== + + /// + /// 测试目的:Value 属性可以在创建后被重新赋值。 + /// + [Fact] + public void Value_Settable_AfterCreation() + { + // Arrange + var accessor = new ObjectAccessor("old"); + + // Act + accessor.Value = "new"; + + // Assert + accessor.Value.ShouldBe("new"); + } + + /// + /// 测试目的:Value 可以被设为 null(引用类型)。 + /// + [Fact] + public void Value_CanBeSetToNull() + { + // Arrange + var accessor = new ObjectAccessor("initial"); + + // Act + accessor.Value = null; + + // Assert + accessor.Value.ShouldBeNull(); + } + + // ==================== IObjectAccessor 接口 ==================== + + /// + /// 测试目的:ObjectAccessor 可以通过 IObjectAccessor<T> 接口使用。 + /// + [Fact] + public void ObjectAccessor_Implements_IObjectAccessorInterface() + { + // Arrange & Act + IObjectAccessor accessor = new ObjectAccessor("hello"); + + // Assert + accessor.Value.ShouldBe("hello"); + accessor.ShouldBeAssignableTo>(); + } +} diff --git a/framework/tests/Bing.Core.Tests/DependencyInjection/ScopedDictionaryTest.cs b/framework/tests/Bing.Core.Tests/DependencyInjection/ScopedDictionaryTest.cs new file mode 100644 index 00000000..50ce8c35 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/DependencyInjection/ScopedDictionaryTest.cs @@ -0,0 +1,162 @@ +using System.Security.Claims; +using Shouldly; + +namespace Bing.Tests.DependencyInjection; + +/// +/// ScopedDictionary Scoped 生命周期字典测试 +/// +public class ScopedDictionaryTest +{ + // ==================== 基本字典操作 ==================== + + /// + /// 测试目的:新建字典后为空,DataAuthValidRoleNames 默认为空数组。 + /// + [Fact] + public void NewInstance_IsEmpty_And_DefaultRoleNamesIsEmpty() + { + // Arrange & Act + var dict = new ScopedDictionary(); + + // Assert + dict.Count.ShouldBe(0); + dict.DataAuthValidRoleNames.ShouldNotBeNull(); + dict.DataAuthValidRoleNames.ShouldBeEmpty(); + } + + /// + /// 测试目的:添加键值对后,可以通过键正确读取值。 + /// + [Fact] + public void AddKeyValue_CanBeRetrievedByKey() + { + // Arrange + var dict = new ScopedDictionary(); + + // Act + dict["name"] = "Alice"; + dict["age"] = 30; + + // Assert + dict["name"].ShouldBe("Alice"); + dict["age"].ShouldBe(30); + } + + /// + /// 测试目的:TryGetValue 对存在的键返回 true 和正确值;不存在的键返回 false。 + /// + [Fact] + public void TryGetValue_ExistingKey_ReturnsTrueAndValue() + { + // Arrange + var dict = new ScopedDictionary(); + dict["key"] = "value"; + + // Act + var found = dict.TryGetValue("key", out var value); + var notFound = dict.TryGetValue("missing", out var missing); + + // Assert + found.ShouldBeTrue(); + value.ShouldBe("value"); + notFound.ShouldBeFalse(); + missing.ShouldBeNull(); + } + + // ==================== Identity 属性 ==================== + + /// + /// 测试目的:Identity 属性默认为 null,可以被设置。 + /// + [Fact] + public void Identity_DefaultIsNull_CanBeSet() + { + // Arrange + var dict = new ScopedDictionary(); + var identity = new ClaimsIdentity("test"); + + // Assert before + dict.Identity.ShouldBeNull(); + + // Act + dict.Identity = identity; + + // Assert after + dict.Identity.ShouldBeSameAs(identity); + } + + // ==================== DataAuthValidRoleNames ==================== + + /// + /// 测试目的:DataAuthValidRoleNames 可以被设置,并保存正确值。 + /// + [Fact] + public void DataAuthValidRoleNames_CanBeSet() + { + // Arrange + var dict = new ScopedDictionary(); + var roles = new[] { "Admin", "Manager" }; + + // Act + dict.DataAuthValidRoleNames = roles; + + // Assert + dict.DataAuthValidRoleNames.ShouldBe(roles); + } + + // ==================== Dispose ==================== + + /// + /// 测试目的:Dispose 后所有键值对应被清空(Count = 0)。 + /// + [Fact] + public void Dispose_ClearsAllItems() + { + // Arrange + var dict = new ScopedDictionary(); + dict["a"] = 1; + dict["b"] = 2; + + // Act + dict.Dispose(); + + // Assert + dict.Count.ShouldBe(0); + } + + /// + /// 测试目的:Dispose 后 Identity 应被清为 null。 + /// + [Fact] + public void Dispose_ClearsIdentity() + { + // Arrange + var dict = new ScopedDictionary(); + dict.Identity = new ClaimsIdentity("test"); + + // Act + dict.Dispose(); + + // Assert + dict.Identity.ShouldBeNull(); + } + + /// + /// 测试目的:多次 Dispose 不应抛出异常(幂等性)。 + /// + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + // Arrange + var dict = new ScopedDictionary(); + dict["key"] = "value"; + + // Act & Assert + Should.NotThrow(() => + { + dict.Dispose(); + dict.Dispose(); + }); + } +} diff --git a/framework/tests/Bing.Core.Tests/EnumerationDisposableTest.cs b/framework/tests/Bing.Core.Tests/EnumerationDisposableTest.cs new file mode 100644 index 00000000..eb024de9 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/EnumerationDisposableTest.cs @@ -0,0 +1,284 @@ +using Microsoft.Extensions.Logging; +using Bing.Logging; +using Shouldly; +using Xunit; + +namespace Bing.Tests; + +// ───────────────────────────────────────────────────────────────────────────── +// 测试用 Enumeration 子类 +// ───────────────────────────────────────────────────────────────────────────── + +public class OrderStatus : Enumeration +{ + public static readonly OrderStatus Pending = new OrderStatus("1", "待处理"); + public static readonly OrderStatus Confirmed = new OrderStatus("2", "已确认"); + public static readonly OrderStatus Shipped = new OrderStatus("3", "已发货"); + + private OrderStatus(string id, string name) : base(id, name) { } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class EnumerationTest +{ + // ═══════════════════════════════════════════════════════════ + // 基本属性 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Id 与 Name 属性应与构造时传入的值一致。 + /// + [Fact] + public void Properties_ShouldMatchConstructorArgs() + { + OrderStatus.Pending.Id.ShouldBe("1"); + OrderStatus.Pending.Name.ShouldBe("待处理"); + } + + // ═══════════════════════════════════════════════════════════ + // ToString + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ToString 应输出 "[TypeName] Id = x, Name = y" 格式。 + /// + [Fact] + public void ToString_ShouldReturnFormattedString() + { + OrderStatus.Pending.ToString().ShouldBe("[OrderStatus] Id = 1, Name = 待处理"); + } + + // ═══════════════════════════════════════════════════════════ + // Equals / GetHashCode + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:相同 Id 的同类型枚举项应相等。 + /// + [Fact] + public void Equals_SameIdSameType_ShouldBeTrue() + { + var a = OrderStatus.Pending; + var b = OrderStatus.Pending; + a.Equals(b).ShouldBeTrue(); + } + + /// + /// 测试目的:不同 Id 的枚举项应不等。 + /// + [Fact] + public void Equals_DifferentId_ShouldBeFalse() + { + OrderStatus.Pending.Equals(OrderStatus.Confirmed).ShouldBeFalse(); + } + + /// + /// 测试目的:与 null 比较应返回 false。 + /// + [Fact] + public void Equals_Null_ShouldBeFalse() + { + OrderStatus.Pending.Equals(null).ShouldBeFalse(); + } + + /// + /// 测试目的:相同 Id 的项 GetHashCode 应相等。 + /// + [Fact] + public void GetHashCode_SameId_ShouldBeEqual() + { + OrderStatus.Pending.GetHashCode().ShouldBe(OrderStatus.Pending.GetHashCode()); + } + + // ═══════════════════════════════════════════════════════════ + // CompareTo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Id="1" 应小于 Id="2",CompareTo 返回负数。 + /// + [Fact] + public void CompareTo_SmallerIdFirst_ShouldReturnNegative() + { + OrderStatus.Pending.CompareTo(OrderStatus.Confirmed).ShouldBeLessThan(0); + } + + /// + /// 测试目的:相同 Id 的项 CompareTo 应返回 0。 + /// + [Fact] + public void CompareTo_SameId_ShouldReturnZero() + { + OrderStatus.Pending.CompareTo(OrderStatus.Pending).ShouldBe(0); + } + + // ═══════════════════════════════════════════════════════════ + // GetAll + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetAll 应返回所有声明的静态枚举项。 + /// + [Fact] + public void GetAll_ShouldReturnAllItems() + { + var all = Enumeration.GetAll().ToList(); + all.Count.ShouldBe(3); + all.ShouldContain(OrderStatus.Pending); + all.ShouldContain(OrderStatus.Confirmed); + all.ShouldContain(OrderStatus.Shipped); + } + + // ═══════════════════════════════════════════════════════════ + // FromValue / FromDisplayName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FromValue 对存在的 Id 应返回对应枚举项。 + /// + [Fact] + public void FromValue_ExistingId_ShouldReturnMatchingItem() + { + var item = Enumeration.FromValue("2"); + item.ShouldBe(OrderStatus.Confirmed); + } + + /// + /// 测试目的:FromValue 对不存在的 Id 应抛出 InvalidOperationException。 + /// + [Fact] + public void FromValue_NonExistingId_ShouldThrowInvalidOperationException() + { + Should.Throw(() => Enumeration.FromValue("99")); + } + + /// + /// 测试目的:FromDisplayName 对存在的 Name 应返回对应枚举项。 + /// + [Fact] + public void FromDisplayName_ExistingName_ShouldReturnMatchingItem() + { + var item = Enumeration.FromDisplayName("已发货"); + item.ShouldBe(OrderStatus.Shipped); + } + + /// + /// 测试目的:FromDisplayName 对不存在的 Name 应抛出 InvalidOperationException。 + /// + [Fact] + public void FromDisplayName_NonExistingName_ShouldThrowInvalidOperationException() + { + Should.Throw(() => Enumeration.FromDisplayName("不存在")); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Disposable 测试 +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class DisposableTest +{ + private class ConcreteDisposable : System.Disposable + { + public bool ManagedDisposeCalled { get; private set; } + + protected override void Dispose(bool disposing) + { + if (disposing) + ManagedDisposeCalled = true; + base.Dispose(disposing); + } + } + + /// + /// 测试目的:调用 Dispose() 后,Disposed 应为 true。 + /// + [Fact] + public void Dispose_ShouldSetDisposedTrue() + { + var obj = new ConcreteDisposable(); + obj.Dispose(); + obj.Disposed.ShouldBeTrue(); + } + + /// + /// 测试目的:调用 Dispose() 时,managed dispose 路径应被触发。 + /// + [Fact] + public void Dispose_ShouldCallManagedDispose() + { + var obj = new ConcreteDisposable(); + obj.Dispose(); + obj.ManagedDisposeCalled.ShouldBeTrue(); + } + + /// + /// 测试目的:using 语句结束后 Disposed 应自动置为 true。 + /// + [Fact] + public void Using_ShouldAutoDispose() + { + ConcreteDisposable obj; + using (obj = new ConcreteDisposable()) + { + obj.Disposed.ShouldBeFalse(); + } + obj.Disposed.ShouldBeTrue(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BingExceptionExtensions 测试 +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class BingExceptionExtensionsTest +{ + private class HasLogLevelException : Exception, IHasLogLevel + { + public LogLevel LogLevel { get; } + public HasLogLevelException(LogLevel level) : base("test") => LogLevel = level; + } + + /// + /// 测试目的:实现了 IHasLogLevel 的异常,GetLogLevel 应返回其 LogLevel 属性值。 + /// + [Theory] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Critical)] + public void GetLogLevel_WhenExceptionImplementsInterface_ShouldReturnItsLevel(LogLevel level) + { + var ex = new HasLogLevelException(level); + ex.GetLogLevel().ShouldBe(level); + } + + /// + /// 测试目的:普通异常(不实现 IHasLogLevel)使用默认值时应返回 Error。 + /// + [Fact] + public void GetLogLevel_PlainException_ShouldReturnDefaultError() + { + var ex = new Exception("plain"); + ex.GetLogLevel().ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:普通异常可自定义默认级别,应返回指定的 defaultLevel。 + /// + [Fact] + public void GetLogLevel_PlainExceptionWithCustomDefault_ShouldReturnCustomDefault() + { + var ex = new Exception("plain"); + ex.GetLogLevel(LogLevel.Warning).ShouldBe(LogLevel.Warning); + } +} diff --git a/framework/tests/Bing.Core.Tests/Events/EventsAndOptionsTest.cs b/framework/tests/Bing.Core.Tests/Events/EventsAndOptionsTest.cs new file mode 100644 index 00000000..ca92cdf6 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Events/EventsAndOptionsTest.cs @@ -0,0 +1,491 @@ +using Bing.Events; +using Bing.ExceptionHandling; +using Bing.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Bing.Core.Tests; + +// ─── 测试辅助:带 EventName 特性的事件 ──────────────────────────── + +[EventName("my.custom.event")] +file class NamedEvent : Event { } + +/// +/// 未标注 EventName 特性的普通事件(回退到 FullName) +/// +file class UnnamedEvent : Event { } + +/// +/// 泛型事件,使用 GenericEventNameAttribute 注解前缀 +/// +[GenericEventName(Prefix = "pre.")] +file class GenericPrefixEvent : Event { } + +// ─── EventNameAttribute 测试 ────────────────────────────────────── + +/// +/// 单元测试 +/// +public class EventNameAttributeTest +{ + /// + /// 测试目的:构造函数传入有效名称,Name 属性应返回该值。 + /// + [Fact] + public void Constructor_WithValidName_ShouldSetNameProperty() + { + // Arrange & Act + var attr = new EventNameAttribute("order.created"); + + // Assert + attr.Name.ShouldBe("order.created"); + } + + /// + /// 测试目的:GetName 方法应返回构造时设置的 Name 值(忽略 eventType 参数)。 + /// + [Fact] + public void GetName_ShouldReturnConstructorName() + { + // Arrange + var attr = new EventNameAttribute("payment.paid"); + + // Act + var result = attr.GetName(typeof(object)); + + // Assert + result.ShouldBe("payment.paid"); + } + + /// + /// 测试目的:GetNameOrDefault<T> 对标注了 [EventName] 的类型应返回特性值。 + /// + [Fact] + public void GetNameOrDefault_Generic_WithAttribute_ShouldReturnAttributeName() + { + // Act + var name = EventNameAttribute.GetNameOrDefault(); + + // Assert + name.ShouldBe("my.custom.event"); + } + + /// + /// 测试目的:GetNameOrDefault 对未标注 [EventName] 的类型应回退到 FullName。 + /// + [Fact] + public void GetNameOrDefault_WithoutAttribute_ShouldReturnFullName() + { + // Act + var name = EventNameAttribute.GetNameOrDefault(); + + // Assert + name.ShouldNotBeNullOrEmpty(); + name.ShouldContain(nameof(UnnamedEvent)); + } + + /// + /// 测试目的:GetNameOrDefault(Type) 传入 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void GetNameOrDefault_NullType_ShouldThrow() + { + // Act & Assert + Should.Throw(() => EventNameAttribute.GetNameOrDefault(null)); + } +} + +// ─── GenericEventNameAttribute 测试 ────────────────────────────── + +/// +/// 单元测试 +/// +public class GenericEventNameAttributeTest +{ + /// + /// 测试目的:传入非泛型类型时应抛出 Warning 异常。 + /// + [Fact] + public void GetName_NonGenericType_ShouldThrowWarning() + { + // Arrange + var attr = new GenericEventNameAttribute(); + + // Act & Assert + Should.Throw(() => attr.GetName(typeof(int))); + } + + /// + /// 测试目的:泛型类型有多个泛型参数时应抛出 Warning 异常。 + /// + [Fact] + public void GetName_MultipleGenericArgs_ShouldThrowWarning() + { + // Arrange + var attr = new GenericEventNameAttribute(); + // Dictionary 有两个泛型参数 + var multiArgGenericType = typeof(Dictionary); + + // Act & Assert + Should.Throw(() => attr.GetName(multiArgGenericType)); + } + + /// + /// 测试目的:Prefix 为空时,GetName 返回泛型参数的 FullName(无前缀)。 + /// + [Fact] + public void GetName_WithoutPrefixOrPostfix_ShouldReturnGenericArgFullName() + { + // Arrange + var attr = new GenericEventNameAttribute(); + var eventType = typeof(GenericPrefixEvent); + + // Act + var name = attr.GetName(eventType); + + // Assert + name.ShouldContain(nameof(UnnamedEvent)); + } + + /// + /// 测试目的:设置 Prefix 后,GetName 应在结果前追加前缀。 + /// + [Fact] + public void GetName_WithPrefix_ShouldPrependPrefix() + { + // Arrange + var attr = new GenericEventNameAttribute { Prefix = "domain." }; + var eventType = typeof(GenericPrefixEvent); + + // Act + var name = attr.GetName(eventType); + + // Assert + name.ShouldStartWith("domain."); + } + + /// + /// 测试目的:设置 Postfix 后,GetName 应在结果后追加后缀。 + /// + [Fact] + public void GetName_WithPostfix_ShouldAppendPostfix() + { + // Arrange + var attr = new GenericEventNameAttribute { Postfix = ".created" }; + var eventType = typeof(GenericPrefixEvent); + + // Act + var name = attr.GetName(eventType); + + // Assert + name.ShouldEndWith(".created"); + } +} + +// ─── Event 测试 ─────────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class EventClassTest +{ + /// + /// 测试目的:默认构造时 Id 应为非空 GUID 格式字符串,Time 应接近当前时间。 + /// + [Fact] + public void DefaultCtor_ShouldSetIdAndTime() + { + // Arrange + var before = DateTime.Now.AddSeconds(-1); + + // Act + var evt = new Event(); + + // Assert + evt.Id.ShouldNotBeNullOrEmpty(); + Guid.TryParse(evt.Id, out _).ShouldBeTrue(); + evt.Time.ShouldBeGreaterThan(before); + evt.Time.ShouldBeLessThanOrEqualTo(DateTime.Now.AddSeconds(1)); + } + + /// + /// 测试目的:传入显式事件名时,GetEventName 应返回该名称。 + /// + [Fact] + public void GetEventName_WithExplicitName_ShouldReturnThatName() + { + // Arrange + var evt = new Event("explicit.event"); + + // Act + var name = evt.GetEventName(); + + // Assert + name.ShouldBe("explicit.event"); + } + + /// + /// 测试目的:不传入名称但类型标注了 [EventName],GetEventName 应返回特性值。 + /// + [Fact] + public void GetEventName_WithAttribute_ShouldReturnAttributeName() + { + // Arrange + var evt = new NamedEvent(); + + // Act + var name = evt.GetEventName(); + + // Assert + name.ShouldBe("my.custom.event"); + } + + /// + /// 测试目的:不传入名称且未标注特性,GetEventName 应回退到 FullName(含命名空间)。 + /// + [Fact] + public void GetEventName_NoAttributeNoName_ShouldReturnFullName() + { + // Arrange + var evt = new UnnamedEvent(); + + // Act + var name = evt.GetEventName(); + + // Assert + name.ShouldNotBeNullOrEmpty(); + name.ShouldContain(nameof(UnnamedEvent)); + } + + /// + /// 测试目的:ToString 应包含事件标识和时间信息,不抛出异常。 + /// + [Fact] + public void ToString_ShouldContainIdAndTime() + { + // Arrange + var evt = new Event("test.event"); + + // Act + var str = Should.NotThrow(() => evt.ToString()); + + // Assert + str.ShouldContain(evt.Id); + str.ShouldContain("事件标识"); + } +} + +// ─── BingOptionsBase / BingOptions 测试 ────────────────────────── + +/// +/// 单元测试 +/// +public class BingOptionsTest +{ + /// + /// 测试目的:AddExtension 传入 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void AddExtension_NullExtension_ShouldThrowArgumentNullException() + { + // Arrange + var options = new BingOptions(); + + // Act & Assert + Should.Throw(() => options.AddExtension(null)); + } + + /// + /// 测试目的:AddExtension 传入有效扩展后,再次调用 AddExtension 可继续添加(列表增长)。 + /// + [Fact] + public void AddExtension_ValidExtension_ShouldBeAdded() + { + // Arrange + var options = new BingOptions(); + var ext1 = new Mock().Object; + var ext2 = new Mock().Object; + + // Act + options.AddExtension(ext1); + options.AddExtension(ext2); + + // Assert — 通过 Extensions 内部属性(internal)无法直接验证,但 AddExtension 不抛异常即符合预期 + Should.NotThrow(() => options.AddExtension(new Mock().Object)); + } + + /// + /// 测试目的:BingOptions 是 BingOptionsBase 的直接子类。 + /// + [Fact] + public void BingOptions_ShouldExtendBingOptionsBase() + { + // Act + var options = new BingOptions(); + + // Assert + (options is BingOptionsBase).ShouldBeTrue(); + } +} + +// ─── ExceptionNotificationContext 测试 ──────────────────────────── + +/// +/// 单元测试 +/// +public class ExceptionNotificationContextTest +{ + /// + /// 测试目的:构造函数传入 null 异常时应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_NullException_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new ExceptionNotificationContext(null)); + } + + /// + /// 测试目的:正常构造时,Exception 属性应等于传入的异常实例。 + /// + [Fact] + public void Constructor_WithException_ShouldSetExceptionProperty() + { + // Arrange + var ex = new InvalidOperationException("测试异常"); + + // Act + var ctx = new ExceptionNotificationContext(ex); + + // Assert + ctx.Exception.ShouldBeSameAs(ex); + } + + /// + /// 测试目的:未指定 logLevel 时,LogLevel 应从异常 GetLogLevel() 推断(普通异常 = Error)。 + /// + [Fact] + public void Constructor_WithoutLogLevel_ShouldInferLogLevel() + { + // Arrange + var ex = new Exception("普通异常"); + + // Act + var ctx = new ExceptionNotificationContext(ex); + + // Assert + ctx.LogLevel.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:传入显式 logLevel 时,应使用指定的级别。 + /// + [Fact] + public void Constructor_WithExplicitLogLevel_ShouldUseSpecifiedLevel() + { + // Arrange + var ex = new Exception("带 LogLevel 的异常"); + + // Act + var ctx = new ExceptionNotificationContext(ex, LogLevel.Warning); + + // Assert + ctx.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:默认 Handled 应为 true。 + /// + [Fact] + public void Constructor_DefaultHandled_ShouldBeTrue() + { + // Arrange + var ex = new Exception("默认 handled"); + + // Act + var ctx = new ExceptionNotificationContext(ex); + + // Assert + ctx.Handled.ShouldBeTrue(); + } + + /// + /// 测试目的:传入 handled = false 时,Handled 属性应为 false。 + /// + [Fact] + public void Constructor_WithHandledFalse_ShouldSetHandledFalse() + { + // Arrange + var ex = new Exception("未处理异常"); + + // Act + var ctx = new ExceptionNotificationContext(ex, handled: false); + + // Assert + ctx.Handled.ShouldBeFalse(); + } + + /// + /// 测试目的:Handled 属性是可读写的,可在构造后修改。 + /// + [Fact] + public void Handled_CanBeModifiedAfterConstruction() + { + // Arrange + var ctx = new ExceptionNotificationContext(new Exception("mutable")); + + // Act + ctx.Handled = false; + + // Assert + ctx.Handled.ShouldBeFalse(); + } +} + +// ─── ExceptionNotifierExtensions 测试 ──────────────────────────── + +/// +/// 单元测试 +/// +public class ExceptionNotifierExtensionsTest +{ + /// + /// 测试目的:NotifyAsync 扩展方法对 null notifier 应抛出 ArgumentNullException。 + /// + [Fact] + public async Task NotifyAsync_NullNotifier_ShouldThrowArgumentNullException() + { + // Act & Assert + await Should.ThrowAsync(() => + ExceptionNotifierExtensions.NotifyAsync(null, new Exception("test"))); + } + + /// + /// 测试目的:NotifyAsync 扩展方法应将参数正确包装为 ExceptionNotificationContext 并调用 notifier。 + /// + [Fact] + public async Task NotifyAsync_ShouldForwardToNotifierWithContext() + { + // Arrange + var mockNotifier = new Mock(); + ExceptionNotificationContext capturedCtx = null; + mockNotifier + .Setup(n => n.NotifyAsync(It.IsAny())) + .Callback(c => capturedCtx = c) + .Returns(Task.CompletedTask); + + var ex = new InvalidOperationException("转发测试"); + + // Act + await mockNotifier.Object.NotifyAsync(ex, LogLevel.Warning, handled: false); + + // Assert + capturedCtx.ShouldNotBeNull(); + capturedCtx.Exception.ShouldBeSameAs(ex); + capturedCtx.LogLevel.ShouldBe(LogLevel.Warning); + capturedCtx.Handled.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Core.Tests/Exceptions/BusinessExceptionTest.cs b/framework/tests/Bing.Core.Tests/Exceptions/BusinessExceptionTest.cs new file mode 100644 index 00000000..2b350418 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Exceptions/BusinessExceptionTest.cs @@ -0,0 +1,200 @@ +using Microsoft.Extensions.Logging; +using Shouldly; + +namespace Bing.Tests.Exceptions; + +/// +/// BusinessException 业务异常 / UserFriendlyException 用户友好异常 测试 +/// +public class BusinessExceptionTest +{ + // ==================== BusinessException 构造与属性 ==================== + + /// + /// 测试目的:Code 属性应保存构造时传入的错误码。 + /// + [Fact] + public void BusinessException_Code_IsSetFromConstructor() + { + // Arrange & Act + var ex = new BusinessException("ERR001", "出错了"); + + // Assert + ex.Code.ShouldBe("ERR001"); + } + + /// + /// 测试目的:Details 属性应保存构造时传入的错误详情。 + /// + [Fact] + public void BusinessException_Details_IsSetFromConstructor() + { + // Arrange & Act + var ex = new BusinessException("ERR001", "出错了", details: "详细描述"); + + // Assert + ex.Details.ShouldBe("详细描述"); + } + + /// + /// 测试目的:不传 logLevel 时,默认日志级别应为 Warning。 + /// + [Fact] + public void BusinessException_LogLevel_DefaultIsWarning() + { + // Arrange & Act + var ex = new BusinessException("ERR001", "出错了"); + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:显式传入 LogLevel 应被正确保存。 + /// + [Fact] + public void BusinessException_LogLevel_CanBeSetExplicitly() + { + // Arrange & Act + var ex = new BusinessException("ERR001", "出错了", logLevel: LogLevel.Error); + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:Details 不传时默认为 null。 + /// + [Fact] + public void BusinessException_Details_DefaultIsNull() + { + // Arrange & Act + var ex = new BusinessException("ERR001", "出错了"); + + // Assert + ex.Details.ShouldBeNull(); + } + + // ==================== WithData 流式 API ==================== + + /// + /// 测试目的:WithData 应将键值对写入 Data 字典,并返回 this(支持链式调用)。 + /// + [Fact] + public void BusinessException_WithData_AddsDataAndReturnsThis() + { + // Arrange + var ex = new BusinessException("ERR001", "出错了"); + + // Act + var returned = ex.WithData("userId", 42).WithData("orderId", "ORD-001"); + + // Assert + returned.ShouldBeSameAs(ex); + ex.Data["userId"].ShouldBe(42); + ex.Data["orderId"].ShouldBe("ORD-001"); + } + + /// + /// 测试目的:WithData 对同一键赋值两次,后者覆盖前者。 + /// + [Fact] + public void BusinessException_WithData_OverwriteExistingKey() + { + // Arrange + var ex = new BusinessException("ERR", "msg"); + + // Act + ex.WithData("key", "first"); + ex.WithData("key", "second"); + + // Assert + ex.Data["key"].ShouldBe("second"); + } + + // ==================== IBusinessException 接口 ==================== + + /// + /// 测试目的:BusinessException 应实现 IBusinessException 接口。 + /// + [Fact] + public void BusinessException_Implements_IBusinessException() + { + // Arrange & Act + var ex = new BusinessException("ERR", "msg"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + // ==================== UserFriendlyException ==================== + + /// + /// 测试目的:UserFriendlyException 应继承 BusinessException。 + /// + [Fact] + public void UserFriendlyException_InheritsBusinessException() + { + // Arrange & Act + var ex = new UserFriendlyException("操作失败"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:UserFriendlyException 应实现 IUserFriendlyException 接口。 + /// + [Fact] + public void UserFriendlyException_Implements_IUserFriendlyException() + { + // Arrange & Act + var ex = new UserFriendlyException("操作失败"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:UserFriendlyException 的 Code 和 Details 应正确被传递。 + /// + [Fact] + public void UserFriendlyException_Code_Details_SetCorrectly() + { + // Arrange & Act + var ex = new UserFriendlyException( + message: "操作失败", + code: "USER_ERR", + details: "详情"); + + // Assert + ex.Code.ShouldBe("USER_ERR"); + ex.Details.ShouldBe("详情"); + } + + /// + /// 测试目的:UserFriendlyException 默认 LogLevel 为 Warning(继承自 BusinessException)。 + /// + [Fact] + public void UserFriendlyException_LogLevel_DefaultIsWarning() + { + // Arrange & Act + var ex = new UserFriendlyException("出错了"); + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:UserFriendlyException 不传 code 时,Code 应为 null。 + /// + [Fact] + public void UserFriendlyException_NullCode_IsNull() + { + // Arrange & Act + var ex = new UserFriendlyException("出错了"); + + // Assert + ex.Code.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.Core.Tests/Exceptions/ConcurrencyExceptionAndLogLevelTest.cs b/framework/tests/Bing.Core.Tests/Exceptions/ConcurrencyExceptionAndLogLevelTest.cs new file mode 100644 index 00000000..150d6deb --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Exceptions/ConcurrencyExceptionAndLogLevelTest.cs @@ -0,0 +1,268 @@ +using Bing.Exceptions; +using Bing.Exceptions.Prompts; +using Bing.Logging; +using Microsoft.Extensions.Logging; + +namespace Bing.Tests.Exceptions; + +/// +/// ConcurrencyException、HasLogLevelExtensions、ExceptionPrompt 单元测试 +/// +public class ConcurrencyExceptionAndLogLevelTest +{ + // ════════════════════════════════════════════════════════════════ + // ConcurrencyException + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造不应抛异常,且 Message 包含并发异常提示前缀。 + /// + [Fact] + public void ConcurrencyException_DefaultCtor_MessageShouldNotBeEmpty() + { + // Act + var ex = new ConcurrencyException(); + + // Assert + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:传入 message 时,Message 属性应包含并发提示文本。 + /// + [Fact] + public void ConcurrencyException_WithMessage_MessageShouldContainPrefix() + { + // Act + var ex = new ConcurrencyException("版本冲突"); + + // Assert + // Message 由 LibraryResource.ConcurrencyExceptionMessage + 传入 message 拼接 + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:传入 Exception 包装时,InnerException 应与传入一致。 + /// + [Fact] + public void ConcurrencyException_WithInnerException_ShouldExposeInnerException() + { + // Arrange + var inner = new InvalidOperationException("db conflict"); + + // Act + var ex = new ConcurrencyException(inner); + + // Assert + ex.InnerException.ShouldBeSameAs(inner); + } + + /// + /// 测试目的:传入 message + exception 时,两者均应被保存。 + /// + [Fact] + public void ConcurrencyException_WithMessageAndException_ShouldStoreAll() + { + // Arrange + var inner = new Exception("cause"); + + // Act + var ex = new ConcurrencyException("conflict detail", inner); + + // Assert + ex.InnerException.ShouldBeSameAs(inner); + ex.Message.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:GetMessage(isProduction=true) 应返回通用提示(不暴露详细信息)。 + /// + [Fact] + public void ConcurrencyException_GetMessage_Production_ShouldReturnGenericMessage() + { + // Act + var ex = new ConcurrencyException("secret internal detail"); + var msg = ex.GetMessage(isProduction: true); + + // Assert + msg.ShouldNotBeNullOrWhiteSpace(); + // 生产模式不应包含原始 message 中的内部细节(防信息泄露) + msg.ShouldNotContain("secret internal detail"); + } + + /// + /// 测试目的:GetMessage(isProduction=false) 应包含更详细的信息(调试模式)。 + /// + [Fact] + public void ConcurrencyException_GetMessage_NonProduction_ShouldReturnDetailedMessage() + { + // Act + var ex = new ConcurrencyException("conflict detail"); + var msg = ex.GetMessage(isProduction: false); + + // Assert + msg.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:ConcurrencyException 继承自 Warning,应能被 Warning 类型捕获。 + /// + [Fact] + public void ConcurrencyException_IsAssignableTo_Warning() + { + // Assert + new ConcurrencyException().ShouldBeAssignableTo(); + } + + // ════════════════════════════════════════════════════════════════ + // HasLogLevelExtensions.WithLogLevel + // ════════════════════════════════════════════════════════════════ + + // Warning 实现了 IHasLogLevel(通过 Warning 基类) + private class LogLevelException : Exception, IHasLogLevel + { + public LogLevel LogLevel { get; set; } = LogLevel.Error; + } + + /// + /// 测试目的:WithLogLevel 应将传入的日志级别赋给异常,并返回该异常(链式调用)。 + /// + [Fact] + public void WithLogLevel_ShouldSetLogLevelAndReturnSameException() + { + // Arrange + var ex = new LogLevelException(); + + // Act + var returned = ex.WithLogLevel(LogLevel.Warning); + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Warning); + returned.ShouldBeSameAs(ex); + } + + /// + /// 测试目的:所有合法的 LogLevel 值均应能正确被 WithLogLevel 赋值。 + /// + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + public void WithLogLevel_AllLevels_ShouldSetCorrectly(LogLevel level) + { + // Arrange + var ex = new LogLevelException(); + + // Act + ex.WithLogLevel(level); + + // Assert + ex.LogLevel.ShouldBe(level); + } + + /// + /// 测试目的:传入 null 时 WithLogLevel 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithLogLevel_NullException_ShouldThrowArgumentNullException() + { + // Arrange + LogLevelException ex = null; + + // Act & Assert + Should.Throw(() => ex.WithLogLevel(LogLevel.Error)); + } + + // ════════════════════════════════════════════════════════════════ + // ExceptionPrompt + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddPrompt(null) 应抛出 ArgumentNullException。 + /// + [Fact] + public void ExceptionPrompt_AddPrompt_Null_ShouldThrow() + { + // Act & Assert + Should.Throw(() => ExceptionPrompt.AddPrompt(null)); + } + + /// + /// 测试目的:GetPrompt(null, ...) 应返回 null,不抛异常。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_NullException_ShouldReturnNull() + { + // Act + var result = ExceptionPrompt.GetPrompt(null, isProduction: true); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:普通异常在生产模式下 GetPrompt 应返回通用系统错误提示(不暴露原始消息)。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_SystemException_ProductionMode_ShouldReturnSystemError() + { + // Arrange + var ex = new InvalidOperationException("internal secret"); + + // Act + var result = ExceptionPrompt.GetPrompt(ex, isProduction: true); + + // Assert + result.ShouldNotBeNullOrWhiteSpace(); + result.ShouldNotContain("internal secret"); + } + + /// + /// 测试目的:Warning 异常在生产模式下 GetPrompt 应返回 Warning.GetMessage(true)(用户友好消息)。 + /// + [Fact] + public void ExceptionPrompt_GetPrompt_Warning_ProductionMode_ShouldReturnWarningMessage() + { + // Arrange + var ex = new Warning("操作被禁止"); + + // Act + var result = ExceptionPrompt.GetPrompt(ex, isProduction: true); + + // Assert + result.ShouldNotBeNullOrWhiteSpace(); + result.ShouldBe(ex.GetMessage(isProduction: true)); + } + + /// + /// 测试目的:GetException(null) 应返回 null,不抛异常。 + /// + [Fact] + public void ExceptionPrompt_GetException_Null_ShouldReturnNull() + { + // Act + var result = ExceptionPrompt.GetException(null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:GetException(普通异常) 在无自定义 prompt 时,应返回原始异常本身。 + /// + [Fact] + public void ExceptionPrompt_GetException_NoPrompts_ShouldReturnSameException() + { + // Arrange + var ex = new InvalidOperationException("raw"); + + // Act + var result = ExceptionPrompt.GetException(ex); + + // Assert + result.ShouldNotBeNull(); + } +} diff --git a/framework/tests/Bing.Core.Tests/Exceptions/ExceptionExtensionsAdditionalTest.cs b/framework/tests/Bing.Core.Tests/Exceptions/ExceptionExtensionsAdditionalTest.cs new file mode 100644 index 00000000..1646b1a1 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Exceptions/ExceptionExtensionsAdditionalTest.cs @@ -0,0 +1,210 @@ +using Bing.Exceptions; +using Bing.Logging; +using Microsoft.Extensions.Logging; +using Shouldly; + +namespace Bing.Tests.Exceptions; + +/// +/// ExceptionExtensions 扩展方法额外测试(GetHttpStatusCode / GetErrorCode / GetLogLevel) +/// +public class ExceptionExtensionsAdditionalTest +{ + // ==================== GetHttpStatusCode ==================== + + /// + /// 测试目的:传入 null 异常,GetHttpStatusCode 应返回 200。 + /// + [Fact] + public void GetHttpStatusCode_NullException_Returns200() + { + // Arrange + Exception? exception = null; + + // Act + var code = exception.GetHttpStatusCode(); + + // Assert + code.ShouldBe(200); + } + + /// + /// 测试目的:普通系统异常(非 Warning),GetHttpStatusCode 应返回 200。 + /// + [Fact] + public void GetHttpStatusCode_SystemException_Returns200() + { + // Arrange + var exception = new InvalidOperationException("something went wrong"); + + // Act + var code = exception.GetHttpStatusCode(); + + // Assert + code.ShouldBe(200); + } + + /// + /// 测试目的:Warning 异常带有 HttpStatusCode,GetHttpStatusCode 应返回该值。 + /// + [Fact] + public void GetHttpStatusCode_WarningWithStatusCode_ReturnsWarningStatusCode() + { + // Arrange + var warning = new Warning("出错了", httpStatusCode: 400); + + // Act + var code = warning.GetHttpStatusCode(); + + // Assert + code.ShouldBe(400); + } + + /// + /// 测试目的:Warning 未设置 HttpStatusCode(默认 0),GetHttpStatusCode 应返回 0(default int)。 + /// + [Fact] + public void GetHttpStatusCode_WarningWithoutStatusCode_ReturnsDefaultZero() + { + // Arrange + var warning = new Warning("出错了"); + + // Act + var code = warning.GetHttpStatusCode(); + + // Assert + code.ShouldBe(0); // default(int) + } + + // ==================== GetErrorCode ==================== + + /// + /// 测试目的:传入 null 异常,GetErrorCode 应返回 null。 + /// + [Fact] + public void GetErrorCode_NullException_ReturnsNull() + { + // Arrange + Exception? exception = null; + + // Act + var code = exception.GetErrorCode(); + + // Assert + code.ShouldBeNull(); + } + + /// + /// 测试目的:普通系统异常(非 Warning),GetErrorCode 应返回 null。 + /// + [Fact] + public void GetErrorCode_SystemException_ReturnsNull() + { + // Arrange + var exception = new Exception("error"); + + // Act + var code = exception.GetErrorCode(); + + // Assert + code.ShouldBeNull(); + } + + /// + /// 测试目的:Warning 带错误码,GetErrorCode 应返回该错误码。 + /// + [Fact] + public void GetErrorCode_WarningWithCode_ReturnsCode() + { + // Arrange + var warning = new Warning("出错了", code: "ERR-001"); + + // Act + var code = warning.GetErrorCode(); + + // Assert + code.ShouldBe("ERR-001"); + } + + /// + /// 测试目的:Warning 未设置 Code,GetErrorCode 应返回 null。 + /// + [Fact] + public void GetErrorCode_WarningWithoutCode_ReturnsNull() + { + // Arrange + var warning = new Warning("出错了"); + + // Act + var code = warning.GetErrorCode(); + + // Assert + code.ShouldBeNull(); + } + + // ==================== GetLogLevel ==================== + + /// + /// 测试目的:BusinessException 实现 IHasLogLevel,GetLogLevel 应返回其 LogLevel 属性值。 + /// + [Fact] + public void GetLogLevel_IHasLogLevel_ReturnsExceptionLogLevel() + { + // Arrange + var ex = new BusinessException("ERR", "msg", logLevel: LogLevel.Critical); + + // Act + var level = ex.GetLogLevel(); + + // Assert + level.ShouldBe(LogLevel.Critical); + } + + /// + /// 测试目的:普通异常(非 IHasLogLevel),默认返回 Error。 + /// + [Fact] + public void GetLogLevel_PlainException_ReturnsDefaultError() + { + // Arrange + var ex = new InvalidOperationException("error"); + + // Act + var level = ex.GetLogLevel(); + + // Assert + level.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:普通异常传入自定义 defaultLevel,应返回该自定义值。 + /// + [Fact] + public void GetLogLevel_PlainException_CustomDefault_ReturnsCustom() + { + // Arrange + var ex = new Exception("error"); + + // Act + var level = ex.GetLogLevel(LogLevel.Warning); + + // Assert + level.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:BusinessException LogLevel 为 Warning,不受 defaultLevel 参数影响。 + /// + [Fact] + public void GetLogLevel_IHasLogLevel_NotAffectedByDefaultLevel() + { + // Arrange + var ex = new BusinessException("ERR", "msg", logLevel: LogLevel.Warning); + + // Act:传入 Error 作为默认值,但应使用异常自身的 Warning + var level = ex.GetLogLevel(LogLevel.Error); + + // Assert + level.ShouldBe(LogLevel.Warning); + } +} diff --git a/framework/tests/Bing.Core.Tests/Threading/AmbientScopeTest.cs b/framework/tests/Bing.Core.Tests/Threading/AmbientScopeTest.cs new file mode 100644 index 00000000..02087e08 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Threading/AmbientScopeTest.cs @@ -0,0 +1,206 @@ +using Bing.Threading; +using Shouldly; + +namespace Bing.Tests.Threading; + +/// +/// AsyncLocalAmbientDataContext 和 AmbientDataContextAmbientScopeProvider 环境范围测试 +/// +public class AmbientScopeTest +{ + // ==================== AsyncLocalAmbientDataContext ==================== + + /// + /// 测试目的:SetData 后 GetData 应返回相同的值(基本读写)。 + /// + [Fact] + public void AsyncLocalDataContext_SetData_GetData_RoundTrip() + { + // Arrange + var ctx = new AsyncLocalAmbientDataContext(); + + // Act + ctx.SetData("key1", "value1"); + var result = ctx.GetData("key1"); + + // Assert + result.ShouldBe("value1"); + } + + /// + /// 测试目的:SetData 为 null 后 GetData 应返回 null。 + /// + [Fact] + public void AsyncLocalDataContext_SetData_Null_GetData_ReturnsNull() + { + // Arrange + var ctx = new AsyncLocalAmbientDataContext(); + ctx.SetData("key1", "value1"); + + // Act + ctx.SetData("key1", null); + var result = ctx.GetData("key1"); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:不同的键互不干扰。 + /// + [Fact] + public void AsyncLocalDataContext_DifferentKeys_AreIndependent() + { + // Arrange + var ctx = new AsyncLocalAmbientDataContext(); + + // Act + ctx.SetData("keyA", "A"); + ctx.SetData("keyB", "B"); + + // Assert + ctx.GetData("keyA").ShouldBe("A"); + ctx.GetData("keyB").ShouldBe("B"); + } + + /// + /// 测试目的:在异步任务中,AsyncLocal 数据应与当前上下文隔离(子任务不影响父)。 + /// + [Fact] + public async Task AsyncLocalDataContext_AsyncTask_IsIsolated() + { + // Arrange + var ctx = new AsyncLocalAmbientDataContext(); + ctx.SetData("key", "parent"); + + string? childValue = null; + + // Act:在独立任务中修改,不应影响父上下文 + await Task.Run(() => + { + ctx.SetData("key", "child"); + childValue = ctx.GetData("key") as string; + }); + + // Assert:父上下文的值不被子任务覆盖(AsyncLocal 隔离) + // 注意:AsyncLocal 在子任务中的修改不会流回父上下文 + childValue.ShouldBe("child"); + } + + // ==================== AmbientDataContextAmbientScopeProvider ==================== + + private static AmbientDataContextAmbientScopeProvider CreateProvider() + { + var ctx = new AsyncLocalAmbientDataContext(); + return new AmbientDataContextAmbientScopeProvider(ctx); + } + + /// + /// 测试目的:未开始任何 Scope 时,GetValue 应返回类型默认值(null for string)。 + /// + [Fact] + public void AmbientScope_GetValue_OutsideScope_ReturnsDefault() + { + // Arrange + var provider = CreateProvider(); + + // Act + var value = provider.GetValue("ctx"); + + // Assert + value.ShouldBeNull(); + } + + /// + /// 测试目的:BeginScope 后 GetValue 应返回设置的值。 + /// + [Fact] + public void AmbientScope_GetValue_InsideScope_ReturnsValue() + { + // Arrange + var provider = CreateProvider(); + + // Act + using (provider.BeginScope("ctx", "hello")) + { + // Assert + provider.GetValue("ctx").ShouldBe("hello"); + } + } + + /// + /// 测试目的:Dispose scope 后,GetValue 应恢复为默认值 null。 + /// + [Fact] + public void AmbientScope_GetValue_AfterDispose_ReturnsDefault() + { + // Arrange + var provider = CreateProvider(); + + // Act + using (provider.BeginScope("ctx", "hello")) + { + provider.GetValue("ctx").ShouldBe("hello"); + } + + // Assert + provider.GetValue("ctx").ShouldBeNull(); + } + + /// + /// 测试目的:嵌套 BeginScope 内层值覆盖外层,内层 Dispose 后恢复外层值。 + /// + [Fact] + public void AmbientScope_Nested_InnerValueOverridesOuter_ThenRestores() + { + // Arrange + var provider = CreateProvider(); + + // Act & Assert + using (provider.BeginScope("ctx", "outer")) + { + provider.GetValue("ctx").ShouldBe("outer"); + + using (provider.BeginScope("ctx", "inner")) + { + provider.GetValue("ctx").ShouldBe("inner"); + } + + // 内层 Dispose 后恢复外层 + provider.GetValue("ctx").ShouldBe("outer"); + } + + // 外层 Dispose 后恢复默认 + provider.GetValue("ctx").ShouldBeNull(); + } + + /// + /// 测试目的:不同的 contextKey 互不干扰。 + /// + [Fact] + public void AmbientScope_DifferentKeys_AreIndependent() + { + // Arrange + var provider = CreateProvider(); + + // Act + using (provider.BeginScope("ctxA", "A")) + using (provider.BeginScope("ctxB", "B")) + { + // Assert + provider.GetValue("ctxA").ShouldBe("A"); + provider.GetValue("ctxB").ShouldBe("B"); + } + } + + /// + /// 测试目的:AmbientDataContextAmbientScopeProvider 构造函数传入 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void AmbientScopeProvider_NullDataContext_ThrowsArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new AmbientDataContextAmbientScopeProvider(null!)); + } +} diff --git a/framework/tests/Bing.Core.Tests/Threading/CallContextAndAsyncLocalTest.cs b/framework/tests/Bing.Core.Tests/Threading/CallContextAndAsyncLocalTest.cs new file mode 100644 index 00000000..082d0067 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Threading/CallContextAndAsyncLocalTest.cs @@ -0,0 +1,246 @@ +using Bing.Threading; +using Shouldly; +using Xunit; + +namespace Bing.Threading; + +// ========================================================================= +// CallContext Tests +// ========================================================================= + +/// +/// 测试目的:验证 CallContext 的 SetValue / GetValue / Remove / Clear 语义正确性。 +/// +public class CallContextTest +{ + // 每个测试用唯一键名,避免并发/顺序干扰 + private static string Key(string suffix) => $"CallContext_Test_{suffix}_{Guid.NewGuid():N}"; + + /// + /// 测试目的:SetValue 后 GetValue 应返回同一对象。 + /// + [Fact] + public void SetValue_ThenGetValue_ShouldReturnSameObject() + { + // Arrange + var key = Key("set"); + var value = new object(); + + // Act + CallContext.SetValue(key, value); + + // Assert + CallContext.GetValue(key).ShouldBeSameAs(value); + } + + /// + /// 测试目的:未设置的 key GetValue 应返回 null。 + /// + [Fact] + public void GetValue_NotSet_ShouldReturnNull() + { + var key = Key("notset"); + CallContext.GetValue(key).ShouldBeNull(); + } + + /// + /// 测试目的:SetValue 设置的值可以被覆盖,GetValue 应返回最新值。 + /// + [Fact] + public void SetValue_Overwrite_ShouldReturnLatestValue() + { + // Arrange + var key = Key("overwrite"); + CallContext.SetValue(key, "first"); + + // Act + CallContext.SetValue(key, "second"); + + // Assert + CallContext.GetValue(key).ShouldBe("second"); + } + + /// + /// 测试目的:Remove 后 GetValue 应返回 null。 + /// + [Fact] + public void Remove_AfterSet_ShouldMakeValueNull() + { + // Arrange + var key = Key("remove"); + CallContext.SetValue(key, "data"); + + // Act + CallContext.Remove(key); + + // Assert + CallContext.GetValue(key).ShouldBeNull(); + } + + /// + /// 测试目的:Remove 不存在的 key 时不应抛出异常。 + /// + [Fact] + public void Remove_NonExistentKey_ShouldNotThrow() + { + var key = Key("remove_nonexistent"); + Should.NotThrow(() => CallContext.Remove(key)); + } + + /// + /// 测试目的:SetValue 支持 null 值,GetValue 应返回 null。 + /// + [Fact] + public void SetValue_NullValue_GetValue_ShouldReturnNull() + { + // Arrange + var key = Key("null_value"); + + // Act + CallContext.SetValue(key, null); + + // Assert + CallContext.GetValue(key).ShouldBeNull(); + } + + /// + /// 测试目的:不同 key 的值相互独立,不会相互干扰。 + /// + [Fact] + public void SetValue_DifferentKeys_ShouldBeIndependent() + { + // Arrange + var key1 = Key("key1"); + var key2 = Key("key2"); + + // Act + CallContext.SetValue(key1, "value1"); + CallContext.SetValue(key2, "value2"); + + // Assert + CallContext.GetValue(key1).ShouldBe("value1"); + CallContext.GetValue(key2).ShouldBe("value2"); + } + + /// + /// 测试目的:SetValue 支持存储不同类型的对象。 + /// + [Fact] + public void SetValue_SupportsDifferentTypes() + { + // Arrange + var intKey = Key("int"); + var listKey = Key("list"); + + // Act + CallContext.SetValue(intKey, 42); + CallContext.SetValue(listKey, new List { "a", "b" }); + + // Assert + CallContext.GetValue(intKey).ShouldBe(42); + ((List)CallContext.GetValue(listKey)).Count.ShouldBe(2); + } +} + +// ========================================================================= +// AsyncLocalExtensions Tests +// ========================================================================= + +/// +/// 测试目的:验证 AsyncLocal<T>.SetScoped 的范围设定与恢复行为。 +/// +public class AsyncLocalExtensionsTest +{ + /// + /// 测试目的:SetScoped 设置新值后,在范围内 AsyncLocal 值应为新值。 + /// + [Fact] + public void SetScoped_InScope_ShouldHaveNewValue() + { + // Arrange + var local = new AsyncLocal(); + local.Value = "original"; + + // Act & Assert + using (local.SetScoped("scoped")) + { + local.Value.ShouldBe("scoped"); + } + } + + /// + /// 测试目的:离开 SetScoped 范围后,AsyncLocal 应恢复为原值。 + /// + [Fact] + public void SetScoped_AfterDispose_ShouldRestoreOriginalValue() + { + // Arrange + var local = new AsyncLocal(); + local.Value = "original"; + + // Act + var scope = local.SetScoped("new-value"); + scope.Dispose(); + + // Assert + local.Value.ShouldBe("original"); + } + + /// + /// 测试目的:嵌套 SetScoped 时,内层结束后恢复外层值,外层结束后恢复原始值。 + /// + [Fact] + public void SetScoped_Nested_ShouldRestoreLayerByLayer() + { + // Arrange + var local = new AsyncLocal(); + local.Value = 0; + + // Act & Assert + using (local.SetScoped(1)) + { + local.Value.ShouldBe(1); + + using (local.SetScoped(2)) + { + local.Value.ShouldBe(2); + } + + local.Value.ShouldBe(1); // 恢复外层 + } + + local.Value.ShouldBe(0); // 恢复原始值 + } + + /// + /// 测试目的:原始值为 null 时,SetScoped 结束后应恢复为 null。 + /// + [Fact] + public void SetScoped_OriginalNull_ShouldRestoreNull() + { + // Arrange + var local = new AsyncLocal(); + // local.Value == null by default + + // Act + using (local.SetScoped("temp")) + { + local.Value.ShouldBe("temp"); + } + + // Assert + local.Value.ShouldBeNull(); + } + + /// + /// 测试目的:SetScoped 返回的 IDisposable 不为 null。 + /// + [Fact] + public void SetScoped_ReturnsNonNullDisposable() + { + var local = new AsyncLocal(); + var scope = local.SetScoped(99); + scope.ShouldNotBeNull(); + scope.Dispose(); // 不抛 + } +} diff --git a/framework/tests/Bing.Core.Tests/Timing/ClockTest.cs b/framework/tests/Bing.Core.Tests/Timing/ClockTest.cs new file mode 100644 index 00000000..90650f40 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Timing/ClockTest.cs @@ -0,0 +1,147 @@ +using Bing.Timing; +using Shouldly; + +namespace Bing.Tests.Timing; + +/// +/// IClock / SystemClock 时钟测试 +/// +public class ClockTest +{ + // ==================== SystemClock 基本属性 ==================== + + /// + /// 测试目的:SystemClock.Now 应返回本地时间(Kind == Local)。 + /// + [Fact] + public void SystemClock_Now_Kind_IsLocal() + { + // Arrange + var clock = new SystemClock(); + + // Act + var now = clock.Now; + + // Assert + now.Kind.ShouldBe(DateTimeKind.Local); + } + + /// + /// 测试目的:SystemClock.UtcNow 应返回 UTC 时间(Kind == Utc)。 + /// + [Fact] + public void SystemClock_UtcNow_Kind_IsUtc() + { + // Arrange + var clock = new SystemClock(); + + // Act + var utcNow = clock.UtcNow; + + // Assert + utcNow.Kind.ShouldBe(DateTimeKind.Utc); + } + + /// + /// 测试目的:SystemClock.NowOffset 返回合法的 DateTimeOffset,偏移量与系统一致。 + /// + [Fact] + public void SystemClock_NowOffset_HasOffset() + { + // Arrange + var clock = new SystemClock(); + + // Act + var offset = clock.NowOffset; + var systemOffset = DateTimeOffset.Now; + + // Assert:偏移量与系统时区一致(允许 1 秒误差) + (systemOffset - offset).Duration().ShouldBeLessThan(TimeSpan.FromSeconds(1)); + } + + /// + /// 测试目的:SystemClock.Now 返回时间与系统当前时间偏差应在 1 秒以内。 + /// + [Fact] + public void SystemClock_Now_CloseToSystemTime() + { + // Arrange + var clock = new SystemClock(); + var before = DateTime.Now; + + // Act + var now = clock.Now; + var after = DateTime.Now; + + // Assert + now.ShouldBeGreaterThanOrEqualTo(before); + now.ShouldBeLessThanOrEqualTo(after); + } + + /// + /// 测试目的:SystemClock.UtcNow 与 Now.ToUniversalTime() 偏差在 1 秒以内。 + /// + [Fact] + public void SystemClock_UtcNow_CloseToNowToUniversalTime() + { + // Arrange + var clock = new SystemClock(); + + // Act + var utcNow = clock.UtcNow; + var localToUtc = clock.Now.ToUniversalTime(); + + // Assert + (utcNow - localToUtc).Duration().ShouldBeLessThan(TimeSpan.FromSeconds(1)); + } + + // ==================== SystemClock.Instance 静态单例 ==================== + + /// + /// 测试目的:SystemClock.Instance 静态单例不为 null,且实现 IClock 接口。 + /// + [Fact] + public void SystemClock_Instance_IsNotNull_And_ImplementsIClock() + { + // Act + var instance = SystemClock.Instance; + + // Assert + instance.ShouldNotBeNull(); + instance.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:SystemClock.Instance 多次访问返回同一对象(单例)。 + /// + [Fact] + public void SystemClock_Instance_ReturnsSameObject() + { + // Act + var a = SystemClock.Instance; + var b = SystemClock.Instance; + + // Assert + a.ShouldBeSameAs(b); + } + + // ==================== IClock 协议 ==================== + + /// + /// 测试目的:SystemClock 实现 IClock 接口,通过接口使用 Now / UtcNow / NowOffset 均正常。 + /// + [Fact] + public void SystemClock_AsIClock_PropertiesAccessible() + { + // Arrange + IClock clock = new SystemClock(); + + // Act & Assert(不抛异常,且属性有值) + Should.NotThrow(() => + { + _ = clock.Now; + _ = clock.UtcNow; + _ = clock.NowOffset; + }); + } +} diff --git a/framework/tests/Bing.Core.Tests/Tracing/TracingAndCancellationTest.cs b/framework/tests/Bing.Core.Tests/Tracing/TracingAndCancellationTest.cs new file mode 100644 index 00000000..6eaff7ad --- /dev/null +++ b/framework/tests/Bing.Core.Tests/Tracing/TracingAndCancellationTest.cs @@ -0,0 +1,235 @@ +using Bing.ExceptionHandling; +using Bing.Threading; +using Bing.Tracing; +using Microsoft.Extensions.Logging; + +namespace Bing.Tests.Core; + +/// +/// TraceIdContext、NoneCancellationTokenProvider、NullExceptionNotifier、 +/// ExceptionNotificationContext 单元测试 +/// +public class TracingAndCancellationTest +{ + // ════════════════════════════════════════════════════════════════ + // TraceIdContext + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入有效 traceId 时,TraceId 属性应与传入值一致。 + /// + [Fact] + public void TraceIdContext_WithValidTraceId_ShouldStoreTraceId() + { + // Arrange & Act + var ctx = new TraceIdContext("abc-123"); + + // Assert + ctx.TraceId.ShouldBe("abc-123"); + } + + /// + /// 测试目的:传入 null 或空字符串时,构造函数应自动生成一个 Guid 作为 TraceId。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void TraceIdContext_WithNullOrEmpty_ShouldAutoGenerateTraceId(string traceId) + { + // Act + var ctx = new TraceIdContext(traceId); + + // Assert + ctx.TraceId.ShouldNotBeNullOrWhiteSpace(); + Guid.TryParse(ctx.TraceId, out _).ShouldBeTrue(); + } + + /// + /// 测试目的:四参数构造函数应正确保存所有字段。 + /// + [Fact] + public void TraceIdContext_FourParamCtor_ShouldStoreAllFields() + { + // Act + var ctx = new TraceIdContext("t1", "r1", "p1", "c1"); + + // Assert + ctx.TraceId.ShouldBe("t1"); + ctx.RootId.ShouldBe("r1"); + ctx.ParentId.ShouldBe("p1"); + ctx.ChildId.ShouldBe("c1"); + } + + /// + /// 测试目的:Current 静态属性应基于 AsyncLocal,在同一异步上下文内可读写。 + /// + [Fact] + public void TraceIdContext_Current_SetAndGet_ShouldReturnSameInstance() + { + // Arrange + var ctx = new TraceIdContext("my-trace"); + var original = TraceIdContext.Current; + + try + { + // Act + TraceIdContext.Current = ctx; + + // Assert + TraceIdContext.Current.ShouldBeSameAs(ctx); + } + finally + { + TraceIdContext.Current = original; + } + } + + /// + /// 测试目的:不同异步任务应拥有独立的 Current 值(AsyncLocal 隔离性验证)。 + /// + [Fact] + public async Task TraceIdContext_Current_ShouldBeIsolatedAcrossTasks() + { + // Arrange + TraceIdContext.Current = null; + var ctx = new TraceIdContext("parent"); + TraceIdContext.Current = ctx; + + // Act - 子任务设置独立的 Current + TraceIdContext childCtx = null; + await Task.Run(() => + { + TraceIdContext.Current = new TraceIdContext("child"); + childCtx = TraceIdContext.Current; + }); + + // Assert - 父上下文不受子任务影响 + TraceIdContext.Current.TraceId.ShouldBe("parent"); + childCtx.TraceId.ShouldBe("child"); + + // Cleanup + TraceIdContext.Current = null; + } + + // ════════════════════════════════════════════════════════════════ + // NoneCancellationTokenProvider + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:Instance 不为 null,且是单例(两次访问同一对象)。 + /// + [Fact] + public void NoneCancellationTokenProvider_Instance_IsSingleton() + { + // Act + var a = NoneCancellationTokenProvider.Instance; + var b = NoneCancellationTokenProvider.Instance; + + // Assert + a.ShouldNotBeNull(); + a.ShouldBeSameAs(b); + } + + /// + /// 测试目的:Token 应等于 CancellationToken.None(不可取消)。 + /// + [Fact] + public void NoneCancellationTokenProvider_Token_ShouldBeCancellationTokenNone() + { + // Act + var token = NoneCancellationTokenProvider.Instance.Token; + + // Assert + token.ShouldBe(CancellationToken.None); + token.CanBeCanceled.ShouldBeFalse(); + } + + // ════════════════════════════════════════════════════════════════ + // NullExceptionNotifier + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:Instance 不为 null,且实现 IExceptionNotifier。 + /// + [Fact] + public void NullExceptionNotifier_Instance_IsNotNullAndImplementsInterface() + { + NullExceptionNotifier.Instance.ShouldNotBeNull(); + NullExceptionNotifier.Instance.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:NotifyAsync 应静默完成,不抛异常,返回已完成的 Task。 + /// + [Fact] + public async Task NullExceptionNotifier_NotifyAsync_ShouldCompleteWithoutThrowing() + { + // Arrange + var ctx = new ExceptionNotificationContext(new InvalidOperationException("test")); + + // Act & Assert + await Should.NotThrowAsync(() => NullExceptionNotifier.Instance.NotifyAsync(ctx)); + } + + // ════════════════════════════════════════════════════════════════ + // ExceptionNotificationContext + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入 null 异常时,构造函数应抛出 ArgumentNullException。 + /// + [Fact] + public void ExceptionNotificationContext_NullException_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => new ExceptionNotificationContext(null)); + } + + /// + /// 测试目的:不传 logLevel 时,LogLevel 应由 exception.GetLogLevel() 自动推断(普通异常默认 Error)。 + /// + [Fact] + public void ExceptionNotificationContext_DefaultLogLevel_ShouldBeError() + { + // Arrange + var ex = new InvalidOperationException("boom"); + + // Act + var ctx = new ExceptionNotificationContext(ex); + + // Assert + ctx.Exception.ShouldBeSameAs(ex); + ctx.LogLevel.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:显式传入 LogLevel 时,应使用传入值而不是自动推断值。 + /// + [Fact] + public void ExceptionNotificationContext_ExplicitLogLevel_ShouldOverrideDefault() + { + // Arrange + var ex = new InvalidOperationException("info"); + + // Act + var ctx = new ExceptionNotificationContext(ex, LogLevel.Warning); + + // Assert + ctx.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:Handled 默认值应为 true,且可被显式设置为 false。 + /// + [Fact] + public void ExceptionNotificationContext_Handled_DefaultIsTrueAndCanBeChanged() + { + // Arrange & Act + var ctxDefault = new ExceptionNotificationContext(new Exception("x")); + var ctxFalse = new ExceptionNotificationContext(new Exception("y"), handled: false); + + // Assert + ctxDefault.Handled.ShouldBeTrue(); + ctxFalse.Handled.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Core.Tests/UserFriendlyExceptionAndLocalLockTest.cs b/framework/tests/Bing.Core.Tests/UserFriendlyExceptionAndLocalLockTest.cs new file mode 100644 index 00000000..48865de6 --- /dev/null +++ b/framework/tests/Bing.Core.Tests/UserFriendlyExceptionAndLocalLockTest.cs @@ -0,0 +1,277 @@ +using Bing.Locks; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace Bing.Tests; + +// ========================================================================= +// UserFriendlyException Tests +// ========================================================================= + +/// +/// 测试目的:验证 UserFriendlyException 的构造参数、属性赋值及继承链。 +/// +public class UserFriendlyExceptionTest +{ + /// + /// 测试目的:仅传入 message 时,Message 属性应正确设置,其余为默认值。 + /// + [Fact] + public void Constructor_MessageOnly_ShouldSetMessage() + { + // Arrange & Act + var ex = new UserFriendlyException("操作失败"); + + // Assert + ex.Message.ShouldBe("操作失败"); + ex.Code.ShouldBeNull(); + ex.Details.ShouldBeNull(); + ex.InnerException.ShouldBeNull(); + ex.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:传入 code 时,Code 应被正确赋值。 + /// + [Fact] + public void Constructor_WithCode_ShouldSetCode() + { + // Arrange & Act + var ex = new UserFriendlyException("错误", code: "ERR-001"); + + // Assert + ex.Code.ShouldBe("ERR-001"); + ex.Message.ShouldBe("错误"); + } + + /// + /// 测试目的:传入 details 时,Details 应被正确赋值。 + /// + [Fact] + public void Constructor_WithDetails_ShouldSetDetails() + { + // Arrange & Act + var ex = new UserFriendlyException("错误", details: "详细描述"); + + // Assert + ex.Details.ShouldBe("详细描述"); + } + + /// + /// 测试目的:传入 innerException 时,InnerException 应被正确赋值。 + /// + [Fact] + public void Constructor_WithInnerException_ShouldSetInnerException() + { + // Arrange + var inner = new InvalidOperationException("inner"); + + // Act + var ex = new UserFriendlyException("外层", innerException: inner); + + // Assert + ex.InnerException.ShouldBeSameAs(inner); + } + + /// + /// 测试目的:自定义 logLevel 时,LogLevel 应被正确赋值。 + /// + [Fact] + public void Constructor_WithLogLevel_ShouldSetLogLevel() + { + var ex = new UserFriendlyException("错误", logLevel: LogLevel.Error); + ex.LogLevel.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:UserFriendlyException 应实现 IUserFriendlyException 接口。 + /// + [Fact] + public void UserFriendlyException_ShouldImplementIUserFriendlyException() + { + var ex = new UserFriendlyException("错误"); + ex.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:UserFriendlyException 应继承自 BusinessException。 + /// + [Fact] + public void UserFriendlyException_ShouldInheritFromBusinessException() + { + var ex = new UserFriendlyException("错误"); + ex.ShouldBeAssignableTo(); + } +} + +// ========================================================================= +// LocalLock Tests +// ========================================================================= + +/// +/// 测试目的:验证 LocalLock 的加锁/释放锁/ExecuteWithLock 逻辑。 +/// +public class LocalLockTest +{ + private static string Key(string suffix) => $"LocalLock_{suffix}_{Guid.NewGuid():N}"; + + // ----------------------------------------------------------------- + // LockTake / LockRelease + // ----------------------------------------------------------------- + + /// + /// 测试目的:LockTake 对未加锁的 key 应返回 true。 + /// + [Fact] + public void LockTake_UnlockedKey_ShouldReturnTrue() + { + var key = Key("take"); + var @lock = new LocalLock(); + var result = @lock.LockTake(key, "v1", TimeSpan.FromSeconds(5)); + + result.ShouldBeTrue(); + + // 清理 + @lock.LockRelease(key, "v1"); + } + + /// + /// 测试目的:LockRelease 已锁定的 key 应返回 true。 + /// + [Fact] + public void LockRelease_AfterLockTake_ShouldReturnTrue() + { + var key = Key("release"); + var @lock = new LocalLock(); + @lock.LockTake(key, "v1", TimeSpan.FromSeconds(5)); + + var released = @lock.LockRelease(key, "v1"); + + released.ShouldBeTrue(); + } + + /// + /// 测试目的:对未加锁的 key 调用 LockRelease 应返回 true(无锁可释放,视为成功)。 + /// + [Fact] + public void LockRelease_NeverLocked_ShouldReturnTrue() + { + var key = Key("never_locked"); + var @lock = new LocalLock(); + + var result = @lock.LockRelease(key, "v1"); + + result.ShouldBeTrue(); + } + + // ----------------------------------------------------------------- + // LockTakeAsync / LockReleaseAsync + // ----------------------------------------------------------------- + + /// + /// 测试目的:LockTakeAsync 对未加锁 key 应返回 true。 + /// + [Fact] + public async Task LockTakeAsync_UnlockedKey_ShouldReturnTrue() + { + var key = Key("async_take"); + var @lock = new LocalLock(); + + var result = await @lock.LockTakeAsync(key, "v1", TimeSpan.FromSeconds(5)); + + result.ShouldBeTrue(); + await @lock.LockReleaseAsync(key, "v1"); + } + + // ----------------------------------------------------------------- + // ExecuteWithLock + // ----------------------------------------------------------------- + + /// + /// 测试目的:ExecuteWithLock 获锁成功时应执行传入的 Action。 + /// + [Fact] + public void ExecuteWithLock_WhenLockAcquired_ShouldExecuteAction() + { + var key = Key("exec"); + var @lock = new LocalLock(); + var executed = false; + + @lock.ExecuteWithLock(key, "v1", TimeSpan.FromSeconds(5), () => { executed = true; }); + + executed.ShouldBeTrue(); + } + + /// + /// 测试目的:ExecuteWithLock Action 为 null 时不应抛出异常。 + /// + [Fact] + public void ExecuteWithLock_NullAction_ShouldNotThrow() + { + var key = Key("null_action"); + var @lock = new LocalLock(); + + Should.NotThrow(() => @lock.ExecuteWithLock(key, "v1", TimeSpan.FromSeconds(5), (Action)null)); + } + + /// + /// 测试目的:ExecuteWithLock 泛型重载 — Action 为 null 时应返回 defaultValue。 + /// + [Fact] + public void ExecuteWithLock_Generic_NullAction_ShouldReturnDefault() + { + var key = Key("generic_null"); + var @lock = new LocalLock(); + + var result = @lock.ExecuteWithLock(key, "v1", TimeSpan.FromSeconds(5), (Func)null, -1); + + result.ShouldBe(-1); + } + + /// + /// 测试目的:ExecuteWithLock 泛型重载 — 锁成功时应执行 Func 并返回其结果。 + /// + [Fact] + public void ExecuteWithLock_Generic_WhenLockAcquired_ShouldReturnFuncResult() + { + var key = Key("generic_exec"); + var @lock = new LocalLock(); + + var result = @lock.ExecuteWithLock(key, "v1", TimeSpan.FromSeconds(5), () => 42); + + result.ShouldBe(42); + } + + // ----------------------------------------------------------------- + // ExecuteWithLockAsync + // ----------------------------------------------------------------- + + /// + /// 测试目的:ExecuteWithLockAsync 获锁成功时应执行异步 Action。 + /// + [Fact] + public async Task ExecuteWithLockAsync_WhenLockAcquired_ShouldExecuteAction() + { + var key = Key("async_exec"); + var @lock = new LocalLock(); + var executed = false; + + await @lock.ExecuteWithLockAsync(key, "v1", TimeSpan.FromSeconds(5), + async () => { executed = true; await Task.CompletedTask; }); + + executed.ShouldBeTrue(); + } + + /// + /// 测试目的:ExecuteWithLockAsync Action 为 null 时不应抛出异常。 + /// + [Fact] + public async Task ExecuteWithLockAsync_NullAction_ShouldNotThrow() + { + var key = Key("async_null"); + var @lock = new LocalLock(); + + await Should.NotThrowAsync(() => @lock.ExecuteWithLockAsync(key, "v1", TimeSpan.FromSeconds(5), (Func)null)); + } +} diff --git a/framework/tests/Bing.Dapper.MySql.Tests.Integration/Startup.cs b/framework/tests/Bing.Dapper.MySql.Tests.Integration/Startup.cs index d3d240b4..11738dae 100644 --- a/framework/tests/Bing.Dapper.MySql.Tests.Integration/Startup.cs +++ b/framework/tests/Bing.Dapper.MySql.Tests.Integration/Startup.cs @@ -25,7 +25,14 @@ public class Startup public void ConfigureHost(IHostBuilder hostBuilder) { hostBuilder.ConfigureDefaults(null) - .UseServiceContext(); + .UseServiceContext() + .ConfigureAppConfiguration((_, builder) => + { + // 支持通过 appsettings.Development.json 覆盖连接字符串(本地开发,不提交到 Git) + builder.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false); + // 支持通过环境变量 ConnectionStrings__DefaultConnection 覆盖(CI/CD 使用) + builder.AddEnvironmentVariables(); + }); } /// diff --git a/framework/tests/Bing.Dapper.MySql.Tests.Integration/appsettings.json b/framework/tests/Bing.Dapper.MySql.Tests.Integration/appsettings.json index ef4ad762..ef8b9dbf 100644 --- a/framework/tests/Bing.Dapper.MySql.Tests.Integration/appsettings.json +++ b/framework/tests/Bing.Dapper.MySql.Tests.Integration/appsettings.json @@ -6,6 +6,10 @@ } }, "ConnectionStrings": { - "DefaultConnection": "server=10.186.135.162;user=root;password=bing2019.00;database=bing_dapper_test;port=3306;charset=utf8" + "DefaultConnection": "" + }, + "IntegrationTest": { + "Note": "请通过环境变量 'ConnectionStrings__DefaultConnection' 或 appsettings.Development.json 提供连接字符串。不要在此文件中硬编码数据库凭据。", + "ExampleConnectionString": "server=localhost;user=root;password=;database=bing_dapper_test;port=3306;charset=utf8" } } \ No newline at end of file diff --git a/framework/tests/Bing.Dapper.MySql.Tests/Builders/MySqlDialectTest.cs b/framework/tests/Bing.Dapper.MySql.Tests/Builders/MySqlDialectTest.cs new file mode 100644 index 00000000..557ffc86 --- /dev/null +++ b/framework/tests/Bing.Dapper.MySql.Tests/Builders/MySqlDialectTest.cs @@ -0,0 +1,179 @@ +using Bing.Data.Sql.Builders; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试 +/// +public class MySqlDialectTest +{ + private readonly IDialect _dialect = MySqlDialect.Instance; + + // ═══════════════════════════════════════════════════════════ + // 标识符 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:OpeningIdentifier 应为反引号 '`',符合 MySQL 标识符规范。 + /// + [Fact] + public void OpeningIdentifier_ShouldBeBacktick() + { + _dialect.OpeningIdentifier.ShouldBe('`'); + } + + /// + /// 测试目的:ClosingIdentifier 应为反引号 '`'。 + /// + [Fact] + public void ClosingIdentifier_ShouldBeBacktick() + { + _dialect.ClosingIdentifier.ShouldBe('`'); + } + + // ═══════════════════════════════════════════════════════════ + // SafeName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SafeName 对普通名称应用反引号包裹,以适配 MySQL 保留字规避。 + /// + [Fact] + public void SafeName_PlainName_ShouldWrapWithBackticks() + { + _dialect.SafeName("user").ShouldBe("`user`"); + } + + /// + /// 测试目的:SafeName 对通配符 "*" 应保持不变,不包裹。 + /// + [Fact] + public void SafeName_Wildcard_ShouldReturnAsIs() + { + _dialect.SafeName("*").ShouldBe("*"); + } + + /// + /// 测试目的:SafeName 对空字符串应返回空字符串,不抛异常。 + /// + [Fact] + public void SafeName_EmptyString_ShouldReturnEmpty() + { + _dialect.SafeName(string.Empty).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对 null 应返回空字符串,不抛异常。 + /// + [Fact] + public void SafeName_Null_ShouldReturnEmpty() + { + _dialect.SafeName(null).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对已有方括号包裹的名称,应剥去后改用反引号包裹。 + /// + [Fact] + public void SafeName_AlreadyBracketWrapped_ShouldRewrapWithBacktick() + { + _dialect.SafeName("[order_id]").ShouldBe("`order_id`"); + } + + /// + /// 测试目的:SafeName 对已有双引号包裹的名称,应剥去后改用反引号包裹。 + /// + [Fact] + public void SafeName_AlreadyDoubleQuoteWrapped_ShouldRewrapWithBacktick() + { + _dialect.SafeName("\"created_at\"").ShouldBe("`created_at`"); + } + + /// + /// 测试目的:SafeName 对已有反引号包裹的名称,应剥去后重新包裹(幂等性)。 + /// + [Fact] + public void SafeName_AlreadyBacktickWrapped_ShouldBeIdempotent() + { + _dialect.SafeName("`table_name`").ShouldBe("``table_name``"); + } + + // ═══════════════════════════════════════════════════════════ + // GetPrefix(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetPrefix() 应返回 "@"(MySqlDialect 未覆盖,继承基类默认值)。 + /// + [Fact] + public void GetPrefix_ShouldReturnAtSign() + { + _dialect.GetPrefix().ShouldBe("@"); + } + + // ═══════════════════════════════════════════════════════════ + // SupportSelectAs(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SupportSelectAs() 应返回 true(MySqlDialect 未覆盖,继承基类默认值)。 + /// + [Fact] + public void SupportSelectAs_ShouldReturnTrue() + { + _dialect.SupportSelectAs().ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // GenerateName(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GenerateName(0) 应返回 "@_p_0"(继承基类默认格式)。 + /// + [Fact] + public void GenerateName_Index0_ShouldReturnAtP0() + { + _dialect.GenerateName(0).ShouldBe("@_p_0"); + } + + // ═══════════════════════════════════════════════════════════ + // GetParamValue(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetParamValue(bool) 应保持原始类型(基类直接透传)。 + /// + [Fact] + public void GetParamValue_Bool_ShouldReturnOriginalValue() + { + _dialect.GetParamValue(true).ShouldBe(true); + _dialect.GetParamValue(false).ShouldBe(false); + } + + /// + /// 测试目的:GetParamValue(null) 应返回 null(基类直接透传,与 Oracle 不同)。 + /// + [Fact] + public void GetParamValue_Null_ShouldReturnNull() + { + _dialect.GetParamValue(null).ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // Instance + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:MySqlDialect.Instance 应为 MySqlDialect 类型,不为 null。 + /// + [Fact] + public void Instance_ShouldBeMySqlDialectType() + { + var instance = MySqlDialect.Instance; + instance.ShouldNotBeNull(); + instance.ShouldBeOfType(); + } +} diff --git a/framework/tests/Bing.Dapper.MySql.Tests/Handlers/GuidTypeHandlerTest.cs b/framework/tests/Bing.Dapper.MySql.Tests/Handlers/GuidTypeHandlerTest.cs new file mode 100644 index 00000000..58493d49 --- /dev/null +++ b/framework/tests/Bing.Dapper.MySql.Tests/Handlers/GuidTypeHandlerTest.cs @@ -0,0 +1,204 @@ +using System.Data; +using Dapper.Handlers; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Handlers; + +/// +/// 单元测试 +/// 覆盖 MySQL 特有的 Guid 字节序转换逻辑(Data1/Data2/Data3 大端序存储)。 +/// +public class GuidTypeHandlerTest +{ + private readonly GuidTypeHandler _handler = new(); + + // ═══════════════════════════════════════════════════════════ + // Parse + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Parse(null) 应返回 Guid.Empty,不抛 NullReferenceException。 + /// + [Fact] + public void Parse_WhenNull_ShouldReturnGuidEmpty() + { + // Act + var result = _handler.Parse(null!); + + // Assert + result.ShouldBe(Guid.Empty); + } + + /// + /// 测试目的:Parse 对经过字节序转换的字节数组,应能还原出原始 Guid(双向转换幂等)。 + /// + [Fact] + public void Parse_AfterSetValue_ShouldRoundTripCorrectly() + { + // Arrange + var original = Guid.NewGuid(); + var mockParam = new Mock(); + byte[]? capturedBytes = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => capturedBytes = (byte[])v); + + // Act + _handler.SetValue(mockParam.Object, original); + var restored = _handler.Parse(capturedBytes!); + + // Assert + restored.ShouldBe(original); + } + + /// + /// 测试目的:Parse 对全零字节(对应 Guid.Empty 的存储格式),应还原为 Guid.Empty。 + /// + [Fact] + public void Parse_AllZeroBytes_ShouldReturnGuidEmpty() + { + // Arrange + var zeroBytes = new byte[16]; + + // Act + var result = _handler.Parse(zeroBytes); + + // Assert + result.ShouldBe(Guid.Empty); + } + + /// + /// 测试目的:Parse 处理的字节数组长度应为 16,与标准 Guid 字节数一致。 + /// + [Fact] + public void Parse_ResultShouldHave16ByteRepresentation() + { + // Arrange + var guid = Guid.NewGuid(); + var mockParam = new Mock(); + byte[]? capturedBytes = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => capturedBytes = (byte[])v); + _handler.SetValue(mockParam.Object, guid); + + // Act + var restored = _handler.Parse(capturedBytes!); + + // Assert + restored.ToByteArray().Length.ShouldBe(16); + } + + // ═══════════════════════════════════════════════════════════ + // SetValue + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SetValue(null, guid) 应静默忽略,不抛 NullReferenceException。 + /// + [Fact] + public void SetValue_WhenParameterIsNull_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _handler.SetValue(null!, Guid.NewGuid())); + } + + /// + /// 测试目的:SetValue(parameter, Guid.Empty) 应静默忽略,不写入 parameter.Value。 + /// + [Fact] + public void SetValue_WhenGuidIsEmpty_ShouldNotSetParameterValue() + { + // Arrange + var mockParam = new Mock(); + + // Act + _handler.SetValue(mockParam.Object, Guid.Empty); + + // Assert + mockParam.VerifySet(p => p.Value = It.IsAny(), Times.Never); + } + + /// + /// 测试目的:SetValue 对有效 Guid 应将字节序转换后的 16 字节数组写入 parameter.Value。 + /// + [Fact] + public void SetValue_WithValidGuid_ShouldSetParameterValueToByteArray() + { + // Arrange + var guid = Guid.NewGuid(); + var mockParam = new Mock(); + object? assignedValue = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => assignedValue = v); + + // Act + _handler.SetValue(mockParam.Object, guid); + + // Assert + assignedValue.ShouldNotBeNull(); + var bytes = assignedValue as byte[]; + bytes.ShouldNotBeNull(); + bytes!.Length.ShouldBe(16); + } + + /// + /// 测试目的:SetValue 写入的字节数组应与原始 Guid 字节数组不同(因为字节序已被调换)。 + /// + [Fact] + public void SetValue_ShouldProduceByteSwappedArray_DifferentFromOriginal() + { + // Arrange + // 使用一个各 byte 明显不同的 Guid(避免对称情况导致 false positive) + var guid = new Guid("01020304-0506-0708-090a-0b0c0d0e0f10"); + var originalBytes = guid.ToByteArray(); + var mockParam = new Mock(); + byte[]? stored = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => stored = (byte[])v); + + // Act + _handler.SetValue(mockParam.Object, guid); + + // Assert + stored.ShouldNotBeNull(); + // Data1 字节序被颠倒(bytes 0-3) + stored![0].ShouldBe(originalBytes[3]); + stored[1].ShouldBe(originalBytes[2]); + stored[2].ShouldBe(originalBytes[1]); + stored[3].ShouldBe(originalBytes[0]); + // Data2 字节序被颠倒(bytes 4-5) + stored[4].ShouldBe(originalBytes[5]); + stored[5].ShouldBe(originalBytes[4]); + // Data3 字节序被颠倒(bytes 6-7) + stored[6].ShouldBe(originalBytes[7]); + stored[7].ShouldBe(originalBytes[6]); + // 后 8 字节保持不变 + for (int i = 8; i < 16; i++) + stored[i].ShouldBe(originalBytes[i]); + } + + /// + /// 测试目的:多个不同 Guid 的 SetValue→Parse 均应还原为原始值(幂等性批量验证)。 + /// + [Theory] + [InlineData("00000000-0000-0000-0000-000000000001")] + [InlineData("ffffffff-ffff-ffff-ffff-ffffffffffff")] + [InlineData("12345678-1234-5678-1234-567812345678")] + public void Parse_RoundTrip_ShouldAlwaysRestoreOriginalGuid(string guidStr) + { + // Arrange + var original = Guid.Parse(guidStr); + var mockParam = new Mock(); + byte[]? stored = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => stored = (byte[])v); + + // Act + _handler.SetValue(mockParam.Object, original); + var restored = _handler.Parse(stored!); + + // Assert + restored.ShouldBe(original); + } +} diff --git a/framework/tests/Bing.Dapper.MySql.Tests/Handlers/StringTypeHandlerTest.cs b/framework/tests/Bing.Dapper.MySql.Tests/Handlers/StringTypeHandlerTest.cs new file mode 100644 index 00000000..3b7b0d65 --- /dev/null +++ b/framework/tests/Bing.Dapper.MySql.Tests/Handlers/StringTypeHandlerTest.cs @@ -0,0 +1,158 @@ +using System.Data; +using Dapper.Handlers; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Handlers; + +/// +/// 单元测试 +/// +public class StringTypeHandlerTest +{ + private readonly StringTypeHandler _handler = new(); + + // ═══════════════════════════════════════════════════════════ + // Parse + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Parse(null) 应返回 null,不抛 NullReferenceException。 + /// + [Fact] + public void Parse_WhenNull_ShouldReturnNull() + { + // Act + var result = _handler.Parse(null!); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:Parse 对普通字符串对象应返回对应的字符串值。 + /// + [Fact] + public void Parse_WithStringValue_ShouldReturnSameString() + { + // Act + var result = _handler.Parse("hello world"); + + // Assert + result.ShouldBe("hello world"); + } + + /// + /// 测试目的:Parse 对空字符串应返回空字符串。 + /// + [Fact] + public void Parse_WithEmptyString_ShouldReturnEmptyString() + { + // Act + var result = _handler.Parse(string.Empty); + + // Assert + result.ShouldBe(string.Empty); + } + + /// + /// 测试目的:Parse 对非字符串对象(如整数)应调用 ToString() 后返回其字符串表示。 + /// + [Fact] + public void Parse_WithNonStringObject_ShouldCallToString() + { + // Act + var result = _handler.Parse(42); + + // Assert + result.ShouldBe("42"); + } + + /// + /// 测试目的:Parse 对 Guid 对象应返回其字符串表示。 + /// + [Fact] + public void Parse_WithGuid_ShouldReturnGuidString() + { + // Arrange + var guid = new Guid("12345678-1234-5678-1234-567812345678"); + + // Act + var result = _handler.Parse(guid); + + // Assert + result.ShouldBe(guid.ToString()); + } + + // ═══════════════════════════════════════════════════════════ + // SetValue + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SetValue(null, value) 应静默忽略,不抛 NullReferenceException。 + /// + [Fact] + public void SetValue_WhenParameterIsNull_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => _handler.SetValue(null!, "test")); + } + + /// + /// 测试目的:SetValue 对有效 string 值应将其写入 parameter.Value。 + /// + [Fact] + public void SetValue_WithValidString_ShouldSetParameterValue() + { + // Arrange + var mockParam = new Mock(); + object? assignedValue = null; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => assignedValue = v); + + // Act + _handler.SetValue(mockParam.Object, "my-value"); + + // Assert + assignedValue.ShouldBe("my-value"); + } + + /// + /// 测试目的:SetValue 对 null 字符串值应将 null 写入 parameter.Value。 + /// + [Fact] + public void SetValue_WithNullString_ShouldSetParameterValueToNull() + { + // Arrange + var mockParam = new Mock(); + var valueWasSet = false; + mockParam.SetupSet(p => p.Value = null!) + .Callback(() => valueWasSet = true); + + // Act + _handler.SetValue(mockParam.Object, null!); + + // Assert + valueWasSet.ShouldBeTrue(); + } + + /// + /// 测试目的:SetValue 对空字符串应将空字符串写入 parameter.Value。 + /// + [Fact] + public void SetValue_WithEmptyString_ShouldSetParameterValueToEmpty() + { + // Arrange + var mockParam = new Mock(); + object? assignedValue = "not-set"; + mockParam.SetupSet(p => p.Value = It.IsAny()) + .Callback(v => assignedValue = v); + + // Act + _handler.SetValue(mockParam.Object, string.Empty); + + // Assert + assignedValue.ShouldBe(string.Empty); + } +} diff --git a/framework/tests/Bing.Dapper.MySql.Tests/Metadata/MySqlTypeConverterTest.cs b/framework/tests/Bing.Dapper.MySql.Tests/Metadata/MySqlTypeConverterTest.cs new file mode 100644 index 00000000..22c2bfca --- /dev/null +++ b/framework/tests/Bing.Dapper.MySql.Tests/Metadata/MySqlTypeConverterTest.cs @@ -0,0 +1,351 @@ +using System.Data; +using Bing.Data.Metadata; + +namespace Bing.Dapper.Tests.Metadata; + +/// +/// 测试目的:验证 将 MySql 数据类型字符串正确转换为 +/// +public class MySqlTypeConverterTest +{ + private readonly MySqlTypeConverter _converter = new(); + + #region Null / Whitespace + + /// + /// 测试目的:传入 null 时应返回 null,不抛异常。 + /// + [Fact] + public void ToDbType_NullInput_ShouldReturnNull() + { + // Act + var result = _converter.ToDbType(null); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:传入空字符串时应返回 null。 + /// + [Fact] + public void ToDbType_EmptyInput_ShouldReturnNull() + { + // Act + var result = _converter.ToDbType(string.Empty); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:传入纯空白字符串时应返回 null。 + /// + [Fact] + public void ToDbType_WhitespaceInput_ShouldReturnNull() + { + // Act + var result = _converter.ToDbType(" "); + + // Assert + result.ShouldBeNull(); + } + + #endregion + + #region Unknown Type + + /// + /// 测试目的:传入未知类型时应抛出 NotImplementedException,确保未映射类型不被静默忽略。 + /// + [Fact] + public void ToDbType_UnknownType_ShouldThrowNotImplementedException() + { + // Act & Assert + Should.Throw(() => _converter.ToDbType("unknown_type")); + } + + /// + /// 测试目的:大写输入应与小写等效(不区分大小写)。 + /// + [Fact] + public void ToDbType_UpperCaseInput_ShouldBeCaseInsensitive() + { + // Act + var result = _converter.ToDbType("INT"); + + // Assert + result.ShouldBe(DbType.Int32); + } + + #endregion + + #region String Types + + /// + /// 测试目的:char 类型且 length=36 应映射为 Guid(MySQL 中常用 char(36) 存储 UUID)。 + /// + [Fact] + public void ToDbType_CharWithLength36_ShouldReturnGuid() + { + // Act + var result = _converter.ToDbType("char", 36); + + // Assert + result.ShouldBe(DbType.Guid); + } + + /// + /// 测试目的:char 类型且 length != 36 时应映射为 String。 + /// + [Fact] + public void ToDbType_CharWithOtherLength_ShouldReturnString() + { + // Act + var result = _converter.ToDbType("char", 10); + + // Assert + result.ShouldBe(DbType.String); + } + + /// + /// 测试目的:char 类型不传 length 时默认不为 36,应映射为 String。 + /// + [Fact] + public void ToDbType_CharWithNoLength_ShouldReturnString() + { + // Act + var result = _converter.ToDbType("char"); + + // Assert + result.ShouldBe(DbType.String); + } + + /// + /// 测试目的:varchar 应映射为 String。 + /// + [Fact] + public void ToDbType_Varchar_ShouldReturnString() + { + // Act + var result = _converter.ToDbType("varchar"); + + // Assert + result.ShouldBe(DbType.String); + } + + /// + /// 测试目的:text/tinytext/mediumtext/longtext 均应映射为 String。 + /// + [Theory] + [InlineData("text")] + [InlineData("tinytext")] + [InlineData("mediumtext")] + [InlineData("longtext")] + public void ToDbType_TextTypes_ShouldReturnString(string dataType) + { + // Act + var result = _converter.ToDbType(dataType); + + // Assert + result.ShouldBe(DbType.String); + } + + #endregion + + #region Integer Types + + /// + /// 测试目的:tinyint 且 length=1 时映射为 Boolean(MySQL 中 tinyint(1) 表示布尔)。 + /// + [Fact] + public void ToDbType_TinyIntWithLength1_ShouldReturnBoolean() + { + // Act + var result = _converter.ToDbType("tinyint", 1); + + // Assert + result.ShouldBe(DbType.Boolean); + } + + /// + /// 测试目的:tinyint 且 length != 1 时应映射为 Byte。 + /// + [Fact] + public void ToDbType_TinyIntWithOtherLength_ShouldReturnByte() + { + // Act + var result = _converter.ToDbType("tinyint", 4); + + // Assert + result.ShouldBe(DbType.Byte); + } + + /// + /// 测试目的:bit 应映射为 Boolean。 + /// + [Fact] + public void ToDbType_Bit_ShouldReturnBoolean() + { + // Act + var result = _converter.ToDbType("bit"); + + // Assert + result.ShouldBe(DbType.Boolean); + } + + /// + /// 测试目的:smallint 应映射为 Int16。 + /// + [Fact] + public void ToDbType_SmallInt_ShouldReturnInt16() + { + // Act + var result = _converter.ToDbType("smallint"); + + // Assert + result.ShouldBe(DbType.Int16); + } + + /// + /// 测试目的:int/integer/mediumint 均应映射为 Int32。 + /// + [Theory] + [InlineData("int")] + [InlineData("integer")] + [InlineData("mediumint")] + public void ToDbType_Int32Types_ShouldReturnInt32(string dataType) + { + // Act + var result = _converter.ToDbType(dataType); + + // Assert + result.ShouldBe(DbType.Int32); + } + + /// + /// 测试目的:bigint 应映射为 Int64。 + /// + [Fact] + public void ToDbType_BigInt_ShouldReturnInt64() + { + // Act + var result = _converter.ToDbType("bigint"); + + // Assert + result.ShouldBe(DbType.Int64); + } + + #endregion + + #region Floating-Point Types + + /// + /// 测试目的:float 应映射为 Single。 + /// + [Fact] + public void ToDbType_Float_ShouldReturnSingle() + { + // Act + var result = _converter.ToDbType("float"); + + // Assert + result.ShouldBe(DbType.Single); + } + + /// + /// 测试目的:double 应映射为 Double。 + /// + [Fact] + public void ToDbType_Double_ShouldReturnDouble() + { + // Act + var result = _converter.ToDbType("double"); + + // Assert + result.ShouldBe(DbType.Double); + } + + /// + /// 测试目的:decimal/numeric 均应映射为 Decimal。 + /// + [Theory] + [InlineData("decimal")] + [InlineData("numeric")] + public void ToDbType_DecimalTypes_ShouldReturnDecimal(string dataType) + { + // Act + var result = _converter.ToDbType(dataType); + + // Assert + result.ShouldBe(DbType.Decimal); + } + + #endregion + + #region Date / Time Types + + /// + /// 测试目的:date 应映射为 Date。 + /// + [Fact] + public void ToDbType_Date_ShouldReturnDate() + { + // Act + var result = _converter.ToDbType("date"); + + // Assert + result.ShouldBe(DbType.Date); + } + + /// + /// 测试目的:time 应映射为 Time。 + /// + [Fact] + public void ToDbType_Time_ShouldReturnTime() + { + // Act + var result = _converter.ToDbType("time"); + + // Assert + result.ShouldBe(DbType.Time); + } + + /// + /// 测试目的:datetime/timestamp 均应映射为 DateTime。 + /// + [Theory] + [InlineData("datetime")] + [InlineData("timestamp")] + public void ToDbType_DateTimeTypes_ShouldReturnDateTime(string dataType) + { + // Act + var result = _converter.ToDbType(dataType); + + // Assert + result.ShouldBe(DbType.DateTime); + } + + #endregion + + #region Binary Types + + /// + /// 测试目的:blob/tinyblob/mediumblob/longblob 均应映射为 Binary。 + /// + [Theory] + [InlineData("blob")] + [InlineData("tinyblob")] + [InlineData("mediumblob")] + [InlineData("longblob")] + public void ToDbType_BlobTypes_ShouldReturnBinary(string dataType) + { + // Act + var result = _converter.ToDbType(dataType); + + // Assert + result.ShouldBe(DbType.Binary); + } + + #endregion +} diff --git a/framework/tests/Bing.Dapper.Oracle.Tests/Builders/Clauses/JoinClauseTest.cs b/framework/tests/Bing.Dapper.Oracle.Tests/Builders/Clauses/JoinClauseTest.cs new file mode 100644 index 00000000..2a5a93a6 --- /dev/null +++ b/framework/tests/Bing.Dapper.Oracle.Tests/Builders/Clauses/JoinClauseTest.cs @@ -0,0 +1,252 @@ +using Bing.Data.Sql.Builders; +using Bing.Data.Sql.Builders.Clauses; +using Bing.Data.Sql.Builders.Core; +using Bing.Data.Sql.Builders.Params; + +namespace Bing.Dapper.Tests.Builders.Clauses; + +/// +/// 单元测试 +/// 验证 Oracle 方言下的 Join 子句生成行为(双引号标识符,:p_0 参数格式) +/// +public class OracleJoinClauseTest +{ + private readonly IParameterManager _parameterManager; + private readonly OracleJoinClause _clause; + + public OracleJoinClauseTest() + { + _parameterManager = new ParameterManager(OracleDialect.Instance); + var builder = new OracleBuilder(); + _clause = new OracleJoinClause( + builder, + OracleDialect.Instance, + new EntityResolver(), + new EntityAliasRegister(), + _parameterManager, + null); + } + + private string GetSql() => _clause.ToSql(); + + // ── Default ─────────────────────────────────────────────────── + + /// + /// 测试目的:初始状态不设置任何 Join,ToSql 应返回空字符串,不抛异常。 + /// + [Fact] + public void Test_Default() + { + Assert.Empty(GetSql()); + } + + // ── Join ────────────────────────────────────────────────────── + + /// + /// 测试目的:设置一个 Join 表,On 条件使用 Oracle 双引号和 :p_N 参数格式。 + /// + [Fact] + public void Test_Join_Basic() + { + // Arrange + var result = new StringBuilder(); + result.Append("Join \"t\" "); + result.Append("On \"a\".\"id\"=:p_0"); + + // Act + _clause.Join("t"); + _clause.On("a.id", "b"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + /// + /// 测试目的:Join 时指定表别名,输出中应含别名。 + /// + [Fact] + public void Test_Join_WithAlias() + { + // Arrange + var result = new StringBuilder(); + result.Append("Join \"t\" \"x\" "); + result.Append("On \"x\".\"id\"=:p_0"); + + // Act + _clause.Join("t", "x"); + _clause.On("x.id", "b"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + /// + /// 测试目的:On 中未先设置 Join,ToSql 应返回空(条件被忽略)。 + /// + [Fact] + public void Test_On_WithoutJoin_ShouldBeEmpty() + { + // Act + _clause.On("a.id", "b"); + + // Assert + Assert.Empty(GetSql()); + } + + /// + /// 测试目的:多个 On 条件用 And 连接。 + /// + [Fact] + public void Test_Join_MultipleOn() + { + // Arrange + var result = new StringBuilder(); + result.Append("Join \"t\" "); + result.Append("On \"a\".\"id\"=:p_0 And \"a\".\"code\"=:p_1"); + + // Act + _clause.Join("t"); + _clause.On("a.id", "b"); + _clause.On("a.code", "c"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + /// + /// 测试目的:多个 Join 块各自独立,每块用换行分隔。 + /// + [Fact] + public void Test_MultipleJoins() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Join \"t1\" "); + result.AppendLine("On \"a\".\"id\"=:p_0 "); + result.Append("Join \"t2\" "); + result.Append("On \"b\".\"id\"=:p_1"); + + // Act + _clause.Join("t1"); + _clause.On("a.id", "v1"); + _clause.Join("t2"); + _clause.On("b.id", "v2"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + // ── LeftJoin ────────────────────────────────────────────────── + + /// + /// 测试目的:LeftJoin 输出 "Left Join" 关键字,格式与 Oracle 方言一致。 + /// + [Fact] + public void Test_LeftJoin_Basic() + { + // Arrange + var result = new StringBuilder(); + result.Append("Left Join \"t\" "); + result.Append("On \"a\".\"id\"=:p_0"); + + // Act + _clause.LeftJoin("t"); + _clause.On("a.id", "b"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + /// + /// 测试目的:RightJoin 输出 "Right Join" 关键字。 + /// + [Fact] + public void Test_RightJoin_Basic() + { + // Arrange + var result = new StringBuilder(); + result.Append("Right Join \"t\" "); + result.Append("On \"a\".\"id\"=:p_0"); + + // Act + _clause.RightJoin("t"); + _clause.On("a.id", "b"); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + // ── On Operator ────────────────────────────────────────────── + + /// + /// 测试目的:On 条件支持不等于运算符,输出 <> 符号。 + /// + [Fact] + public void Test_On_WithNotEqualOperator() + { + // Arrange + var result = new StringBuilder(); + result.Append("Join \"t\" "); + result.Append("On \"a\".\"id\"<>:p_0"); + + // Act + _clause.Join("t"); + _clause.On("a.id", "b", Operator.NotEqual); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + /// + /// 测试目的:On 条件支持小于运算符,输出 < 符号。 + /// + [Fact] + public void Test_On_WithLessOperator() + { + // Arrange + var result = new StringBuilder(); + result.Append("Join \"t\" "); + result.Append("On \"a\".\"id\"<:p_0"); + + // Act + _clause.Join("t"); + _clause.On("a.id", "b", Operator.Less); + + // Assert + Assert.Equal(result.ToString(), GetSql()); + } + + // ── AppendJoin ──────────────────────────────────────────────── + + /// + /// 测试目的:AppendJoin 追加原始 SQL 片段,输出应原样保留。 + /// + [Fact] + public void Test_AppendJoin_Raw() + { + // Act + _clause.AppendJoin("Join \"raw_table\" On 1=1"); + + // Assert + Assert.Equal("Join \"raw_table\" On 1=1", GetSql()); + } + + // ── Clear ───────────────────────────────────────────────────── + + /// + /// 测试目的:Clear 后再次调用 ToSql 应返回空,原有 Join 被清除。 + /// + [Fact] + public void Test_Clear_ShouldResetJoin() + { + // Arrange + _clause.Join("t"); + _clause.On("a.id", "b"); + + // Act + _clause.Clear(); + + // Assert + Assert.Empty(GetSql()); + } +} diff --git a/framework/tests/Bing.Dapper.Oracle.Tests/Builders/OracleDialectTest.cs b/framework/tests/Bing.Dapper.Oracle.Tests/Builders/OracleDialectTest.cs new file mode 100644 index 00000000..589c5a44 --- /dev/null +++ b/framework/tests/Bing.Dapper.Oracle.Tests/Builders/OracleDialectTest.cs @@ -0,0 +1,275 @@ +using Bing.Data.Sql.Builders; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试 +/// +public class OracleDialectTest +{ + private readonly IDialect _dialect = OracleDialect.Instance; + + // ═══════════════════════════════════════════════════════════ + // 标识符 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:OpeningIdentifier 应为双引号 '"',符合 Oracle 标准。 + /// + [Fact] + public void OpeningIdentifier_ShouldBeDoubleQuote() + { + _dialect.OpeningIdentifier.ShouldBe('"'); + } + + /// + /// 测试目的:ClosingIdentifier 应为双引号 '"',符合 Oracle 标准。 + /// + [Fact] + public void ClosingIdentifier_ShouldBeDoubleQuote() + { + _dialect.ClosingIdentifier.ShouldBe('"'); + } + + // ═══════════════════════════════════════════════════════════ + // SafeName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SafeName 对普通标识符应用双引号包裹,适配 Oracle 风格。 + /// + [Fact] + public void SafeName_PlainName_ShouldWrapWithDoubleQuotes() + { + _dialect.SafeName("TableName").ShouldBe("\"TableName\""); + } + + /// + /// 测试目的:SafeName 对通配符 "*" 应保持不变(不包裹引号)。 + /// + [Fact] + public void SafeName_Wildcard_ShouldReturnAsIs() + { + _dialect.SafeName("*").ShouldBe("*"); + } + + /// + /// 测试目的:SafeName 对空字符串应返回空字符串,不抛异常。 + /// + [Fact] + public void SafeName_EmptyString_ShouldReturnEmpty() + { + _dialect.SafeName(string.Empty).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对已有方括号包裹的名称,应剥去方括号后再用双引号包裹。 + /// + [Fact] + public void SafeName_AlreadyBracketWrapped_ShouldRewrap() + { + _dialect.SafeName("[ColName]").ShouldBe("\"ColName\""); + } + + /// + /// 测试目的:SafeName 对已有反引号包裹的名称,应剥去反引号后再用双引号包裹。 + /// + [Fact] + public void SafeName_AlreadyBacktickWrapped_ShouldRewrap() + { + _dialect.SafeName("`ColName`").ShouldBe("\"ColName\""); + } + + // ═══════════════════════════════════════════════════════════ + // GetPrefix + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetPrefix() 应返回 ":",Oracle 绑定变量用冒号前缀。 + /// + [Fact] + public void GetPrefix_ShouldReturnColon() + { + _dialect.GetPrefix().ShouldBe(":"); + } + + // ═══════════════════════════════════════════════════════════ + // SupportSelectAs + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SupportSelectAs() 应返回 false,Oracle Select 子句不支持 AS 关键字(直接空格)。 + /// + [Fact] + public void SupportSelectAs_ShouldReturnFalse() + { + _dialect.SupportSelectAs().ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // GenerateName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GenerateName(0) 应返回 ":p_0",使用冒号前缀。 + /// + [Fact] + public void GenerateName_Index0_ShouldReturnColonP0() + { + _dialect.GenerateName(0).ShouldBe(":p_0"); + } + + /// + /// 测试目的:GenerateName(5) 应返回 ":p_5"。 + /// + [Fact] + public void GenerateName_Index5_ShouldReturnColonP5() + { + _dialect.GenerateName(5).ShouldBe(":p_5"); + } + + // ═══════════════════════════════════════════════════════════ + // GetParamName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetParamName 对以 ":" 开头的名称,应剥去前缀冒号后返回。 + /// + [Fact] + public void GetParamName_WithColonPrefix_ShouldStripColon() + { + _dialect.GetParamName(":p_0").ShouldBe("p_0"); + } + + /// + /// 测试目的:GetParamName 对不含冒号前缀的名称,应直接返回原名。 + /// + [Fact] + public void GetParamName_WithoutColonPrefix_ShouldReturnAsIs() + { + _dialect.GetParamName("p_0").ShouldBe("p_0"); + } + + // ═══════════════════════════════════════════════════════════ + // GetParamValue + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetParamValue(null) 应返回空字符串,而不是 null,避免绑定时报错。 + /// + [Fact] + public void GetParamValue_WhenNull_ShouldReturnEmptyString() + { + _dialect.GetParamValue(null).ShouldBe(string.Empty); + } + + /// + /// 测试目的:GetParamValue(true) 应返回整数 1(Oracle 无 bool 类型,用 1/0 替代)。 + /// + [Fact] + public void GetParamValue_BoolTrue_ShouldReturn1() + { + _dialect.GetParamValue(true).ShouldBe(1); + } + + /// + /// 测试目的:GetParamValue(false) 应返回整数 0。 + /// + [Fact] + public void GetParamValue_BoolFalse_ShouldReturn0() + { + _dialect.GetParamValue(false).ShouldBe(0); + } + + /// + /// 测试目的:GetParamValue(int16 值) 应转换为字符串(Oracle ODP 参数需显式字符串)。 + /// + [Fact] + public void GetParamValue_Int16_ShouldReturnString() + { + _dialect.GetParamValue((short)42).ShouldBe("42"); + } + + /// + /// 测试目的:GetParamValue(int32 值) 应转换为字符串。 + /// + [Fact] + public void GetParamValue_Int32_ShouldReturnString() + { + _dialect.GetParamValue(100).ShouldBe("100"); + } + + /// + /// 测试目的:GetParamValue(int64 值) 应转换为字符串。 + /// + [Fact] + public void GetParamValue_Int64_ShouldReturnString() + { + _dialect.GetParamValue(9999999999L).ShouldBe("9999999999"); + } + + /// + /// 测试目的:GetParamValue(float 值) 应转换为字符串。 + /// + [Fact] + public void GetParamValue_Single_ShouldReturnString() + { + var result = _dialect.GetParamValue(3.14f); + result.ShouldBeOfType(); + } + + /// + /// 测试目的:GetParamValue(double 值) 应转换为字符串。 + /// + [Fact] + public void GetParamValue_Double_ShouldReturnString() + { + var result = _dialect.GetParamValue(3.14d); + result.ShouldBeOfType(); + } + + /// + /// 测试目的:GetParamValue(decimal 值) 应转换为字符串。 + /// + [Fact] + public void GetParamValue_Decimal_ShouldReturnString() + { + _dialect.GetParamValue(9.99m).ShouldBe("9.99"); + } + + /// + /// 测试目的:GetParamValue(string 值) 应转换为字符串($"{value}" 格式)。 + /// + [Fact] + public void GetParamValue_String_ShouldReturnSameString() + { + _dialect.GetParamValue("hello").ShouldBe("hello"); + } + + /// + /// 测试目的:GetParamValue(Guid 值) 应转换为字符串(走 default 分支)。 + /// + [Fact] + public void GetParamValue_Guid_ShouldReturnGuidString() + { + var guid = Guid.NewGuid(); + _dialect.GetParamValue(guid).ShouldBe(guid.ToString()); + } + + // ═══════════════════════════════════════════════════════════ + // Instance 唯一性 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:每次访问 OracleDialect.Instance 应返回新实例(非单例,但均为 OracleDialect 类型)。 + /// + [Fact] + public void Instance_ShouldBeOracleDialectType() + { + var instance = OracleDialect.Instance; + instance.ShouldNotBeNull(); + instance.ShouldBeOfType(); + } +} diff --git a/framework/tests/Bing.Dapper.Oracle.Tests/Metadata/OracleTypeConverterTest.cs b/framework/tests/Bing.Dapper.Oracle.Tests/Metadata/OracleTypeConverterTest.cs new file mode 100644 index 00000000..46ff965b --- /dev/null +++ b/framework/tests/Bing.Dapper.Oracle.Tests/Metadata/OracleTypeConverterTest.cs @@ -0,0 +1,64 @@ +using Bing.Data.Metadata; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Metadata; + +/// +/// 单元测试。 +/// 当前实现中除空输入返回 null 外,所有类型均抛出 NotImplementedException(待实现)。 +/// 测试目的是锁定现有边界行为,防止意外改动破坏空输入语义。 +/// +public class OracleTypeConverterTest +{ + private readonly OracleTypeConverter _converter = new(); + + // ═══════════════════════════════════════════════════════════ + // 边界:空输入 → null(已实现) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:dataType 为 null 时应返回 null,不抛异常。 + /// + [Fact] + public void ToDbType_Null_ShouldReturnNull() + { + _converter.ToDbType(null).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Empty_ShouldReturnNull() + { + _converter.ToDbType(string.Empty).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空白字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Whitespace_ShouldReturnNull() + { + _converter.ToDbType(" ").ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // 未实现类型 → NotImplementedException(占位) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入任意非空类型时应抛出 NotImplementedException(当前 Oracle 实现尚未完成)。 + /// + [Theory] + [InlineData("varchar2")] + [InlineData("number")] + [InlineData("date")] + [InlineData("clob")] + [InlineData("blob")] + public void ToDbType_AnyNonEmptyType_ShouldThrowNotImplementedException(string dataType) + { + Should.Throw(() => _converter.ToDbType(dataType)); + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Bing.Dapper.PostgreSql.Tests.Integration.csproj b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Bing.Dapper.PostgreSql.Tests.Integration.csproj index 4f6e176a..78472c9d 100644 --- a/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Bing.Dapper.PostgreSql.Tests.Integration.csproj +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Bing.Dapper.PostgreSql.Tests.Integration.csproj @@ -7,6 +7,17 @@ + + + + + + Always + + + Always + appsettings.json + diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/SqlQuery/PostgreSqlQueryTest.cs b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/SqlQuery/PostgreSqlQueryTest.cs new file mode 100644 index 00000000..993706cb --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/SqlQuery/PostgreSqlQueryTest.cs @@ -0,0 +1,44 @@ +using Bing.Data.Sql; +using Bing.Test.Shared; +using Xunit.Abstractions; + +namespace Bing.Dapper.Tests.SqlQuery; + +/// +/// PostgreSQL SQL 查询集成测试骨架。 +/// 所有测试方法使用 标注, +/// 在未设置 RUN_INTEGRATION_TESTS=true 时自动跳过。 +/// +/// 运行前提: +/// - 设置环境变量 RUN_INTEGRATION_TESTS=true +/// - 设置环境变量 ConnectionStrings__DefaultConnection(或创建 appsettings.Development.json) +/// - PostgreSQL 实例可访问,数据库和账号已准备好 +/// +public class PostgreSqlQueryTest +{ + private readonly ITestOutputHelper _output; + private readonly ISqlQuery _sqlQuery; + + /// + /// 初始化测试 + /// + public PostgreSqlQueryTest(ITestOutputHelper output, ISqlQuery sqlQuery) + { + _output = output; + _sqlQuery = sqlQuery; + } + + /// + /// 测试目的:验证 PostgreSQL 连接可用,SELECT 1 应返回 1。 + /// + [IntegrationFact] + public async Task GetValue_SelectOne_ShouldReturnOne() + { + // Arrange & Act + var result = await _sqlQuery.AppendLine("SELECT 1").ToIntAsync(); + + // Assert + _output.WriteLine($"SELECT 1 = {result}"); + Assert.Equal(1, result); + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Startup.cs b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Startup.cs new file mode 100644 index 00000000..46ac12ca --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/Startup.cs @@ -0,0 +1,52 @@ +using AspectCore.Extensions.Hosting; +using Bing.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.DependencyInjection; +using Xunit.DependencyInjection.Logging; + +namespace Bing.Dapper.Tests; + +/// +/// 集成测试启动配置。 +/// 连接字符串通过以下任一方式提供(优先级从高到低): +/// 1. 环境变量:ConnectionStrings__DefaultConnection +/// 2. appsettings.Development.json(本地开发,不提交到 Git) +/// 3. appsettings.json(默认为空,确保无硬编码凭据) +/// 前置条件: +/// - PostgreSQL 实例可访问 +/// - 数据库账号有 CREATE / DROP / DML 权限 +/// CI 启用方式:设置环境变量 RUN_INTEGRATION_TESTS=true 并提供连接字符串 +/// +public class Startup +{ + /// + /// 配置主机 + /// + public void ConfigureHost(IHostBuilder hostBuilder) + { + hostBuilder + .ConfigureDefaults(null) + .UseServiceContext() + .ConfigureAppConfiguration((_, builder) => + { + builder.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false); + builder.AddEnvironmentVariables(); + }); + } + + /// + /// 配置服务 + /// + public void ConfigureServices(IServiceCollection services, HostBuilderContext context) + { + var connectionString = context.Configuration.GetConnectionString("DefaultConnection"); + services.AddPostgreSqlQuery(connectionString ?? string.Empty); + services.AddPostgreSqlExecutor(connectionString ?? string.Empty); + services.AddLogging(logBuilder => logBuilder.AddXunitOutput()); + services.EnableAop(); + services.AddBing(); + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/appsettings.json b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/appsettings.json new file mode 100644 index 00000000..38dbe9c9 --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests.Integration/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft": "Error" + } + }, + "ConnectionStrings": { + "DefaultConnection": "" + }, + "IntegrationTest": { + "Note": "请通过环境变量 'ConnectionStrings__DefaultConnection' 或 appsettings.Development.json 提供连接字符串。不要在此文件中硬编码数据库凭据。", + "ExampleConnectionString": "Host=localhost;Database=bing_dapper_pgsql_test;Username=postgres;Password=;" + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlDialectTest.cs b/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlDialectTest.cs new file mode 100644 index 00000000..c92a51ab --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlDialectTest.cs @@ -0,0 +1,166 @@ +using Bing.Data.Sql.Builders; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试 +/// +public class PostgreSqlDialectTest +{ + private readonly IDialect _dialect = PostgreSqlDialect.Instance; + + // ═══════════════════════════════════════════════════════════ + // 标识符 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:OpeningIdentifier 应为双引号 '"',符合 PostgreSQL ANSI SQL 规范。 + /// + [Fact] + public void OpeningIdentifier_ShouldBeDoubleQuote() + { + _dialect.OpeningIdentifier.ShouldBe('"'); + } + + /// + /// 测试目的:ClosingIdentifier 应为双引号 '"'。 + /// + [Fact] + public void ClosingIdentifier_ShouldBeDoubleQuote() + { + _dialect.ClosingIdentifier.ShouldBe('"'); + } + + // ═══════════════════════════════════════════════════════════ + // SafeName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SafeName 对普通表名应用双引号包裹(区分大小写语义)。 + /// + [Fact] + public void SafeName_PlainName_ShouldWrapWithDoubleQuotes() + { + _dialect.SafeName("orders").ShouldBe("\"orders\""); + } + + /// + /// 测试目的:SafeName 对通配符 "*" 应保持不变。 + /// + [Fact] + public void SafeName_Wildcard_ShouldReturnAsIs() + { + _dialect.SafeName("*").ShouldBe("*"); + } + + /// + /// 测试目的:SafeName 对空字符串应返回空字符串,不抛异常。 + /// + [Fact] + public void SafeName_EmptyString_ShouldReturnEmpty() + { + _dialect.SafeName(string.Empty).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对已有方括号包裹的名称,应剥去后改用双引号包裹。 + /// + [Fact] + public void SafeName_AlreadyBracketWrapped_ShouldRewrap() + { + _dialect.SafeName("[user_id]").ShouldBe("\"user_id\""); + } + + /// + /// 测试目的:SafeName 对已有反引号包裹的名称,应剥去后改用双引号包裹。 + /// + [Fact] + public void SafeName_BacktickWrapped_ShouldRewrap() + { + _dialect.SafeName("`created_at`").ShouldBe("\"created_at\""); + } + + // ═══════════════════════════════════════════════════════════ + // GetPrefix(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetPrefix() 应返回基类默认值 "@"(PostgreSqlDialect 未覆盖该方法)。 + /// + [Fact] + public void GetPrefix_ShouldReturnAtSign() + { + _dialect.GetPrefix().ShouldBe("@"); + } + + // ═══════════════════════════════════════════════════════════ + // SupportSelectAs(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SupportSelectAs() 应返回 true(PostgreSqlDialect 未覆盖,继承基类默认值)。 + /// + [Fact] + public void SupportSelectAs_ShouldReturnTrue() + { + _dialect.SupportSelectAs().ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // GetParamValue(继承自 DialectBase) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetParamValue 对 bool 值应保持原始类型(PostgreSQL 原生支持 boolean 类型)。 + /// + [Fact] + public void GetParamValue_Bool_ShouldReturnOriginalValue() + { + _dialect.GetParamValue(true).ShouldBe(true); + _dialect.GetParamValue(false).ShouldBe(false); + } + + /// + /// 测试目的:GetParamValue 对整数值应保持原始类型(PostgreSQL 原生支持整数)。 + /// + [Fact] + public void GetParamValue_Int_ShouldReturnOriginalValue() + { + _dialect.GetParamValue(42).ShouldBe(42); + } + + /// + /// 测试目的:GetParamValue 对 null 应返回 null(基类默认行为,与 Oracle 不同)。 + /// + [Fact] + public void GetParamValue_Null_ShouldReturnNull() + { + _dialect.GetParamValue(null).ShouldBeNull(); + } + + /// + /// 测试目的:GetParamValue 对字符串应返回相同字符串(原样透传)。 + /// + [Fact] + public void GetParamValue_String_ShouldReturnSameString() + { + _dialect.GetParamValue("hello").ShouldBe("hello"); + } + + // ═══════════════════════════════════════════════════════════ + // Instance + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:PostgreSqlDialect.Instance 应为 PostgreSqlDialect 类型,不为 null。 + /// + [Fact] + public void Instance_ShouldBePostgreSqlDialectType() + { + var instance = PostgreSqlDialect.Instance; + instance.ShouldNotBeNull(); + instance.ShouldBeOfType(); + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlParamLiteralsResolverTest.cs b/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlParamLiteralsResolverTest.cs new file mode 100644 index 00000000..6dd37e02 --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests/Builders/PostgreSqlParamLiteralsResolverTest.cs @@ -0,0 +1,233 @@ +using Bing.Data.Sql.Builders; +using Bing.Data.Sql.Builders.Params; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试。 +/// 验证各类型参数的字面值输出:null、bool、整型/浮点型(无引号)、默认类型(有单引号)。 +/// 不依赖数据库连接,纯逻辑单元测试。 +/// +public class PostgreSqlParamLiteralsResolverTest +{ + private readonly IParamLiteralsResolver _resolver = PostgreSqlParamLiteralsResolver.Instance; + + // ─── null ──────────────────────────────────────────────────────────────── + + /// + /// 测试目的:null 值应返回 PostgreSQL 空字符串字面值 "''"。 + /// + [Fact] + public void GetParamLiterals_NullValue_ShouldReturnEmptyStringLiteral() + { + // Act & Assert + _resolver.GetParamLiterals(null).ShouldBe("''"); + } + + // ─── boolean ───────────────────────────────────────────────────────────── + + /// + /// 测试目的:bool true 应返回 PostgreSQL 布尔字面值 "true"(无引号)。 + /// + [Fact] + public void GetParamLiterals_BoolTrue_ShouldReturnLowercaseTrue() + { + // Act & Assert + _resolver.GetParamLiterals(true).ShouldBe("true"); + } + + /// + /// 测试目的:bool false 应返回 PostgreSQL 布尔字面值 "false"(无引号)。 + /// + [Fact] + public void GetParamLiterals_BoolFalse_ShouldReturnLowercaseFalse() + { + // Act & Assert + _resolver.GetParamLiterals(false).ShouldBe("false"); + } + + // ─── 整数类型 ───────────────────────────────────────────────────────────── + + /// + /// 测试目的:int32 应直接返回数字字符串,不加单引号。 + /// + [Fact] + public void GetParamLiterals_Int32_ShouldReturnRawNumber() + { + // Act & Assert + _resolver.GetParamLiterals(42).ShouldBe("42"); + } + + /// + /// 测试目的:int16 (short) 应直接返回数字字符串,不加单引号。 + /// + [Fact] + public void GetParamLiterals_Int16_ShouldReturnRawNumber() + { + // Arrange + short value = 100; + + // Act & Assert + _resolver.GetParamLiterals(value).ShouldBe("100"); + } + + /// + /// 测试目的:int64 (long) 应直接返回数字字符串,不加单引号。 + /// + [Fact] + public void GetParamLiterals_Int64_ShouldReturnRawNumber() + { + // Act & Assert + _resolver.GetParamLiterals(9999L).ShouldBe("9999"); + } + + /// + /// 测试目的:负整数也应直接输出(无引号)。 + /// + [Fact] + public void GetParamLiterals_NegativeInt_ShouldReturnRawNumber() + { + // Act & Assert + _resolver.GetParamLiterals(-1).ShouldBe("-1"); + } + + // ─── 浮点/精度类型(验证无引号,不验证具体格式以避免文化依赖)───────────── + + /// + /// 测试目的:float (Single) 应不带引号输出,属于数值分支。 + /// + [Fact] + public void GetParamLiterals_Float_ShouldNotWrapWithSingleQuotes() + { + // Act + var result = _resolver.GetParamLiterals(3.14f); + + // Assert:数值类型不应有单引号包围 + result.ShouldNotStartWith("'"); + result.ShouldNotEndWith("'"); + } + + /// + /// 测试目的:double 应不带引号输出,属于数值分支。 + /// + [Fact] + public void GetParamLiterals_Double_ShouldNotWrapWithSingleQuotes() + { + // Act + var result = _resolver.GetParamLiterals(1.5d); + + // Assert + result.ShouldNotStartWith("'"); + result.ShouldNotEndWith("'"); + } + + /// + /// 测试目的:decimal 应不带引号输出,属于数值分支。 + /// + [Fact] + public void GetParamLiterals_Decimal_ShouldNotWrapWithSingleQuotes() + { + // Act + var result = _resolver.GetParamLiterals(9.99m); + + // Assert + result.ShouldNotStartWith("'"); + result.ShouldNotEndWith("'"); + } + + // ─── 默认分支(字符串/Guid/DateTime 等) ───────────────────────────────── + + /// + /// 测试目的:字符串类型属于 default 分支,应被单引号包围。 + /// + [Fact] + public void GetParamLiterals_String_ShouldWrapWithSingleQuotes() + { + // Act + var result = _resolver.GetParamLiterals("hello"); + + // Assert + result.ShouldBe("'hello'"); + } + + /// + /// 测试目的:空字符串应被单引号包围("''"),区别于 null 的 "''"(但结果相同)。 + /// + [Fact] + public void GetParamLiterals_EmptyString_ShouldWrapWithSingleQuotes() + { + // Act + var result = _resolver.GetParamLiterals(string.Empty); + + // Assert + result.ShouldBe("''"); + } + + /// + /// 测试目的:Guid 属于 default 分支,应被单引号包围。 + /// + [Fact] + public void GetParamLiterals_Guid_ShouldWrapWithSingleQuotes() + { + // Arrange + var guid = new Guid("12345678-1234-1234-1234-123456789abc"); + + // Act + var result = _resolver.GetParamLiterals(guid); + + // Assert + result.ShouldStartWith("'"); + result.ShouldEndWith("'"); + result.ShouldContain("12345678"); + } + + /// + /// 测试目的:DateTime 属于 default 分支,应被单引号包围。 + /// + [Fact] + public void GetParamLiterals_DateTime_ShouldWrapWithSingleQuotes() + { + // Arrange + var dt = new DateTime(2024, 6, 15); + + // Act + var result = _resolver.GetParamLiterals(dt); + + // Assert + result.ShouldStartWith("'"); + result.ShouldEndWith("'"); + } + + // ─── Instance 访问 ──────────────────────────────────────────────────────── + + /// + /// 测试目的:Instance 属性应返回 IParamLiteralsResolver 实现,功能可用。 + /// + [Fact] + public void Instance_ShouldReturnFunctionalResolver() + { + // Arrange & Act + var inst = PostgreSqlParamLiteralsResolver.Instance; + + // Assert + inst.ShouldNotBeNull(); + // 验证基本功能可用(null→空字符串字面值) + inst.GetParamLiterals(null).ShouldBe("''"); + } + + /// + /// 测试目的:Instance 每次返回新实例(非单例设计),两次访问引用不等。 + /// + [Fact] + public void Instance_CalledTwice_ShouldReturnDifferentInstances() + { + // Arrange & Act + var first = PostgreSqlParamLiteralsResolver.Instance; + var second = PostgreSqlParamLiteralsResolver.Instance; + + // Assert:构造函数封闭,但 Instance 每次 new 一个 + ReferenceEquals(first, second).ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Dapper.PostgreSql.Tests/Metadata/PostgreSqlTypeConverterTest.cs b/framework/tests/Bing.Dapper.PostgreSql.Tests/Metadata/PostgreSqlTypeConverterTest.cs new file mode 100644 index 00000000..41e1fd67 --- /dev/null +++ b/framework/tests/Bing.Dapper.PostgreSql.Tests/Metadata/PostgreSqlTypeConverterTest.cs @@ -0,0 +1,238 @@ +using System.Data; +using Bing.Data.Metadata; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Metadata; + +/// +/// 单元测试。 +/// 验证每个 PostgreSQL 数据类型字符串都能正确映射为 DbType,以及边界/负例行为。 +/// +public class PostgreSqlTypeConverterTest +{ + private readonly PostgreSqlTypeConverter _converter = new(); + + // ═══════════════════════════════════════════════════════════ + // 边界:空输入 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:dataType 为 null 时应返回 null,不抛异常。 + /// + [Fact] + public void ToDbType_Null_ShouldReturnNull() + { + _converter.ToDbType(null).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Empty_ShouldReturnNull() + { + _converter.ToDbType(string.Empty).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空白字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Whitespace_ShouldReturnNull() + { + _converter.ToDbType(" ").ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // 负例:未知类型抛出 NotImplementedException + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:未知类型应抛出 NotImplementedException,便于发现未覆盖的类型映射。 + /// + [Fact] + public void ToDbType_UnknownType_ShouldThrowNotImplementedException() + { + Should.Throw(() => _converter.ToDbType("pg_unknown_type")); + } + + // ═══════════════════════════════════════════════════════════ + // 大小写不敏感 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:类型名称大小写不敏感,"INT4" 应与 "int4" 映射相同。 + /// + [Fact] + public void ToDbType_CaseInsensitive_ShouldMatch() + { + _converter.ToDbType("INT4").ShouldBe(DbType.Int32); + _converter.ToDbType("Int4").ShouldBe(DbType.Int32); + } + + // ═══════════════════════════════════════════════════════════ + // GUID 类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:uuid → DbType.Guid(PostgreSQL UUID 类型)。 + /// + [Fact] + public void ToDbType_Uuid_ShouldBeGuid() + { + _converter.ToDbType("uuid").ShouldBe(DbType.Guid); + } + + // ═══════════════════════════════════════════════════════════ + // 字符串类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:varchar/text/json/jsonb/xml → DbType.String。 + /// + [Theory] + [InlineData("varchar")] + [InlineData("text")] + [InlineData("json")] + [InlineData("jsonb")] + [InlineData("xml")] + public void ToDbType_StringTypes_ShouldBeString(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.String); + } + + // ═══════════════════════════════════════════════════════════ + // 布尔类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:bool → DbType.Boolean。 + /// + [Fact] + public void ToDbType_Bool_ShouldBeBoolean() + { + _converter.ToDbType("bool").ShouldBe(DbType.Boolean); + } + + // ═══════════════════════════════════════════════════════════ + // 整数类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:char → DbType.Byte(PostgreSQL 中 char 映射为单字节)。 + /// + [Fact] + public void ToDbType_Char_ShouldBeByte() + { + _converter.ToDbType("char").ShouldBe(DbType.Byte); + } + + /// + /// 测试目的:int2 → DbType.Int16(2 字节整数)。 + /// + [Fact] + public void ToDbType_Int2_ShouldBeInt16() + { + _converter.ToDbType("int2").ShouldBe(DbType.Int16); + } + + /// + /// 测试目的:int4 → DbType.Int32(4 字节整数)。 + /// + [Fact] + public void ToDbType_Int4_ShouldBeInt32() + { + _converter.ToDbType("int4").ShouldBe(DbType.Int32); + } + + /// + /// 测试目的:int8 → DbType.Int64(8 字节整数)。 + /// + [Fact] + public void ToDbType_Int8_ShouldBeInt64() + { + _converter.ToDbType("int8").ShouldBe(DbType.Int64); + } + + // ═══════════════════════════════════════════════════════════ + // 浮点/精确数值类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:float4 → DbType.Single(4 字节浮点)。 + /// + [Fact] + public void ToDbType_Float4_ShouldBeSingle() + { + _converter.ToDbType("float4").ShouldBe(DbType.Single); + } + + /// + /// 测试目的:float8 → DbType.Double(8 字节浮点)。 + /// + [Fact] + public void ToDbType_Float8_ShouldBeDouble() + { + _converter.ToDbType("float8").ShouldBe(DbType.Double); + } + + /// + /// 测试目的:numeric/decimal → DbType.Decimal(精确小数)。 + /// + [Theory] + [InlineData("numeric")] + [InlineData("decimal")] + public void ToDbType_DecimalTypes_ShouldBeDecimal(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.Decimal); + } + + // ═══════════════════════════════════════════════════════════ + // 日期时间类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:date → DbType.Date(仅日期,不含时间)。 + /// + [Fact] + public void ToDbType_Date_ShouldBeDate() + { + _converter.ToDbType("date").ShouldBe(DbType.Date); + } + + /// + /// 测试目的:time/timetz → DbType.Time(含时区与不含时区均映射为 Time)。 + /// + [Theory] + [InlineData("time")] + [InlineData("timetz")] + public void ToDbType_TimeTypes_ShouldBeTime(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.Time); + } + + /// + /// 测试目的:timestamp/timestamptz → DbType.DateTime(含时区与不含时区均映射为 DateTime)。 + /// + [Theory] + [InlineData("timestamp")] + [InlineData("timestamptz")] + public void ToDbType_TimestampTypes_ShouldBeDateTime(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.DateTime); + } + + // ═══════════════════════════════════════════════════════════ + // 二进制类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:bytea → DbType.Binary(PostgreSQL 二进制数据类型)。 + /// + [Fact] + public void ToDbType_Bytea_ShouldBeBinary() + { + _converter.ToDbType("bytea").ShouldBe(DbType.Binary); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Bing.Dapper.SqlServer.Tests.Integration.csproj b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Bing.Dapper.SqlServer.Tests.Integration.csproj index 9866a5da..a30c10cb 100644 --- a/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Bing.Dapper.SqlServer.Tests.Integration.csproj +++ b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Bing.Dapper.SqlServer.Tests.Integration.csproj @@ -7,6 +7,17 @@ + + + + + + Always + + + Always + appsettings.json + diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/SqlQuery/SqlServerQueryTest.cs b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/SqlQuery/SqlServerQueryTest.cs new file mode 100644 index 00000000..d77f395e --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/SqlQuery/SqlServerQueryTest.cs @@ -0,0 +1,44 @@ +using Bing.Data.Sql; +using Bing.Test.Shared; +using Xunit.Abstractions; + +namespace Bing.Dapper.Tests.SqlQuery; + +/// +/// SqlServer SQL 查询集成测试骨架。 +/// 所有测试方法使用 标注, +/// 在未设置 RUN_INTEGRATION_TESTS=true 时自动跳过。 +/// +/// 运行前提: +/// - 设置环境变量 RUN_INTEGRATION_TESTS=true +/// - 设置环境变量 ConnectionStrings__DefaultConnection(或创建 appsettings.Development.json) +/// - SQL Server 实例可访问,数据库和账号已准备好 +/// +public class SqlServerQueryTest +{ + private readonly ITestOutputHelper _output; + private readonly ISqlQuery _sqlQuery; + + /// + /// 初始化测试 + /// + public SqlServerQueryTest(ITestOutputHelper output, ISqlQuery sqlQuery) + { + _output = output; + _sqlQuery = sqlQuery; + } + + /// + /// 测试目的:验证 SQL Server 连接可用,SELECT 1 应返回 1。 + /// + [IntegrationFact] + public async Task GetValue_SelectOne_ShouldReturnOne() + { + // Arrange & Act + var result = await _sqlQuery.AppendLine("SELECT 1").ToIntAsync(); + + // Assert + _output.WriteLine($"SELECT 1 = {result}"); + Assert.Equal(1, result); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Startup.cs b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Startup.cs new file mode 100644 index 00000000..6d751d77 --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/Startup.cs @@ -0,0 +1,57 @@ +using AspectCore.Extensions.Hosting; +using Bing.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.DependencyInjection; +using Xunit.DependencyInjection.Logging; + +namespace Bing.Dapper.Tests; + +/// +/// 集成测试启动配置。 +/// 连接字符串通过以下任一方式提供(优先级从高到低): +/// 1. 环境变量:ConnectionStrings__DefaultConnection +/// 2. appsettings.Development.json(本地开发,不提交到 Git) +/// 3. appsettings.json(默认为空,确保无硬编码凭据) +/// 前置条件: +/// - SQL Server 实例可访问 +/// - 数据库账号有 CREATE / DROP / DML 权限 +/// CI 启用方式:设置环境变量 RUN_INTEGRATION_TESTS=true 并提供连接字符串 +/// +public class Startup +{ + /// + /// 配置主机 + /// + public void ConfigureHost(IHostBuilder hostBuilder) + { + hostBuilder + .ConfigureDefaults(null) + .UseServiceContext() + .ConfigureAppConfiguration((_, builder) => + { + // 允许通过 appsettings.Development.json 覆盖连接字符串(本地开发使用) + builder.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false); + // 允许通过环境变量覆盖(CI/CD 使用) + builder.AddEnvironmentVariables(); + }); + } + + /// + /// 配置服务 + /// + public void ConfigureServices(IServiceCollection services, HostBuilderContext context) + { + var connectionString = context.Configuration.GetConnectionString("DefaultConnection"); + + // 如果连接字符串为空,SqlQuery/SqlExecutor 将无法正常工作, + // 但 Startup 本身不应抛异常,测试通过 [IntegrationFact] 跳过机制保护。 + services.AddSqlServerSqlQuery(connectionString ?? string.Empty); + services.AddSqlServerSqlExecutor(connectionString ?? string.Empty); + services.AddLogging(logBuilder => logBuilder.AddXunitOutput()); + services.EnableAop(); + services.AddBing(); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/appsettings.json b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/appsettings.json new file mode 100644 index 00000000..d56b0b12 --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests.Integration/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft": "Error" + } + }, + "ConnectionStrings": { + "DefaultConnection": "" + }, + "IntegrationTest": { + "Note": "请通过环境变量 'ConnectionStrings__DefaultConnection' 或 appsettings.Development.json 提供连接字符串。不要在此文件中硬编码数据库凭据。", + "ExampleConnectionString": "Server=localhost;Database=bing_dapper_sqlserver_test;User Id=sa;Password=;TrustServerCertificate=true;" + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests/Bing.Dapper.SqlServer.Tests.csproj b/framework/tests/Bing.Dapper.SqlServer.Tests/Bing.Dapper.SqlServer.Tests.csproj new file mode 100644 index 00000000..b747198f --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests/Bing.Dapper.SqlServer.Tests.csproj @@ -0,0 +1,12 @@ + + + + + Bing.Dapper.Tests + false + + + + + + diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerBuilderTest.cs b/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerBuilderTest.cs new file mode 100644 index 00000000..d82a80cf --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerBuilderTest.cs @@ -0,0 +1,302 @@ +using System.Text; +using Bing.Data.Sql.Builders; +using Bing.Data.Sql; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试 +/// 验证 SQL Server 方言下 SQL 生成行为:方括号标识符、@p_N 参数、分页语法 +/// +public class SqlServerBuilderTest +{ + private SqlServerBuilder NewBuilder() => new SqlServerBuilder(); + + // ── Select + From + Where ──────────────────────────────────── + + /// + /// 测试目的:基础 Select/From/Where 生成 SQL Server 格式(方括号 + @p_N)。 + /// + [Fact] + public void Test_SelectFromWhere_BasicFormat() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select [Name] "); + result.AppendLine("From [User] "); + result.Append("Where [Age]=@p_0"); + + var builder = NewBuilder(); + + // Act + builder.Select("Name") + .From("User") + .Where("Age", 25); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + Assert.Equal(25, builder.GetParam("p_0")); + } + + /// + /// 测试目的:Select * From 无条件时生成干净 SQL,不含 Where 子句。 + /// + [Fact] + public void Test_SelectAll_NoWhere() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.Append("From [Product]"); + + var builder = NewBuilder(); + + // Act + builder.Select("*").From("Product"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + /// + /// 测试目的:Select 多列时,各列均应被方括号包裹。 + /// + [Fact] + public void Test_Select_MultipleColumns() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select [Id],[Name],[Email] "); + result.Append("From [User]"); + + var builder = NewBuilder(); + + // Act + builder.Select("Id,Name,Email").From("User"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + /// + /// 测试目的:多个 Where 条件应以 And 连接,参数按顺序编号。 + /// + [Fact] + public void Test_Where_MultipleConditions() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [User] "); + result.Append("Where [Status]=@p_0 And [Age]=@p_1"); + + var builder = NewBuilder(); + + // Act + builder.Select("*") + .From("User") + .Where("Status", "active") + .Where("Age", 30); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + Assert.Equal("active", builder.GetParam("p_0")); + Assert.Equal(30, builder.GetParam("p_1")); + } + + // ── Join ───────────────────────────────────────────────────── + + /// + /// 测试目的:Join 子句使用方括号标识符,ON 条件参数格式正确。 + /// + [Fact] + public void Test_Join_Basic() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [Order] [o] "); + result.AppendLine("Join [User] [u] On [o].[UserId]=[u].[Id] "); + result.Append("Where [o].[Status]=@p_0"); + + var builder = NewBuilder(); + + // Act + builder.Select("*") + .From("Order", "o") + .Join("User", "u") + .On("o.UserId", "u.Id") + .Where("o.Status", "paid"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + /// + /// 测试目的:LeftJoin 正确生成 "Left Join" 关键字。 + /// + [Fact] + public void Test_LeftJoin() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [Order] [o] "); + result.Append("Left Join [User] [u] On [o].[UserId]=[u].[Id]"); + + var builder = NewBuilder(); + + // Act + builder.Select("*") + .From("Order", "o") + .LeftJoin("User", "u") + .On("o.UserId", "u.Id"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + // ── OrderBy ────────────────────────────────────────────────── + + /// + /// 测试目的:OrderBy 指定列名,升序生成 Asc 关键字。 + /// + [Fact] + public void Test_OrderBy_Asc() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [User] "); + result.Append("Order By [Name] Asc"); + + var builder = NewBuilder(); + + // Act + builder.Select("*").From("User").OrderBy("Name"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + /// + /// 测试目的:OrderByDesc 生成 Desc 关键字。 + /// + [Fact] + public void Test_OrderBy_Desc() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [User] "); + result.Append("Order By [CreatedTime] Desc"); + + var builder = NewBuilder(); + + // Act + builder.Select("*").From("User").OrderByDesc("CreatedTime"); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + // ── Paging ─────────────────────────────────────────────────── + + /// + /// 测试目的:SQL Server 分页使用 OFFSET/FETCH NEXT 语法,不使用 LIMIT/TOP。 + /// + [Fact] + public void Test_Paging_OffsetFetch() + { + // Arrange + var result = new StringBuilder(); + result.AppendLine("Select * "); + result.AppendLine("From [User] "); + result.AppendLine("Order By [Id] Asc "); + result.Append("Offset @_p_0 Rows Fetch Next @_p_1 Rows Only"); + + var builder = NewBuilder(); + + // Act + builder.Select("*").From("User").OrderBy("Id").Page(1, 10); + + // Assert + Assert.Equal(result.ToString(), builder.ToSql()); + } + + // ── Clone / New ────────────────────────────────────────────── + + /// + /// 测试目的:Clone 后返回独立副本,修改原始 Builder 不影响克隆体。 + /// + [Fact] + public void Test_Clone_ShouldBeIndependent() + { + // Arrange + var original = NewBuilder(); + original.Select("*").From("User"); + + // Act + var cloned = (SqlServerBuilder)original.Clone(); + original.Where("Id", 1); + + // Assert — clone 不含 Where 条件 + cloned.ToSql().ShouldNotContain("Where"); + } + + /// + /// 测试目的:New() 返回相同方言的新 Builder,不含任何已有状态。 + /// + [Fact] + public void Test_New_ShouldReturnFreshBuilder() + { + // Arrange + var original = NewBuilder(); + original.Select("*").From("User").Where("Id", 1); + + // Act + var fresh = original.New(); + + // Assert + fresh.ToSql().ShouldBeNullOrEmpty(); + } + + // ── GetParams ──────────────────────────────────────────────── + + /// + /// 测试目的:无条件时 GetParams 返回空字典,不为 null。 + /// + [Fact] + public void Test_GetParams_NoConditions_ShouldBeEmpty() + { + // Arrange + var builder = NewBuilder(); + builder.Select("*").From("User"); + + // Act + var parms = builder.GetParams(); + + // Assert + parms.ShouldNotBeNull(); + parms.ShouldBeEmpty(); + } + + /// + /// 测试目的:有条件时 GetParams 返回所有参数键值对,数量与条件数量一致。 + /// + [Fact] + public void Test_GetParams_WithConditions_ShouldHaveCorrectCount() + { + // Arrange + var builder = NewBuilder(); + builder.Select("*").From("User").Where("Id", 1).Where("Name", "Alice"); + + // Act + var parms = builder.GetParams(); + + // Assert + parms.Count.ShouldBe(2); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerDialectTest.cs b/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerDialectTest.cs new file mode 100644 index 00000000..fb048198 --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests/Builders/SqlServerDialectTest.cs @@ -0,0 +1,196 @@ +using Bing.Data.Sql.Builders; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Builders; + +/// +/// 单元测试 +/// +public class SqlServerDialectTest +{ + private readonly IDialect _dialect = SqlServerDialect.Instance; + + // ═══════════════════════════════════════════════════════════ + // 标识符(默认方括号) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:OpeningIdentifier 应为方括号 '[',符合 SQL Server 标识符规范。 + /// + [Fact] + public void OpeningIdentifier_ShouldBeLeftBracket() + { + _dialect.OpeningIdentifier.ShouldBe('['); + } + + /// + /// 测试目的:ClosingIdentifier 应为方括号 ']'。 + /// + [Fact] + public void ClosingIdentifier_ShouldBeRightBracket() + { + _dialect.ClosingIdentifier.ShouldBe(']'); + } + + // ═══════════════════════════════════════════════════════════ + // SafeName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SafeName 对普通名称应用 [name] 包裹,适配 SQL Server 保留字规避。 + /// + [Fact] + public void SafeName_PlainName_ShouldWrapWithBrackets() + { + _dialect.SafeName("user").ShouldBe("[user]"); + } + + /// + /// 测试目的:SafeName 对通配符 "*" 应保持不变,不包裹。 + /// + [Fact] + public void SafeName_Wildcard_ShouldReturnAsIs() + { + _dialect.SafeName("*").ShouldBe("*"); + } + + /// + /// 测试目的:SafeName 对空字符串应返回空字符串,不抛异常。 + /// + [Fact] + public void SafeName_EmptyString_ShouldReturnEmpty() + { + _dialect.SafeName(string.Empty).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对 null 应返回空字符串,不抛 NullReferenceException。 + /// + [Fact] + public void SafeName_Null_ShouldReturnEmpty() + { + _dialect.SafeName(null!).ShouldBe(string.Empty); + } + + /// + /// 测试目的:SafeName 对已有方括号包裹的名称应先剥离再重新包裹,保持幂等。 + /// + [Fact] + public void SafeName_AlreadyBracketed_ShouldBeIdempotent() + { + _dialect.SafeName("[order]").ShouldBe("[order]"); + } + + /// + /// 测试目的:SafeName 对含双引号包裹的名称应正确转换为方括号格式。 + /// + [Fact] + public void SafeName_DoubleQuoted_ShouldConvertToBrackets() + { + _dialect.SafeName("\"order\"").ShouldBe("[order]"); + } + + /// + /// 测试目的:SafeName 对含空格的名称应用方括号包裹。 + /// + [Fact] + public void SafeName_NameWithSpace_ShouldWrapWithBrackets() + { + _dialect.SafeName("order item").ShouldBe("[order item]"); + } + + // ═══════════════════════════════════════════════════════════ + // 参数前缀 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetPrefix() 应返回 "@",符合 SQL Server 参数命名约定。 + /// + [Fact] + public void GetPrefix_ShouldReturnAt() + { + _dialect.GetPrefix().ShouldBe("@"); + } + + // ═══════════════════════════════════════════════════════════ + // 参数名生成 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GenerateName(0) 应生成 "@_p_0"。 + /// + [Fact] + public void GenerateName_Zero_ShouldReturnExpected() + { + _dialect.GenerateName(0).ShouldBe("@_p_0"); + } + + /// + /// 测试目的:GenerateName(99) 应生成 "@_p_99",验证多位数序号格式正确。 + /// + [Fact] + public void GenerateName_LargeIndex_ShouldReturnExpected() + { + _dialect.GenerateName(99).ShouldBe("@_p_99"); + } + + // ═══════════════════════════════════════════════════════════ + // SelectAs 支持 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SupportSelectAs() 应返回 true,SQL Server 支持 SELECT 列别名。 + /// + [Fact] + public void SupportSelectAs_ShouldReturnTrue() + { + _dialect.SupportSelectAs().ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // GetParamValue 透传 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetParamValue 对布尔值 true 应原样透传(SQL Server 支持 bit 映射)。 + /// + [Fact] + public void GetParamValue_Bool_ShouldPassThrough() + { + _dialect.GetParamValue(true).ShouldBe(true); + } + + /// + /// 测试目的:GetParamValue 对 null 应原样透传。 + /// + [Fact] + public void GetParamValue_Null_ShouldReturnNull() + { + _dialect.GetParamValue(null).ShouldBeNull(); + } + + /// + /// 测试目的:GetParamValue 对整数值应原样透传。 + /// + [Fact] + public void GetParamValue_Int_ShouldPassThrough() + { + _dialect.GetParamValue(42).ShouldBe(42); + } + + // ═══════════════════════════════════════════════════════════ + // Instance 单例 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Instance 每次访问应返回 SqlServerDialect 类型的非 null 实例。 + /// + [Fact] + public void Instance_ShouldReturnSqlServerDialect() + { + var instance = SqlServerDialect.Instance; + instance.ShouldNotBeNull(); + instance.ShouldBeOfType(); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests/Metadata/SqlServerTypeConverterTest.cs b/framework/tests/Bing.Dapper.SqlServer.Tests/Metadata/SqlServerTypeConverterTest.cs new file mode 100644 index 00000000..03db1935 --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests/Metadata/SqlServerTypeConverterTest.cs @@ -0,0 +1,310 @@ +using System.Data; +using Bing.Data.Metadata; +using Shouldly; +using Xunit; + +namespace Bing.Dapper.Tests.Metadata; + +/// +/// 单元测试。 +/// 验证每个 SQL Server 数据类型字符串都能正确映射为 DbType,以及边界/负例行为。 +/// +public class SqlServerTypeConverterTest +{ + private readonly SqlServerTypeConverter _converter = new(); + + // ═══════════════════════════════════════════════════════════ + // 边界:空输入 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:dataType 为 null 时应返回 null,不抛异常。 + /// + [Fact] + public void ToDbType_Null_ShouldReturnNull() + { + _converter.ToDbType(null).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Empty_ShouldReturnNull() + { + _converter.ToDbType(string.Empty).ShouldBeNull(); + } + + /// + /// 测试目的:dataType 为空白字符串时应返回 null。 + /// + [Fact] + public void ToDbType_Whitespace_ShouldReturnNull() + { + _converter.ToDbType(" ").ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // 负例:未知类型抛出 NotImplementedException + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:未知类型应抛出 NotImplementedException,便于发现未覆盖的类型映射。 + /// + [Fact] + public void ToDbType_UnknownType_ShouldThrowNotImplementedException() + { + Should.Throw(() => _converter.ToDbType("unknown_type")); + } + + // ═══════════════════════════════════════════════════════════ + // 大小写不敏感 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:类型名称大小写不敏感,"INT" 应与 "int" 映射相同。 + /// + [Fact] + public void ToDbType_CaseInsensitive_ShouldMatch() + { + _converter.ToDbType("INT").ShouldBe(DbType.Int32); + _converter.ToDbType("Int").ShouldBe(DbType.Int32); + } + + // ═══════════════════════════════════════════════════════════ + // GUID 类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:uniqueidentifier → DbType.Guid(SQL Server GUID 存储类型)。 + /// + [Theory] + [InlineData("uniqueidentifier")] + public void ToDbType_UniqueIdentifier_ShouldBeGuid(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.Guid); + } + + // ═══════════════════════════════════════════════════════════ + // 字符串类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:nvarchar/text/ntext → DbType.String(Unicode 变长字符串)。 + /// + [Theory] + [InlineData("nvarchar")] + [InlineData("text")] + [InlineData("ntext")] + public void ToDbType_UnicodeStringTypes_ShouldBeString(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.String); + } + + /// + /// 测试目的:varchar → DbType.AnsiString(ANSI 变长字符串)。 + /// + [Fact] + public void ToDbType_Varchar_ShouldBeAnsiString() + { + _converter.ToDbType("varchar").ShouldBe(DbType.AnsiString); + } + + /// + /// 测试目的:char → DbType.AnsiStringFixedLength(ANSI 定长字符串)。 + /// + [Fact] + public void ToDbType_Char_ShouldBeAnsiStringFixedLength() + { + _converter.ToDbType("char").ShouldBe(DbType.AnsiStringFixedLength); + } + + /// + /// 测试目的:nchar → DbType.StringFixedLength(Unicode 定长字符串)。 + /// + [Fact] + public void ToDbType_Nchar_ShouldBeStringFixedLength() + { + _converter.ToDbType("nchar").ShouldBe(DbType.StringFixedLength); + } + + // ═══════════════════════════════════════════════════════════ + // 布尔类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:bit → DbType.Boolean。 + /// + [Fact] + public void ToDbType_Bit_ShouldBeBoolean() + { + _converter.ToDbType("bit").ShouldBe(DbType.Boolean); + } + + // ═══════════════════════════════════════════════════════════ + // 整数类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:tinyint → DbType.Byte(1 字节无符号整数)。 + /// + [Fact] + public void ToDbType_TinyInt_ShouldBeByte() + { + _converter.ToDbType("tinyint").ShouldBe(DbType.Byte); + } + + /// + /// 测试目的:smallint → DbType.Int16。 + /// + [Fact] + public void ToDbType_SmallInt_ShouldBeInt16() + { + _converter.ToDbType("smallint").ShouldBe(DbType.Int16); + } + + /// + /// 测试目的:int → DbType.Int32。 + /// + [Fact] + public void ToDbType_Int_ShouldBeInt32() + { + _converter.ToDbType("int").ShouldBe(DbType.Int32); + } + + /// + /// 测试目的:bigint → DbType.Int64。 + /// + [Fact] + public void ToDbType_BigInt_ShouldBeInt64() + { + _converter.ToDbType("bigint").ShouldBe(DbType.Int64); + } + + // ═══════════════════════════════════════════════════════════ + // 浮点/精确数值类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:real → DbType.Single(4 字节浮点)。 + /// + [Fact] + public void ToDbType_Real_ShouldBeSingle() + { + _converter.ToDbType("real").ShouldBe(DbType.Single); + } + + /// + /// 测试目的:float → DbType.Double(8 字节浮点)。 + /// + [Fact] + public void ToDbType_Float_ShouldBeDouble() + { + _converter.ToDbType("float").ShouldBe(DbType.Double); + } + + /// + /// 测试目的:decimal/numeric/money/smallmoney → DbType.Decimal。 + /// + [Theory] + [InlineData("decimal")] + [InlineData("numeric")] + [InlineData("money")] + [InlineData("smallmoney")] + public void ToDbType_DecimalTypes_ShouldBeDecimal(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.Decimal); + } + + // ═══════════════════════════════════════════════════════════ + // 日期时间类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:date → DbType.Date(仅日期,不含时间)。 + /// + [Fact] + public void ToDbType_Date_ShouldBeDate() + { + _converter.ToDbType("date").ShouldBe(DbType.Date); + } + + /// + /// 测试目的:time → DbType.Time(仅时间,不含日期)。 + /// + [Fact] + public void ToDbType_Time_ShouldBeTime() + { + _converter.ToDbType("time").ShouldBe(DbType.Time); + } + + /// + /// 测试目的:datetime/smalldatetime → DbType.DateTime。 + /// + [Theory] + [InlineData("datetime")] + [InlineData("smalldatetime")] + public void ToDbType_DateTimeTypes_ShouldBeDateTime(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.DateTime); + } + + /// + /// 测试目的:datetime2 → DbType.DateTime2(高精度日期时间)。 + /// + [Fact] + public void ToDbType_DateTime2_ShouldBeDateTime2() + { + _converter.ToDbType("datetime2").ShouldBe(DbType.DateTime2); + } + + /// + /// 测试目的:datetimeoffset → DbType.DateTimeOffset(含时区偏移)。 + /// + [Fact] + public void ToDbType_DateTimeOffset_ShouldBeDateTimeOffset() + { + _converter.ToDbType("datetimeoffset").ShouldBe(DbType.DateTimeOffset); + } + + // ═══════════════════════════════════════════════════════════ + // 二进制类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:binary/varbinary/varbinary(max)/image/rowversion/timestamp → DbType.Binary。 + /// + [Theory] + [InlineData("binary")] + [InlineData("varbinary")] + [InlineData("varbinary(max)")] + [InlineData("image")] + [InlineData("rowversion")] + [InlineData("timestamp")] + public void ToDbType_BinaryTypes_ShouldBeBinary(string dataType) + { + _converter.ToDbType(dataType).ShouldBe(DbType.Binary); + } + + // ═══════════════════════════════════════════════════════════ + // 其他类型 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:xml → DbType.Xml。 + /// + [Fact] + public void ToDbType_Xml_ShouldBeXml() + { + _converter.ToDbType("xml").ShouldBe(DbType.Xml); + } + + /// + /// 测试目的:sql_variant → DbType.Object(可变类型容器)。 + /// + [Fact] + public void ToDbType_SqlVariant_ShouldBeObject() + { + _converter.ToDbType("sql_variant").ShouldBe(DbType.Object); + } +} diff --git a/framework/tests/Bing.Dapper.SqlServer.Tests/global-usings.cs b/framework/tests/Bing.Dapper.SqlServer.Tests/global-usings.cs new file mode 100644 index 00000000..14743d37 --- /dev/null +++ b/framework/tests/Bing.Dapper.SqlServer.Tests/global-usings.cs @@ -0,0 +1,3 @@ +global using System; +global using Xunit; +global using Shouldly; diff --git a/framework/tests/Bing.Data.Tests/DataConfigConnectionAndTreeConditionTest.cs b/framework/tests/Bing.Data.Tests/DataConfigConnectionAndTreeConditionTest.cs new file mode 100644 index 00000000..8a0272dd --- /dev/null +++ b/framework/tests/Bing.Data.Tests/DataConfigConnectionAndTreeConditionTest.cs @@ -0,0 +1,542 @@ +using System.Data; +using Bing.Data; +using Bing.Data.Enums; +using Bing.Data.Queries.Conditions; +using Bing.Trees; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Data.Tests; + +// ─── 测试辅助:实体样本(同时实现 IPath + IEnabled + IParentId)──────── + +file class TreeEntity : IPath, IEnabled, IParentId +{ + public string Path { get; set; } + public int Level { get; set; } + public bool Enabled { get; set; } + public Guid? ParentId { get; set; } +} + +// ─── ConnectionStringCollection 测试 ───────────────────────────── + +/// +/// 单元测试 +/// +public class ConnectionStringCollectionTest +{ + /// + /// 测试目的:常量 DefaultConnectionStringName 值应为 "Default"。 + /// + [Fact] + public void DefaultConnectionStringName_ShouldBe_Default() + { + // Assert + ConnectionStringCollection.DefaultConnectionStringName.ShouldBe("Default"); + } + + /// + /// 测试目的:Default 属性赋值后可正确读取。 + /// + [Fact] + public void Default_SetAndGet_ShouldReturnSameValue() + { + // Arrange + var col = new ConnectionStringCollection(); + + // Act + col.Default = "Server=localhost;Database=test"; + + // Assert + col.Default.ShouldBe("Server=localhost;Database=test"); + } + + /// + /// 测试目的:Default 未赋值时应返回 null。 + /// + [Fact] + public void Default_WhenNotSet_ShouldReturnNull() + { + // Arrange + var col = new ConnectionStringCollection(); + + // Assert + col.Default.ShouldBeNull(); + } + + /// + /// 测试目的:GetConnectionString 传入存在的名称应返回对应值。 + /// + [Fact] + public void GetConnectionString_ExistingName_ShouldReturnValue() + { + // Arrange + var col = new ConnectionStringCollection(); + col["Slave"] = "Server=slave;"; + + // Act + var result = col.GetConnectionString("Slave"); + + // Assert + result.ShouldBe("Server=slave;"); + } + + /// + /// 测试目的:GetConnectionString 传入不存在的名称应回退到 Default。 + /// + [Fact] + public void GetConnectionString_MissingName_ShouldFallbackToDefault() + { + // Arrange + var col = new ConnectionStringCollection(); + col.Default = "Server=default;"; + + // Act + var result = col.GetConnectionString("NotExist"); + + // Assert + result.ShouldBe("Server=default;"); + } + + /// + /// 测试目的:GetConnectionString 传入 "Default" 应返回 Default 属性的值。 + /// + [Fact] + public void GetConnectionString_Default_ShouldReturnDefaultValue() + { + // Arrange + var col = new ConnectionStringCollection(); + col.Default = "Server=master;"; + + // Act + var result = col.GetConnectionString("Default"); + + // Assert + result.ShouldBe("Server=master;"); + } +} + +// ─── DataConfig 测试 ────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class DataConfigTest +{ + /// + /// 测试目的:默认 LogLevel 应为 DataLogLevel.Sql。 + /// + [Fact] + public void Default_LogLevel_ShouldBeSql() + { + // Act + var config = new DataConfig(); + + // Assert + config.LogLevel.ShouldBe(DataLogLevel.Sql); + } + + /// + /// 测试目的:默认 AutoCommit 应为 false。 + /// + [Fact] + public void Default_AutoCommit_ShouldBeFalse() + { + // Act + var config = new DataConfig(); + + // Assert + config.AutoCommit.ShouldBeFalse(); + } + + /// + /// 测试目的:默认 EnabledValidateVersion 应为 true。 + /// + [Fact] + public void Default_EnabledValidateVersion_ShouldBeTrue() + { + // Act + var config = new DataConfig(); + + // Assert + config.EnabledValidateVersion.ShouldBeTrue(); + } + + /// + /// 测试目的:默认 EnabledDeleteFilter 应为 true。 + /// + [Fact] + public void Default_EnabledDeleteFilter_ShouldBeTrue() + { + // Act + var config = new DataConfig(); + + // Assert + config.EnabledDeleteFilter.ShouldBeTrue(); + } + + /// + /// 测试目的:默认构造器应自动初始化 SqlOptions 属性(非 null)。 + /// + [Fact] + public void Default_SqlOptions_ShouldNotBeNull() + { + // Act + var config = new DataConfig(); + + // Assert + config.SqlOptions.ShouldNotBeNull(); + } + + /// + /// 测试目的:默认 AdoLogInterceptor 应为 null。 + /// + [Fact] + public void Default_AdoLogInterceptor_ShouldBeNull() + { + // Act + var config = new DataConfig(); + + // Assert + config.AdoLogInterceptor.ShouldBeNull(); + } + + /// + /// 测试目的:各属性赋值后应正确读取。 + /// + [Fact] + public void Properties_SetAndGet_ShouldRoundtrip() + { + // Arrange + var config = new DataConfig(); + + // Act + config.LogLevel = DataLogLevel.All; + config.AutoCommit = true; + config.EnabledValidateVersion = false; + config.EnabledDeleteFilter = false; + + // Assert + config.LogLevel.ShouldBe(DataLogLevel.All); + config.AutoCommit.ShouldBeTrue(); + config.EnabledValidateVersion.ShouldBeFalse(); + config.EnabledDeleteFilter.ShouldBeFalse(); + } +} + +// ─── SqlOptions 测试 ────────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class SqlOptionsTest +{ + /// + /// 测试目的:默认 DatabaseType 应为 SqlServer。 + /// + [Fact] + public void Default_DatabaseType_ShouldBeSqlServer() + { + // Act + var options = new SqlOptions(); + + // Assert + options.DatabaseType.ShouldBe(DatabaseType.SqlServer); + } + + /// + /// 测试目的:默认 IsClearAfterExecution 应为 true。 + /// + [Fact] + public void Default_IsClearAfterExecution_ShouldBeTrue() + { + // Act + var options = new SqlOptions(); + + // Assert + options.IsClearAfterExecution.ShouldBeTrue(); + } + + /// + /// 测试目的:默认 LogLevel 应为 DataLogLevel.Sql。 + /// + [Fact] + public void Default_LogLevel_ShouldBeSql() + { + // Act + var options = new SqlOptions(); + + // Assert + options.LogLevel.ShouldBe(DataLogLevel.Sql); + } + + /// + /// 测试目的:默认 LogCategory 应为 "Bing.Data.Sql"。 + /// + [Fact] + public void Default_LogCategory_ShouldBeBingDataSql() + { + // Act + var options = new SqlOptions(); + + // Assert + options.LogCategory.ShouldBe("Bing.Data.Sql"); + } + + /// + /// 测试目的:默认 ConnectionString 和 Connection 均为 null。 + /// + [Fact] + public void Default_ConnectionFields_ShouldBeNull() + { + // Act + var options = new SqlOptions(); + + // Assert + options.ConnectionString.ShouldBeNull(); + options.Connection.ShouldBeNull(); + } + + /// + /// 测试目的:各属性赋值后应正确读取。 + /// + [Fact] + public void Properties_SetAndGet_ShouldRoundtrip() + { + // Arrange + var options = new SqlOptions(); + var mockConn = new Mock().Object; + + // Act + options.DatabaseType = DatabaseType.MySql; + options.IsClearAfterExecution = false; + options.LogLevel = DataLogLevel.None; + options.LogCategory = "custom"; + options.ConnectionString = "Server=localhost"; + options.Connection = mockConn; + + // Assert + options.DatabaseType.ShouldBe(DatabaseType.MySql); + options.IsClearAfterExecution.ShouldBeFalse(); + options.LogLevel.ShouldBe(DataLogLevel.None); + options.LogCategory.ShouldBe("custom"); + options.ConnectionString.ShouldBe("Server=localhost"); + options.Connection.ShouldBeSameAs(mockConn); + } + + /// + /// 测试目的:SqlOptions<T> 是 SqlOptions 的子类,属性应继承。 + /// + [Fact] + public void SqlOptionsGeneric_ShouldInheritFromSqlOptions() + { + // Act + var options = new SqlOptions(); + + // Assert + (options is SqlOptions).ShouldBeTrue(); + options.DatabaseType.ShouldBe(DatabaseType.SqlServer); // 继承默认值 + } +} + +// ─── DefaultDatabase 测试 ───────────────────────────────────────── + +/// +/// 单元测试 +/// +public class DefaultDatabaseTest +{ + /// + /// 测试目的:构造函数传入 null 连接时应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WhenConnectionIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => new DefaultDatabase(null)); + } + + /// + /// 测试目的:GetConnection 应返回构造时传入的同一连接实例。 + /// + [Fact] + public void GetConnection_ShouldReturnSameInstanceAsProvided() + { + // Arrange + var mockConn = new Mock().Object; + var db = new DefaultDatabase(mockConn); + + // Act + var result = db.GetConnection(); + + // Assert + result.ShouldBeSameAs(mockConn); + } +} + +// ─── TreeCondition 测试 ────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class TreeConditionTest +{ + private static Mock> CreateParam( + string path = null, int? level = null, bool? enabled = null) + { + var mock = new Mock>(); + mock.Setup(p => p.Path).Returns(path); + mock.Setup(p => p.Level).Returns(level); + mock.Setup(p => p.Enabled).Returns(enabled); + mock.Setup(p => p.ParentId).Returns((Guid?)null); + return mock; + } + + /// + /// 测试目的:参数为 null 时,不应抛出异常,且 GetCondition 返回 null。 + /// + [Fact] + public void Constructor_WhenParameterIsNull_ShouldNotThrow() + { + // Act + var condition = Should.NotThrow(() => + new TreeCondition(null)); + + // Assert + condition.GetCondition().ShouldBeNull(); + } + + /// + /// 测试目的:所有参数均为空时,GetCondition 应返回 null(无约束)。 + /// + [Fact] + public void GetCondition_WhenAllParamsAreNull_ShouldReturnNull() + { + // Arrange + var param = CreateParam(); + var condition = new TreeCondition(param.Object); + + // Assert + condition.GetCondition().ShouldBeNull(); + } + + /// + /// 测试目的:设置 Path 时,GetCondition 应只匹配路径前缀包含该路径的实体。 + /// + [Fact] + public void GetCondition_WithPath_ShouldFilterByPathPrefix() + { + // Arrange + var param = CreateParam(path: "/root/"); + var cond = new TreeCondition(param.Object); + var expr = cond.GetCondition(); + + var entities = new List + { + new() { Path = "/root/child1/", Level = 2, Enabled = true }, + new() { Path = "/other/", Level = 1, Enabled = true }, + }; + + // Act + var results = entities.AsQueryable().Where(expr).ToList(); + + // Assert + results.Count.ShouldBe(1); + results[0].Path.ShouldBe("/root/child1/"); + } + + /// + /// 测试目的:设置 Level 时,GetCondition 应只匹配对应级数的实体。 + /// + [Fact] + public void GetCondition_WithLevel_ShouldFilterByLevel() + { + // Arrange + var param = CreateParam(level: 2); + var cond = new TreeCondition(param.Object); + var expr = cond.GetCondition(); + + var entities = new List + { + new() { Path = "/a/", Level = 1, Enabled = true }, + new() { Path = "/a/b/", Level = 2, Enabled = true }, + new() { Path = "/a/b/c/", Level = 3, Enabled = true }, + }; + + // Act + var results = entities.AsQueryable().Where(expr).ToList(); + + // Assert + results.Count.ShouldBe(1); + results[0].Level.ShouldBe(2); + } + + /// + /// 测试目的:设置 Enabled = true 时,GetCondition 应只匹配已启用的实体。 + /// + [Fact] + public void GetCondition_WithEnabled_ShouldFilterByEnabledFlag() + { + // Arrange + var param = CreateParam(enabled: true); + var cond = new TreeCondition(param.Object); + var expr = cond.GetCondition(); + + var entities = new List + { + new() { Path = "/a/", Level = 1, Enabled = true }, + new() { Path = "/b/", Level = 1, Enabled = false }, + }; + + // Act + var results = entities.AsQueryable().Where(expr).ToList(); + + // Assert + results.Count.ShouldBe(1); + results[0].Enabled.ShouldBeTrue(); + } + + /// + /// 测试目的:同时设置 Path + Level + Enabled 时,应组合 AND 条件过滤。 + /// + [Fact] + public void GetCondition_CombinedConditions_ShouldApplyAll() + { + // Arrange + var param = CreateParam(path: "/root/", level: 2, enabled: true); + var cond = new TreeCondition(param.Object); + var expr = cond.GetCondition(); + + var entities = new List + { + new() { Path = "/root/a/", Level = 2, Enabled = true }, // 全匹配 + new() { Path = "/root/a/", Level = 2, Enabled = false }, // Enabled 不匹配 + new() { Path = "/root/a/", Level = 3, Enabled = true }, // Level 不匹配 + new() { Path = "/other/a/", Level = 2, Enabled = true }, // Path 不匹配 + }; + + // Act + var results = entities.AsQueryable().Where(expr).ToList(); + + // Assert + results.Count.ShouldBe(1); + results[0].Path.ShouldBe("/root/a/"); + results[0].Level.ShouldBe(2); + results[0].Enabled.ShouldBeTrue(); + } + + /// + /// 测试目的:TreeCondition<TEntity>(Guid? 简化版)构造应正常工作。 + /// + [Fact] + public void TreeCondition_GuidShorthand_ShouldWork() + { + // Arrange + var param = CreateParam(enabled: false); + var cond = new TreeCondition(param.Object); + + // Assert + cond.GetCondition().ShouldNotBeNull(); + } +} diff --git a/framework/tests/Bing.Data.Tests/DataExtendingAndTransactionTest.cs b/framework/tests/Bing.Data.Tests/DataExtendingAndTransactionTest.cs new file mode 100644 index 00000000..850dedb2 --- /dev/null +++ b/framework/tests/Bing.Data.Tests/DataExtendingAndTransactionTest.cs @@ -0,0 +1,556 @@ +using System.Data; +using System.Threading.Tasks; +using Bing.Data.ObjectExtending; +using Bing.Data.Transaction; +using Bing.Trees; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Data.Tests; + +// ───────────────────────────────────────────────────────────────────────────── +// ExtraPropertyDictionary +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// ExtraPropertyDictionary 及其扩展方法测试 +/// +public class ExtraPropertyDictionaryTest +{ + // ════════════════════════════════════════════════════════════════ + // 构造 + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后字典应为空。 + /// + [Fact] + public void DefaultCtor_ShouldBeEmpty() + { + // Act + var dict = new ExtraPropertyDictionary(); + + // Assert + dict.ShouldBeEmpty(); + } + + /// + /// 测试目的:用现有字典构造后,所有键值应被复制。 + /// + [Fact] + public void CopyCtor_ShouldCopyAllEntries() + { + // Arrange + var source = new Dictionary + { + ["Key1"] = "Value1", + ["Key2"] = 42 + }; + + // Act + var dict = new ExtraPropertyDictionary(source); + + // Assert + dict.Count.ShouldBe(2); + dict["Key1"].ShouldBe("Value1"); + dict["Key2"].ShouldBe(42); + } + + // ════════════════════════════════════════════════════════════════ + // GetProperty + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:source 为 null 时 GetProperty 应抛出 ArgumentNullException。 + /// + [Fact] + public void GetProperty_NullSource_ShouldThrowArgumentNullException() + { + // Arrange + ExtraPropertyDictionary dict = null; + + // Act & Assert + Should.Throw(() => dict.GetProperty("Key")); + } + + /// + /// 测试目的:键不存在时 GetProperty 应返回该类型默认值。 + /// + [Fact] + public void GetProperty_MissingKey_ShouldReturnDefault() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + + // Act + var result = dict.GetProperty("NotExist"); + + // Assert + result.ShouldBe(default(int)); + } + + /// + /// 测试目的:键存在时 GetProperty 应返回正确的强类型值。 + /// + [Fact] + public void GetProperty_ExistingKey_ShouldReturnTypedValue() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + dict["Name"] = "Alice"; + + // Act + var result = dict.GetProperty("Name"); + + // Assert + result.ShouldBe("Alice"); + } + + /// + /// 测试目的:存储 int 值,GetProperty 应转换为 int 正确返回。 + /// + [Fact] + public void GetProperty_IntValue_ShouldConvertToInt() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + dict["Age"] = "30"; + + // Act + var result = dict.GetProperty("Age"); + + // Assert + result.ShouldBe(30); + } + + // ════════════════════════════════════════════════════════════════ + // SetProperty + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:source 为 null 时 SetProperty 应抛出 ArgumentNullException。 + /// + [Fact] + public void SetProperty_NullSource_ShouldThrowArgumentNullException() + { + // Arrange + ExtraPropertyDictionary dict = null; + + // Act & Assert + Should.Throw(() => dict.SetProperty("Key", "Value")); + } + + /// + /// 测试目的:SetProperty 新键应正确添加。 + /// + [Fact] + public void SetProperty_NewKey_ShouldAdd() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + + // Act + dict.SetProperty("Role", "Admin"); + + // Assert + dict["Role"].ShouldBe("Admin"); + } + + /// + /// 测试目的:SetProperty 已有键应覆盖旧值(不重复)。 + /// + [Fact] + public void SetProperty_ExistingKey_ShouldOverwrite() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + dict["Role"] = "User"; + + // Act + dict.SetProperty("Role", "Admin"); + + // Assert + dict["Role"].ShouldBe("Admin"); + dict.Count.ShouldBe(1); + } + + /// + /// 测试目的:SetProperty 应返回自身(支持链式调用)。 + /// + [Fact] + public void SetProperty_ShouldReturnSameInstance() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + + // Act + var returned = dict.SetProperty("Key", "Value"); + + // Assert + returned.ShouldBeSameAs(dict); + } + + // ════════════════════════════════════════════════════════════════ + // RemoveProperty + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:source 为 null 时 RemoveProperty 应抛出 ArgumentNullException。 + /// + [Fact] + public void RemoveProperty_NullSource_ShouldThrowArgumentNullException() + { + // Arrange + ExtraPropertyDictionary dict = null; + + // Act & Assert + Should.Throw(() => dict.RemoveProperty("Key")); + } + + /// + /// 测试目的:移除存在的键后,字典中不应再包含该键。 + /// + [Fact] + public void RemoveProperty_ExistingKey_ShouldRemove() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + dict["Tag"] = "test"; + + // Act + dict.RemoveProperty("Tag"); + + // Assert + dict.ContainsKey("Tag").ShouldBeFalse(); + } + + /// + /// 测试目的:移除不存在的键时,不应抛出异常(幂等安全)。 + /// + [Fact] + public void RemoveProperty_MissingKey_ShouldNotThrow() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + + // Act & Assert + Should.NotThrow(() => dict.RemoveProperty("NotExist")); + } + + /// + /// 测试目的:RemoveProperty 应返回自身(支持链式调用)。 + /// + [Fact] + public void RemoveProperty_ShouldReturnSameInstance() + { + // Arrange + var dict = new ExtraPropertyDictionary(); + + // Act + var returned = dict.RemoveProperty("Key"); + + // Assert + returned.ShouldBeSameAs(dict); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// TransactionActionManager +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// TransactionActionManager 单元测试 +/// +public class TransactionActionManagerTest +{ + // ════════════════════════════════════════════════════════════════ + // Count + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:初始状态 Count 应为 0。 + /// + [Fact] + public void Count_Initial_ShouldBeZero() + { + // Act + var mgr = new TransactionActionManager(); + + // Assert + mgr.Count.ShouldBe(0); + } + + // ════════════════════════════════════════════════════════════════ + // Register + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:Register(null) 应静默忽略,Count 不增加。 + /// + [Fact] + public void Register_NullAction_ShouldIgnore() + { + // Arrange + var mgr = new TransactionActionManager(); + + // Act + mgr.Register(null); + + // Assert + mgr.Count.ShouldBe(0); + } + + /// + /// 测试目的:Register 一个有效操作后 Count 应为 1。 + /// + [Fact] + public void Register_ValidAction_ShouldIncreaseCount() + { + // Arrange + var mgr = new TransactionActionManager(); + + // Act + mgr.Register(_ => Task.CompletedTask); + + // Assert + mgr.Count.ShouldBe(1); + } + + /// + /// 测试目的:Register 多次,Count 应依次累加。 + /// + [Fact] + public void Register_MultipleActions_ShouldAccumulateCount() + { + // Arrange + var mgr = new TransactionActionManager(); + + // Act + mgr.Register(_ => Task.CompletedTask); + mgr.Register(_ => Task.CompletedTask); + mgr.Register(_ => Task.CompletedTask); + + // Assert + mgr.Count.ShouldBe(3); + } + + // ════════════════════════════════════════════════════════════════ + // CommitAsync + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:CommitAsync 应按注册顺序依次调用所有操作,并将 IDbTransaction 传递给每个操作。 + /// + [Fact] + public async Task CommitAsync_ShouldInvokeAllActionsInOrder() + { + // Arrange + var mgr = new TransactionActionManager(); + var txn = new Mock().Object; + var order = new List(); + + mgr.Register(_ => { order.Add(1); return Task.CompletedTask; }); + mgr.Register(_ => { order.Add(2); return Task.CompletedTask; }); + mgr.Register(_ => { order.Add(3); return Task.CompletedTask; }); + + // Act + await mgr.CommitAsync(txn); + + // Assert + order.ShouldBe(new[] { 1, 2, 3 }); + } + + /// + /// 测试目的:CommitAsync 执行完毕后,Count 应清零(列表清空)。 + /// + [Fact] + public async Task CommitAsync_AfterCommit_CountShouldBeZero() + { + // Arrange + var mgr = new TransactionActionManager(); + var txn = new Mock().Object; + mgr.Register(_ => Task.CompletedTask); + mgr.Register(_ => Task.CompletedTask); + + // Act + await mgr.CommitAsync(txn); + + // Assert + mgr.Count.ShouldBe(0); + } + + /// + /// 测试目的:CommitAsync 应将正确的 IDbTransaction 实例传递给每个操作。 + /// + [Fact] + public async Task CommitAsync_ShouldPassCorrectTransactionToActions() + { + // Arrange + var mgr = new TransactionActionManager(); + var txn = new Mock().Object; + IDbTransaction receivedTxn = null; + + mgr.Register(t => { receivedTxn = t; return Task.CompletedTask; }); + + // Act + await mgr.CommitAsync(txn); + + // Assert + receivedTxn.ShouldBeSameAs(txn); + } + + /// + /// 测试目的:空管理器 CommitAsync 应直接完成,不抛异常。 + /// + [Fact] + public async Task CommitAsync_EmptyManager_ShouldCompleteWithoutThrowing() + { + // Arrange + var mgr = new TransactionActionManager(); + var txn = new Mock().Object; + + // Act & Assert + await Should.NotThrowAsync(() => mgr.CommitAsync(txn)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// TreeQueryParameter +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// TreeQueryParameter 测试用子类(TreeQueryParameter 构造函数受 protected 保护) +/// +file class GuidTreeQuery : TreeQueryParameter +{ + public GuidTreeQuery() { } +} + +/// +/// TreeQueryParameter 单元测试 +/// +public class TreeQueryParameterTest +{ + // ════════════════════════════════════════════════════════════════ + // 默认值 + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Order 应为 "SortId"。 + /// + [Fact] + public void DefaultCtor_Order_ShouldBeSortId() + { + // Act + var query = new GuidTreeQuery(); + + // Assert + query.Order.ShouldBe("SortId"); + } + + /// + /// 测试目的:Path 默认值应为空字符串,且 setter/getter 透明。 + /// + [Fact] + public void Path_DefaultValue_ShouldBeEmptyString() + { + // Act + var query = new GuidTreeQuery(); + + // Assert + query.Path.ShouldBe(string.Empty); + } + + // ════════════════════════════════════════════════════════════════ + // Path getter trim + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:Path 设置带前后空格的值时,getter 应自动 Trim。 + /// + [Fact] + public void Path_WithPaddingWhitespace_GetterShouldTrim() + { + // Arrange + var query = new GuidTreeQuery(); + + // Act + query.Path = " 001.002. "; + + // Assert + query.Path.ShouldBe("001.002."); + } + + /// + /// 测试目的:Path 设置为 null 时,getter 应返回空字符串(null 安全)。 + /// + [Fact] + public void Path_SetNull_GetterShouldReturnEmpty() + { + // Arrange + var query = new GuidTreeQuery(); + + // Act + query.Path = null; + + // Assert + query.Path.ShouldBe(string.Empty); + } + + // ════════════════════════════════════════════════════════════════ + // IsSearch + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:不设置任何搜索条件时,IsSearch 应返回 false。 + /// + [Fact] + public void IsSearch_NoSearchConditions_ShouldReturnFalse() + { + // Act + var query = new GuidTreeQuery(); + + // Assert + query.IsSearch().ShouldBeFalse(); + } + + /// + /// 测试目的:设置 ParentId 后,IsSearch 应返回 true。 + /// + [Fact] + public void IsSearch_WithParentId_ShouldReturnTrue() + { + // Arrange + var query = new GuidTreeQuery { ParentId = Guid.NewGuid() }; + + // Assert + query.IsSearch().ShouldBeTrue(); + } + + /// + /// 测试目的:设置 Path 后,IsSearch 应返回 true。 + /// + [Fact] + public void IsSearch_WithPath_ShouldReturnTrue() + { + // Arrange + var query = new GuidTreeQuery { Path = "001." }; + + // Assert + query.IsSearch().ShouldBeTrue(); + } + + /// + /// 测试目的:仅设置 Page/PageSize/Order(分页排序字段)时,IsSearch 应仍返回 false。 + /// + [Fact] + public void IsSearch_OnlyPagingFields_ShouldReturnFalse() + { + // Arrange + var query = new GuidTreeQuery { Page = 2, PageSize = 20, Order = "SortId" }; + + // Assert + query.IsSearch().ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Data.Tests/Queries/OrderByTest.cs b/framework/tests/Bing.Data.Tests/Queries/OrderByTest.cs new file mode 100644 index 00000000..9d576fb7 --- /dev/null +++ b/framework/tests/Bing.Data.Tests/Queries/OrderByTest.cs @@ -0,0 +1,220 @@ +using Bing.Data.Queries; +using Shouldly; +using Xunit; + +namespace Bing.Data.Tests; + +/// +/// 测试目的:验证 的属性存储与 输出格式。 +/// +public class OrderByItemTest +{ + // ── Generate(升序)──────────────────────────────────────────────── + + /// + /// 测试目的:Desc=false 时,Generate 应返回属性名本身(不附加 " desc")。 + /// + [Fact] + public void Generate_Asc_ShouldReturnNameOnly() + { + // Arrange + var item = new OrderByItem("Name", false); + + // Act + var result = item.Generate(); + + // Assert + result.ShouldBe("Name"); + } + + /// + /// 测试目的:Desc=true 时,Generate 应返回 "{Name} desc"。 + /// + [Fact] + public void Generate_Desc_ShouldReturnNameWithDescSuffix() + { + // Arrange + var item = new OrderByItem("CreationTime", true); + + // Act + var result = item.Generate(); + + // Assert + result.ShouldBe("CreationTime desc"); + } + + // ── 属性读写 ─────────────────────────────────────────────────────── + + /// + /// 测试目的:构造后 Name 和 Desc 属性应与传入参数一致。 + /// + [Fact] + public void Properties_ShouldMatchConstructorArgs() + { + // Arrange & Act + var item = new OrderByItem("Age", true); + + // Assert + item.Name.ShouldBe("Age"); + item.Desc.ShouldBeTrue(); + } + + /// + /// 测试目的:通过属性修改 Name 和 Desc 后,Generate 应反映最新值。 + /// + [Fact] + public void Generate_AfterPropertyChange_ShouldReflectNewValues() + { + // Arrange + var item = new OrderByItem("Name", false); + + // Act + item.Name = "Score"; + item.Desc = true; + + // Assert + item.Generate().ShouldBe("Score desc"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 的 Add / Generate 逻辑, +/// 涵盖空输入、升降序混合、多字段组合等场景。 +/// +public class OrderByBuilderTest +{ + // ── 空输入 ──────────────────────────────────────────────────────── + + /// + /// 测试目的:未 Add 任何项时,Generate 应返回 null 或空字符串,不抛异常。 + /// + [Fact] + public void Generate_WithNoItems_ShouldReturnNullOrEmpty() + { + // Arrange + var builder = new OrderByBuilder(); + + // Act + var result = builder.Generate(); + + // Assert + (result == null || result == string.Empty).ShouldBeTrue(); + } + + /// + /// 测试目的:Add null 名称时应被忽略,Generate 结果应为空。 + /// + [Fact] + public void Add_NullName_ShouldBeIgnored() + { + // Arrange + var builder = new OrderByBuilder(); + + // Act + builder.Add(null); + var result = builder.Generate(); + + // Assert + (result == null || result == string.Empty).ShouldBeTrue(); + } + + /// + /// 测试目的:Add 空白字符串时应被忽略,Generate 结果应为空。 + /// + [Fact] + public void Add_WhitespaceName_ShouldBeIgnored() + { + // Arrange + var builder = new OrderByBuilder(); + + // Act + builder.Add(" "); + var result = builder.Generate(); + + // Assert + (result == null || result == string.Empty).ShouldBeTrue(); + } + + // ── 单字段 ──────────────────────────────────────────────────────── + + /// + /// 测试目的:单个升序字段,Generate 应返回该字段名。 + /// + [Fact] + public void Generate_SingleAscField_ShouldReturnName() + { + // Arrange + var builder = new OrderByBuilder(); + builder.Add("Name"); + + // Act + var result = builder.Generate(); + + // Assert + result.ShouldBe("Name"); + } + + /// + /// 测试目的:单个降序字段,Generate 应返回 "{Name} desc"。 + /// + [Fact] + public void Generate_SingleDescField_ShouldReturnNameDesc() + { + // Arrange + var builder = new OrderByBuilder(); + builder.Add("CreationTime", true); + + // Act + var result = builder.Generate(); + + // Assert + result.ShouldBe("CreationTime desc"); + } + + // ── 多字段 ──────────────────────────────────────────────────────── + + /// + /// 测试目的:多个字段(升降序混合)Generate 应用逗号分隔,顺序与 Add 顺序一致。 + /// + [Fact] + public void Generate_MultipleFields_ShouldJoinWithComma() + { + // Arrange + var builder = new OrderByBuilder(); + builder.Add("Name"); + builder.Add("Age", true); + builder.Add("CreationTime"); + + // Act + var result = builder.Generate(); + + // Assert + result.ShouldContain("Name"); + result.ShouldContain("Age desc"); + result.ShouldContain("CreationTime"); + // 确保顺序:Name 在 Age 前 + result.IndexOf("Name").ShouldBeLessThan(result.IndexOf("Age desc")); + } + + /// + /// 测试目的:Add 两个纯升序字段,Generate 应包含正确的逗号分隔符。 + /// + [Fact] + public void Generate_TwoAscFields_ShouldContainComma() + { + // Arrange + var builder = new OrderByBuilder(); + builder.Add("Id"); + builder.Add("Name"); + + // Act + var result = builder.Generate(); + + // Assert + result.ShouldContain(","); + result.ShouldContain("Id"); + result.ShouldContain("Name"); + } +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Data/SoftDeleteAndDomainExtensionsTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Data/SoftDeleteAndDomainExtensionsTest.cs new file mode 100644 index 00000000..3b65934a --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Data/SoftDeleteAndDomainExtensionsTest.cs @@ -0,0 +1,303 @@ +using Bing.Auditing; +using Bing.Data; +using Bing.Tests.Samples; + +namespace Bing.Data; + +// ───────────────────────────────────────────────────────────────────────────── +// ISoftDelete 测试辅助类型 +// ───────────────────────────────────────────────────────────────────────────── + +/// 简单的逻辑删除实体(仅实现 ISoftDelete) +internal class SoftDeleteEntity : ISoftDelete +{ + public bool IsDeleted { get; set; } +} + +/// 带删除审计的逻辑删除实体(同时实现 ISoftDelete + IDeletionAuditedObject) +internal class AuditedSoftDeleteEntity : ISoftDelete, IDeletionAuditedObject +{ + public bool IsDeleted { get; set; } + public Guid? DeleterId { get; set; } + public DateTime? DeletionTime { get; set; } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 的 IsNullOrDeleted 和 UnDelete 行为。 +/// +public class SoftDeleteExtensionsTest +{ + // ── IsNullOrDeleted ─────────────────────────────────────────────────── + + /// + /// 测试目的:entity 为 null 时,IsNullOrDeleted 应返回 true。 + /// + [Fact] + public void IsNullOrDeleted_WhenNull_ShouldReturnTrue() + { + // Act + var result = ((ISoftDelete)null).IsNullOrDeleted(); + + // Assert + result.ShouldBeTrue(); + } + + /// + /// 测试目的:entity.IsDeleted=true 时,IsNullOrDeleted 应返回 true。 + /// + [Fact] + public void IsNullOrDeleted_WhenDeleted_ShouldReturnTrue() + { + // Arrange + var entity = new SoftDeleteEntity { IsDeleted = true }; + + // Act + var result = entity.IsNullOrDeleted(); + + // Assert + result.ShouldBeTrue(); + } + + /// + /// 测试目的:entity.IsDeleted=false 时,IsNullOrDeleted 应返回 false。 + /// + [Fact] + public void IsNullOrDeleted_WhenNotDeleted_ShouldReturnFalse() + { + // Arrange + var entity = new SoftDeleteEntity { IsDeleted = false }; + + // Act + var result = entity.IsNullOrDeleted(); + + // Assert + result.ShouldBeFalse(); + } + + // ── UnDelete(仅 ISoftDelete) ───────────────────────────────────────── + + /// + /// 测试目的:对已删除实体调用 UnDelete,IsDeleted 应变为 false。 + /// + [Fact] + public void UnDelete_WhenDeleted_ShouldSetIsDeletedFalse() + { + // Arrange + var entity = new SoftDeleteEntity { IsDeleted = true }; + + // Act + entity.UnDelete(); + + // Assert + entity.IsDeleted.ShouldBeFalse(); + } + + /// + /// 测试目的:对未删除实体调用 UnDelete,IsDeleted 应仍为 false(幂等)。 + /// + [Fact] + public void UnDelete_WhenAlreadyNotDeleted_ShouldRemainFalse() + { + // Arrange + var entity = new SoftDeleteEntity { IsDeleted = false }; + + // Act + entity.UnDelete(); + + // Assert + entity.IsDeleted.ShouldBeFalse(); + } + + // ── UnDelete(ISoftDelete + IDeletionAuditedObject) ────────────────── + + /// + /// 测试目的:实体同时实现 IDeletionAuditedObject 时,UnDelete 应将 DeletionTime 和 DeleterId 都置 null。 + /// + [Fact] + public void UnDelete_WithAuditedEntity_ShouldClearAuditFields() + { + // Arrange + var entity = new AuditedSoftDeleteEntity + { + IsDeleted = true, + DeleterId = Guid.NewGuid(), + DeletionTime = DateTime.UtcNow + }; + + // Act + entity.UnDelete(); + + // Assert + entity.IsDeleted.ShouldBeFalse(); + entity.DeleterId.ShouldBeNull(); + entity.DeletionTime.ShouldBeNull(); + } + + /// + /// 测试目的:IDeletionAuditedObject 字段本身已为 null 时,UnDelete 不抛异常(边界场景)。 + /// + [Fact] + public void UnDelete_WithAuditedEntity_WhenFieldsAlreadyNull_ShouldNotThrow() + { + // Arrange + var entity = new AuditedSoftDeleteEntity { IsDeleted = true, DeleterId = null, DeletionTime = null }; + + // Act & Assert + Should.NotThrow(() => entity.UnDelete()); + entity.IsDeleted.ShouldBeFalse(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 扩展方法对各类型 Key 列表的委托行为。 +/// +public class DomainExtensionsCompareTest +{ + // ── Compare IEnumerable ──────────────────────────────────────── + + /// + /// 测试目的:新集合新增一个 Guid 时,CreateList 应包含该 Guid,其余为空。 + /// + [Fact] + public void Compare_Guids_NewItem_ShouldBeInCreateList() + { + // Arrange + var id = Guid.NewGuid(); + var newList = new List { id }; + var oldList = new List(); + + // Act + var result = newList.Compare(oldList); + + // Assert + result.CreateList.ShouldContain(id); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + } + + /// + /// 测试目的:旧集合有而新集合无的 Guid,应在 DeleteList 中。 + /// + [Fact] + public void Compare_Guids_RemovedItem_ShouldBeInDeleteList() + { + // Arrange + var id = Guid.NewGuid(); + var newList = new List(); + var oldList = new List { id }; + + // Act + var result = newList.Compare(oldList); + + // Assert + result.DeleteList.ShouldContain(id); + result.CreateList.ShouldBeEmpty(); + } + + /// + /// 测试目的:新旧集合均包含的 Guid,应在 UpdateList 中。 + /// + [Fact] + public void Compare_Guids_CommonItem_ShouldBeInUpdateList() + { + // Arrange + var id = Guid.NewGuid(); + var newList = new List { id }; + var oldList = new List { id }; + + // Act + var result = newList.Compare(oldList); + + // Assert + result.UpdateList.ShouldContain(id); + } + + // ── Compare IEnumerable ─────────────────────────────────────── + + /// + /// 测试目的:字符串集合 Compare:新增项应进 CreateList。 + /// + [Fact] + public void Compare_Strings_NewItem_ShouldBeInCreateList() + { + // Arrange + var newList = new List { "a", "b" }; + var oldList = new List { "a" }; + + // Act + var result = newList.Compare(oldList); + + // Assert + result.CreateList.ShouldContain("b"); + result.UpdateList.ShouldContain("a"); + result.DeleteList.ShouldBeEmpty(); + } + + // ── Compare IEnumerable ────────────────────────────────────────── + + /// + /// 测试目的:int 集合 Compare:删除项应进 DeleteList。 + /// + [Fact] + public void Compare_Ints_RemovedItem_ShouldBeInDeleteList() + { + // Arrange + var newList = new List { 1 }; + var oldList = new List { 1, 2, 3 }; + + // Act + var result = newList.Compare(oldList); + + // Assert + result.DeleteList.ShouldContain(2); + result.DeleteList.ShouldContain(3); + } + + // ── Compare IEnumerable ───────────────────────────────────────── + + /// + /// 测试目的:long 集合 Compare:新旧均为空时三个列表均为空。 + /// + [Fact] + public void Compare_Longs_BothEmpty_ShouldReturnAllEmpty() + { + // Arrange + var newList = new List(); + var oldList = new List(); + + // Act + var result = newList.Compare(oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + } + + // ── Compare IEnumerable (IKey) ───────────────────────── + + /// + /// 测试目的:实体集合 Compare:新增实体应进 CreateList(通过默认 Guid Key 泛型重载)。 + /// + [Fact] + public void Compare_Entities_NewEntity_ShouldBeInCreateList() + { + // Arrange + var id = Guid.NewGuid(); + var newEntity = new AggregateRootSample(id) { Name = "new" }; + var newList = new List { newEntity }; + var oldList = new List(); + + // Act + var result = newList.Compare(oldList); + + // Assert + result.CreateList.ShouldContain(newEntity); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + } +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/ChangeTracking/ChangeTrackingTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/ChangeTracking/ChangeTrackingTest.cs new file mode 100644 index 00000000..da68f478 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/ChangeTracking/ChangeTrackingTest.cs @@ -0,0 +1,317 @@ +namespace Bing.Domain.ChangeTracking; + +/// +/// 变更跟踪 测试 +/// +public class ChangeTrackingTest +{ + #region ChangedValueDescriptor + + /// + /// 测试目的:验证变更值描述符的属性赋值正确。 + /// + [Fact] + public void ChangedValueDescriptor_Properties_ShouldSetCorrectly() + { + // Arrange & Act + var descriptor = new ChangedValueDescriptor("Name", "姓名", "Alice", "Bob"); + + // Assert + descriptor.PropertyName.ShouldBe("Name"); + descriptor.Description.ShouldBe("姓名"); + descriptor.OldValue.ShouldBe("Alice"); + descriptor.NewValue.ShouldBe("Bob"); + } + + /// + /// 测试目的:验证变更值描述符 ToString 包含属性名、描述、旧值、新值。 + /// + [Fact] + public void ChangedValueDescriptor_ToString_ShouldContainKeyInfo() + { + // Arrange + var descriptor = new ChangedValueDescriptor("Name", "姓名", "Alice", "Bob"); + + // Act + var result = descriptor.ToString(); + + // Assert + result.ShouldContain("Name"); + result.ShouldContain("姓名"); + result.ShouldContain("Alice"); + result.ShouldContain("Bob"); + } + + #endregion + + #region ChangedValueDescriptorCollection + + /// + /// 测试目的:新建集合应为空。 + /// + [Fact] + public void Collection_New_ShouldBeEmpty() + { + // Arrange & Act + var collection = new ChangedValueDescriptorCollection(); + + // Assert + collection.ShouldBeEmpty(); + } + + /// + /// 测试目的:向集合添加有效记录后,应可正常遍历到该记录。 + /// + [Fact] + public void Collection_Add_ShouldContainItem() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + + // Act + collection.Add("Name", "姓名", "Alice", "Bob"); + + // Assert + collection.Count().ShouldBe(1); + collection.First().PropertyName.ShouldBe("Name"); + collection.First().OldValue.ShouldBe("Alice"); + collection.First().NewValue.ShouldBe("Bob"); + } + + /// + /// 测试目的:属性名为空时,不应被添加到集合(4 参数重载)。 + /// + [Fact] + public void Collection_Add_EmptyPropertyName_ShouldNotAdd() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + + // Act + collection.Add("", "描述", "old", "new"); + collection.Add(" ", "描述", "old", "new"); + + // Assert + collection.ShouldBeEmpty(); + } + + /// + /// 测试目的:Descriptor 描述为空时(1 参数重载),不应被添加到集合。 + /// + [Fact] + public void Collection_AddDescriptor_EmptyDescription_ShouldNotAdd() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + var descriptor = new ChangedValueDescriptor("Name", "", "old", "new"); + + // Act + collection.Add(descriptor); + + // Assert + collection.ShouldBeEmpty(); + } + + /// + /// 测试目的:FlushCache 后集合应被清空。 + /// + [Fact] + public void Collection_FlushCache_ShouldClear() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + collection.Add("Name", "姓名", "Alice", "Bob"); + + // Act + collection.FlushCache(); + + // Assert + collection.ShouldBeEmpty(); + } + + /// + /// 测试目的:拷贝构造函数应将原集合的记录复制到新实例中。 + /// + [Fact] + public void Collection_CopyConstructor_ShouldCopyItems() + { + // Arrange + var original = new ChangedValueDescriptorCollection(); + original.Add("Name", "姓名", "Alice", "Bob"); + + // Act + var copy = new ChangedValueDescriptorCollection(original); + + // Assert + copy.Count().ShouldBe(1); + copy.First().PropertyName.ShouldBe("Name"); + } + + /// + /// 测试目的:空集合的 ToString 应返回空字符串。 + /// + [Fact] + public void Collection_ToString_Empty_ShouldReturnEmpty() + { + // Arrange & Act + var collection = new ChangedValueDescriptorCollection(); + + // Assert + collection.ToString().ShouldBeNullOrEmpty(); + } + + /// + /// 测试目的:有记录的集合,ToString 应包含变更信息。 + /// + [Fact] + public void Collection_ToString_WithItems_ShouldContainInfo() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + collection.Add("Name", "姓名", "Alice", "Bob"); + + // Act + var result = collection.ToString(); + + // Assert + result.ShouldContain("姓名"); + result.ShouldContain("Alice"); + result.ShouldContain("Bob"); + } + + #endregion + + #region ChangeTrackingContext + + /// + /// 测试目的:值相等时,不应添加变更记录。 + /// + [Fact] + public void Context_Add_EqualValues_ShouldNotTrack() + { + // Arrange + var ctx = new ChangeTrackingContext(); + + // Act + ctx.Add("Name", "姓名", "Alice", "Alice"); + + // Assert + ctx.GetChangedValueDescriptor().ShouldBeEmpty(); + } + + /// + /// 测试目的:值不同时,应添加变更记录,且旧值/新值经过 Trim + toLower 处理。 + /// + [Fact] + public void Context_Add_DifferentValues_ShouldTrack() + { + // Arrange + var ctx = new ChangeTrackingContext(); + + // Act + ctx.Add("Name", "姓名", "Alice", "Bob"); + + // Assert + var descriptors = ctx.GetChangedValueDescriptor().ToList(); + descriptors.Count.ShouldBe(1); + descriptors[0].PropertyName.ShouldBe("Name"); + descriptors[0].OldValue.ShouldBe("alice"); + descriptors[0].NewValue.ShouldBe("bob"); + } + + /// + /// 测试目的:旧值为 null、新值非空时,应记录变更。 + /// + [Fact] + public void Context_Add_NullToValue_ShouldTrack() + { + // Arrange + var ctx = new ChangeTrackingContext(); + + // Act + ctx.Add("Name", "姓名", null, "Bob"); + + // Assert + ctx.GetChangedValueDescriptor().Count().ShouldBe(1); + } + + /// + /// 测试目的:大小写不同但内容相同的值应视为相等,不应记录变更。 + /// + [Fact] + public void Context_Add_SameValueDifferentCase_ShouldNotTrack() + { + // Arrange + var ctx = new ChangeTrackingContext(); + + // Act + ctx.Add("Name", "姓名", "Alice", "ALICE"); + + // Assert + ctx.GetChangedValueDescriptor().ShouldBeEmpty(); + } + + /// + /// 测试目的:Output 应包含变更信息关键字段,ToString 与 Output 输出一致。 + /// + [Fact] + public void Context_Output_ShouldContainChangedInfo() + { + // Arrange + var ctx = new ChangeTrackingContext(); + ctx.Add("Name", "姓名", "Alice", "Bob"); + + // Act + var output = ctx.Output(); + + // Assert + output.ShouldContain("Name"); + output.ShouldContain("姓名"); + ctx.ToString().ShouldBe(output); + } + + /// + /// 测试目的:空变更上下文的 Output 应返回空字符串。 + /// + [Fact] + public void Context_Output_Empty_ShouldReturnEmpty() + { + // Arrange & Act + var ctx = new ChangeTrackingContext(); + + // Assert + ctx.Output().ShouldBeNullOrEmpty(); + } + + /// + /// 测试目的:通过 ChangedValueDescriptorCollection 构造的上下文,应包含已有的变更记录。 + /// + [Fact] + public void Context_CopyConstructor_ShouldCopyDescriptors() + { + // Arrange + var collection = new ChangedValueDescriptorCollection(); + collection.Add("Name", "姓名", "Alice", "Bob"); + + // Act + var ctx = new ChangeTrackingContext(collection); + + // Assert + ctx.GetChangedValueDescriptor().Count().ShouldBe(1); + } + + /// + /// 测试目的:null 集合作为构造参数,不应抛出异常,上下文应为空。 + /// + [Fact] + public void Context_NullCollection_ShouldNotThrow_AndBeEmpty() + { + // Arrange & Act + var ctx = new ChangeTrackingContext(null); + + // Assert + ctx.GetChangedValueDescriptor().ShouldBeEmpty(); + } + + #endregion +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/AggregateRootDomainEventsTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/AggregateRootDomainEventsTest.cs new file mode 100644 index 00000000..646d94c7 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/AggregateRootDomainEventsTest.cs @@ -0,0 +1,167 @@ +using Bing.Domain.Entities.Events; + +namespace Bing.Domain.Entities; + +/// +/// 聚合根 - 领域事件 测试 +/// +public class AggregateRootDomainEventsTest +{ + /// + /// 测试用领域事件样例 + /// + private class SampleDomainEvent : DomainEvent + { + /// 消息内容 + public string Message { get; } + + /// + /// 初始化 + /// + public SampleDomainEvent(string message) => Message = message; + } + + /// + /// 测试目的:新建聚合根的领域事件集合初始为 null(懒初始化,未触发任何 Add)。 + /// + [Fact] + public void GetDomainEvents_Initial_ShouldBeNull() + { + // Arrange + var sample = new AggregateRootSample(); + + // Act + var events = sample.GetDomainEvents(); + + // Assert + events.ShouldBeNull(); + } + + /// + /// 测试目的:添加一个领域事件后,集合应包含该事件且数量为 1。 + /// + [Fact] + public void AddDomainEvent_Single_ShouldBeInCollection() + { + // Arrange + var sample = new AggregateRootSample(); + var domainEvent = new SampleDomainEvent("created"); + + // Act + sample.AddDomainEvent(domainEvent); + + // Assert + var events = sample.GetDomainEvents(); + events.ShouldNotBeNull(); + events.Count.ShouldBe(1); + events.ShouldContain(domainEvent); + } + + /// + /// 测试目的:连续添加多个领域事件后,集合应按顺序包含所有事件。 + /// + [Fact] + public void AddDomainEvent_Multiple_ShouldContainAll() + { + // Arrange + var sample = new AggregateRootSample(); + var event1 = new SampleDomainEvent("event1"); + var event2 = new SampleDomainEvent("event2"); + + // Act + sample.AddDomainEvent(event1); + sample.AddDomainEvent(event2); + + // Assert + var events = sample.GetDomainEvents(); + events.Count.ShouldBe(2); + events.ElementAt(0).ShouldBe(event1); + events.ElementAt(1).ShouldBe(event2); + } + + /// + /// 测试目的:ClearDomainEvents 后集合应为空(不为 null,已初始化)。 + /// + [Fact] + public void ClearDomainEvents_AfterAdd_ShouldBeEmpty() + { + // Arrange + var sample = new AggregateRootSample(); + sample.AddDomainEvent(new SampleDomainEvent("created")); + + // Act + sample.ClearDomainEvents(); + + // Assert + sample.GetDomainEvents().ShouldBeEmpty(); + } + + /// + /// 测试目的:RemoveDomainEvent 应只移除指定事件,其他事件保持不变。 + /// + [Fact] + public void RemoveDomainEvent_ShouldOnlyRemoveSpecificEvent() + { + // Arrange + var sample = new AggregateRootSample(); + var event1 = new SampleDomainEvent("event1"); + var event2 = new SampleDomainEvent("event2"); + sample.AddDomainEvent(event1); + sample.AddDomainEvent(event2); + + // Act + sample.RemoveDomainEvent(event1); + + // Assert + var events = sample.GetDomainEvents(); + events.Count.ShouldBe(1); + events.ShouldContain(event2); + events.ShouldNotContain(event1); + } + + /// + /// 测试目的:未添加任何事件时调用 ClearDomainEvents 不应抛出异常。 + /// + [Fact] + public void ClearDomainEvents_WhenNoEvents_ShouldNotThrow() + { + // Arrange + var sample = new AggregateRootSample(); + + // Act & Assert + Should.NotThrow(() => sample.ClearDomainEvents()); + } + + /// + /// 测试目的:未添加任何事件时调用 RemoveDomainEvent 不应抛出异常。 + /// + [Fact] + public void RemoveDomainEvent_WhenNoEvents_ShouldNotThrow() + { + // Arrange + var sample = new AggregateRootSample(); + var domainEvent = new SampleDomainEvent("event"); + + // Act & Assert + Should.NotThrow(() => sample.RemoveDomainEvent(domainEvent)); + } + + /// + /// 测试目的:允许向同一聚合根重复添加相同实例的领域事件(无去重)。 + /// + [Fact] + public void AddDomainEvent_SameInstance_AllowsDuplicates() + { + // Arrange + var sample = new AggregateRootSample(); + var domainEvent = new SampleDomainEvent("event"); + + // Act + sample.AddDomainEvent(domainEvent); + sample.AddDomainEvent(domainEvent); + + // Assert + var events = sample.GetDomainEvents(); + events.Count.ShouldBe(2); + } +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DescriptionContextTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DescriptionContextTest.cs new file mode 100644 index 00000000..14ed9ac8 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DescriptionContextTest.cs @@ -0,0 +1,276 @@ +using Bing.Domain.Entities; + +namespace Bing.Domain.Entities; + +/// +/// 描述上下文 测试 +/// +public class DescriptionContextTest +{ + private readonly DescriptionContext _context; + + /// + /// 初始化 + /// + public DescriptionContextTest() + { + _context = new DescriptionContext(); + } + + #region Output - 空上下文 + + /// + /// 测试目的:未添加任何描述时,Output 应返回空字符串。 + /// + [Fact] + public void Output_Empty_ShouldReturnEmptyString() + { + // Act + var result = _context.Output(); + + // Assert + result.ShouldBe(string.Empty); + } + + #endregion + + #region Add(string) + + /// + /// 测试目的:Add(null) 不应添加任何内容,Output 仍为空。 + /// + [Fact] + public void Add_NullString_ShouldIgnore() + { + // Act + _context.Add((string)null); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:Add(空白字符串) 不应添加任何内容,Output 仍为空。 + /// + [Fact] + public void Add_WhitespaceString_ShouldIgnore() + { + // Act + _context.Add(" "); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:Add(有效描述) 应将描述追加到输出中。 + /// + [Fact] + public void Add_ValidString_ShouldAppendToOutput() + { + // Act + _context.Add("Hello"); + + // Assert + _context.Output().ShouldBe("Hello"); + } + + /// + /// 测试目的:多次 Add(string) 应将所有描述顺序拼接。 + /// + [Fact] + public void Add_MultipleStrings_ShouldConcatenateInOrder() + { + // Act + _context.Add("Foo"); + _context.Add("Bar"); + + // Assert + _context.Output().ShouldBe("FooBar"); + } + + #endregion + + #region Add(string name, TValue value) + + /// + /// 测试目的:name 为 null 时应忽略,不影响输出。 + /// + [Fact] + public void Add_NameNull_ShouldIgnore() + { + // Act + _context.Add(null, "value"); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:name 为空白时应忽略,不影响输出。 + /// + [Fact] + public void Add_NameWhitespace_ShouldIgnore() + { + // Act + _context.Add(" ", "value"); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:value 为 null 时应忽略,不影响输出。 + /// + [Fact] + public void Add_ValueNull_ShouldIgnore() + { + // Act + _context.Add("Name", null); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:value 为 int 默认值(0)时应忽略,不影响输出。 + /// + [Fact] + public void Add_IntDefaultValue_ShouldIgnore() + { + // Act + _context.Add("Age", 0); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:name 和 value 均有效时,应以 "name:value," 格式追加,末尾逗号在 Output 时被剪除。 + /// + [Fact] + public void Add_ValidNameAndValue_ShouldFormatAsNameColonValue() + { + // Act + _context.Add("Name", "Alice"); + + // Assert + _context.Output().ShouldBe("Name:Alice"); + } + + /// + /// 测试目的:name 含前后空格时,格式化结果中 name 应被 Trim。 + /// + [Fact] + public void Add_NameWithWhitespacePadding_ShouldTrimName() + { + // Act + _context.Add(" Name ", "Alice"); + + // Assert + _context.Output().ShouldBe("Name:Alice"); + } + + /// + /// 测试目的:连续添加多个键值对,Output 应包含所有键值,末尾逗号被裁去。 + /// + [Fact] + public void Add_MultipleNameValues_ShouldJoinWithComma() + { + // Act + _context.Add("Name", "Alice"); + _context.Add("Age", 30); + + // Assert + var output = _context.Output(); + output.ShouldContain("Name:Alice"); + output.ShouldContain("Age:30"); + output.ShouldEndWith("30"); // 末尾没有多余逗号 + } + + #endregion + + #region FlushCache + + /// + /// 测试目的:FlushCache 后 Output 应返回空字符串。 + /// + [Fact] + public void FlushCache_AfterAdd_ShouldClearOutput() + { + // Arrange + _context.Add("Name", "Alice"); + + // Act + _context.FlushCache(); + + // Assert + _context.Output().ShouldBe(string.Empty); + } + + /// + /// 测试目的:FlushCache 后可以重新添加内容,Output 仅含新内容。 + /// + [Fact] + public void FlushCache_ThenAdd_ShouldOnlyContainNewContent() + { + // Arrange + _context.Add("OldKey", "OldValue"); + _context.FlushCache(); + + // Act + _context.Add("NewKey", "NewValue"); + + // Assert + var output = _context.Output(); + output.ShouldNotContain("OldKey"); + output.ShouldContain("NewKey:NewValue"); + } + + #endregion + + #region ToString + + /// + /// 测试目的:ToString 应与 Output 返回相同的字符串。 + /// + [Fact] + public void ToString_ShouldReturnSameAsOutput() + { + // Arrange + _context.Add("X", "Y"); + + // Act & Assert + _context.ToString().ShouldBe(_context.Output()); + } + + /// + /// 测试目的:空上下文时 ToString 应返回空字符串。 + /// + [Fact] + public void ToString_Empty_ShouldReturnEmptyString() + { + // Act & Assert + _context.ToString().ShouldBe(string.Empty); + } + + #endregion + + #region Output - 末尾逗号裁剪 + + /// + /// 测试目的:单个键值对添加后,Output 末尾不应有逗号。 + /// + [Fact] + public void Output_SingleNameValue_ShouldNotEndWithComma() + { + // Act + _context.Add("Key", "Value"); + + // Assert + _context.Output().ShouldEndWith("Value"); + _context.Output().ShouldNotEndWith(","); + } + + #endregion +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DomainObjectBaseTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DomainObjectBaseTest.cs new file mode 100644 index 00000000..d4fe7804 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/DomainObjectBaseTest.cs @@ -0,0 +1,176 @@ +using Bing.Tests.Samples; + +namespace Bing.Domain.Entities; + +/// +/// 测试目的:验证 DomainObjectBase.ToString() / GetChanges() 与 AddDescriptions / AddChanges 的联动行为。 +/// 通过 AggregateRootSample(测试项目内已有的具体实现)进行黑盒验证。 +/// +public class DomainObjectBaseTest +{ + // ===================================================================== + // ToString — 依赖 AddDescriptions / DescriptionContext + // ===================================================================== + + /// + /// 测试目的:新建的聚合根未设置任何字段时,ToString 不应抛异常,结果可以为空。 + /// + [Fact] + public void ToString_EmptyEntity_ShouldNotThrow() + { + // Arrange + var entity = new AggregateRootSample(); + + // Act & Assert + Should.NotThrow(() => entity.ToString()); + } + + /// + /// 测试目的:设置 Name 字段后,ToString 应包含 Id 和姓名信息(AggregateRootSample.AddDescriptions 实现)。 + /// + [Fact] + public void ToString_WithNameSet_ShouldContainIdAndName() + { + // Arrange + var id = Guid.NewGuid(); + var entity = new AggregateRootSample(id) { Name = "Alice", EnglishName = "alice" }; + + // Act + var result = entity.ToString(); + + // Assert + result.ShouldContain(id.ToString()); + result.ShouldContain("Alice"); + } + + /// + /// 测试目的:多次调用 ToString,每次均应重新填充描述(FlushCache + AddDescriptions),结果一致。 + /// + [Fact] + public void ToString_CalledTwice_ShouldReturnConsistentResult() + { + // Arrange + var entity = new AggregateRootSample(Guid.NewGuid()) { Name = "Bob", EnglishName = "bob" }; + + // Act + var first = entity.ToString(); + var second = entity.ToString(); + + // Assert + first.ShouldBe(second); + } + + /// + /// 测试目的:Name 变更后,再次调用 ToString 应反映新值而非旧值。 + /// + [Fact] + public void ToString_AfterNameChange_ShouldReflectNewName() + { + // Arrange + var entity = new AggregateRootSample(Guid.NewGuid()) { Name = "Old", EnglishName = "old" }; + _ = entity.ToString(); // 第一次调用 + + // Act + entity.Name = "New"; + var result = entity.ToString(); + + // Assert + result.ShouldContain("New"); + result.ShouldNotContain("Old"); + } + + // ===================================================================== + // GetChanges — 依赖 AddChanges / ChangeTrackingContext + // ===================================================================== + + /// + /// 测试目的:两个属性全相同时,GetChanges 应返回空集合(无变更)。 + /// + [Fact] + public void GetChanges_SameValues_ShouldReturnEmpty() + { + // Arrange + var id = Guid.NewGuid(); + var original = AggregateRootSample.CreateSample(id); + var other = AggregateRootSample.CreateSample(id); + + // Act + var changes = original.GetChanges(other); + + // Assert + changes.ShouldBeEmpty(); + } + + /// + /// 测试目的:Name 不同时,GetChanges 应包含 "姓名" 这条变更记录。 + /// + [Fact] + public void GetChanges_NameChanged_ShouldContainNameChange() + { + // Arrange + var id = Guid.NewGuid(); + var original = AggregateRootSample.CreateSample(id); // Name = "TestName" + var updated = AggregateRootSample.CreateSample2(id); // Name = "TestName2" + + // Act + var changes = original.GetChanges(updated); + + // Assert + changes.ShouldNotBeEmpty(); + changes.Any(c => c.PropertyName == "Name").ShouldBeTrue(); + } + + /// + /// 测试目的:GetChanges 传入 null 时应返回空集合,不抛异常。 + /// + [Fact] + public void GetChanges_NullOther_ShouldReturnEmpty() + { + // Arrange + var original = AggregateRootSample.CreateSample(); + + // Act & Assert + var changes = Should.NotThrow(() => original.GetChanges(null)); + changes.ShouldBeEmpty(); + } + + /// + /// 测试目的:多次调用 GetChanges 应返回一致结果(FlushCache 保证每次重置)。 + /// + [Fact] + public void GetChanges_CalledTwice_ShouldReturnConsistentCount() + { + // Arrange + var id = Guid.NewGuid(); + var original = AggregateRootSample.CreateSample(id); + var updated = AggregateRootSample.CreateSample2(id); + + // Act + var first = original.GetChanges(updated).Count(); + var second = original.GetChanges(updated).Count(); + + // Assert + first.ShouldBe(second); + } + + /// + /// 测试目的:GetChanges 旧值/新值应经过 toLower + Trim 处理(ChangeTrackingContext.Add 规范)。 + /// + [Fact] + public void GetChanges_ValuesAreLowercasedAndTrimmed() + { + // Arrange + var id = Guid.NewGuid(); + var original = AggregateRootSample.CreateSample(id); // Name = "TestName" + var updated = AggregateRootSample.CreateSample2(id); // Name = "TestName2" + + // Act + var changes = original.GetChanges(updated).ToList(); + var nameChange = changes.FirstOrDefault(c => c.PropertyName == "Name"); + + // Assert + nameChange.ShouldNotBeNull(); + nameChange.OldValue.ShouldBe("testname"); + nameChange.NewValue.ShouldBe("testname2"); + } +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/EntityHelperTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/EntityHelperTest.cs new file mode 100644 index 00000000..5f386893 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/EntityHelperTest.cs @@ -0,0 +1,385 @@ +namespace Bing.Domain.Entities; + +/// +/// 实体帮助类 测试 +/// +public class EntityHelperTest +{ + #region 类型检查 + + /// + /// 测试目的:IsEntity - 实体类型应返回 true。 + /// + [Fact] + public void IsEntity_EntityType_ShouldReturnTrue() + { + // Arrange & Act & Assert + EntityHelper.IsEntity(typeof(AggregateRootSample)).ShouldBeTrue(); + EntityHelper.IsEntity(typeof(IntAggregateRootSample)).ShouldBeTrue(); + } + + /// + /// 测试目的:IsEntity - 非实体类型应返回 false。 + /// + [Fact] + public void IsEntity_NonEntityType_ShouldReturnFalse() + { + // Arrange & Act & Assert + EntityHelper.IsEntity(typeof(string)).ShouldBeFalse(); + EntityHelper.IsEntity(typeof(int)).ShouldBeFalse(); + EntityHelper.IsEntity(typeof(object)).ShouldBeFalse(); + } + + /// + /// 测试目的:IsEntity - type 为 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void IsEntity_NullType_ShouldThrowArgumentNullException() + { + // Arrange & Act & Assert + Should.Throw(() => EntityHelper.IsEntity(null)); + } + + /// + /// 测试目的:IsEntityWithId - 带 ID 的实体类型应返回 true,且输出正确的 keyType。 + /// + [Fact] + public void IsEntityWithId_EntityWithGuidId_ShouldReturnTrueAndKeyType() + { + // Arrange & Act + var result = EntityHelper.IsEntityWithId(typeof(AggregateRootSample), out var keyType); + + // Assert + result.ShouldBeTrue(); + keyType.ShouldBe(typeof(Guid)); + } + + /// + /// 测试目的:IsEntityWithId - int 主键实体应返回 true,且 keyType 为 int。 + /// + [Fact] + public void IsEntityWithId_EntityWithIntId_ShouldReturnIntKeyType() + { + // Arrange & Act + var result = EntityHelper.IsEntityWithId(typeof(IntAggregateRootSample), out var keyType); + + // Assert + result.ShouldBeTrue(); + keyType.ShouldBe(typeof(int)); + } + + /// + /// 测试目的:IsEntityWithId(type) 单参数重载 - 非实体类型应返回 false。 + /// + [Fact] + public void IsEntityWithId_NonEntityType_ShouldReturnFalse() + { + // Arrange & Act & Assert + EntityHelper.IsEntityWithId(typeof(string)).ShouldBeFalse(); + } + + /// + /// 测试目的:CheckEntity - 非实体类型应抛出 ArgumentException。 + /// + [Fact] + public void CheckEntity_NonEntityType_ShouldThrowArgumentException() + { + // Arrange & Act & Assert + Should.Throw(() => EntityHelper.CheckEntity(typeof(string))); + } + + /// + /// 测试目的:CheckEntity - 实体类型应不抛出异常。 + /// + [Fact] + public void CheckEntity_EntityType_ShouldNotThrow() + { + // Arrange & Act & Assert + Should.NotThrow(() => EntityHelper.CheckEntity(typeof(AggregateRootSample))); + } + + #endregion + + #region 主键类型查找 + + /// + /// 测试目的:FindPrimaryKeyType - Guid 主键实体应返回 typeof(Guid)。 + /// + [Fact] + public void FindPrimaryKeyType_GuidEntity_ShouldReturnGuidType() + { + // Arrange & Act + var keyType = EntityHelper.FindPrimaryKeyType(); + + // Assert + keyType.ShouldBe(typeof(Guid)); + } + + /// + /// 测试目的:FindPrimaryKeyType - int 主键实体应返回 typeof(int)。 + /// + [Fact] + public void FindPrimaryKeyType_IntEntity_ShouldReturnIntType() + { + // Arrange & Act + var keyType = EntityHelper.FindPrimaryKeyType(); + + // Assert + keyType.ShouldBe(typeof(int)); + } + + #endregion + + #region ID 生成 + + /// + /// 测试目的:CreateGuid 应返回非空 Guid,且每次调用结果不同。 + /// + [Fact] + public void CreateGuid_ShouldReturnNonEmptyGuid() + { + // Arrange & Act + var id1 = EntityHelper.CreateGuid(); + var id2 = EntityHelper.CreateGuid(); + + // Assert + id1.ShouldNotBe(Guid.Empty); + id2.ShouldNotBe(Guid.Empty); + id1.ShouldNotBe(id2); + } + + /// + /// 测试目的:CreateKey<Guid> 应返回非空 Guid。 + /// + [Fact] + public void CreateKey_Guid_ShouldReturnNonEmpty() + { + // Arrange & Act + var id = EntityHelper.CreateKey(); + + // Assert + id.ShouldNotBe(Guid.Empty); + } + + /// + /// 测试目的:CreateKey<string> 应返回非空字符串(默认为 Guid 字符串)。 + /// + [Fact] + public void CreateKey_String_ShouldReturnNonEmpty() + { + // Arrange & Act + var id = EntityHelper.CreateKey(); + + // Assert + id.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// 测试目的:RegisterIdGenerator 传入 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void RegisterIdGenerator_NullGenerator_ShouldThrowArgumentNullException() + { + // Arrange & Act & Assert + Should.Throw(() => + EntityHelper.RegisterIdGenerator(null)); + } + + /// + /// 测试目的:更改 GuidGenerateFunc 后,CreateGuid 应使用新的生成函数。 + /// + [Fact] + public void GuidGenerateFunc_Custom_ShouldBeUsedByCreateGuid() + { + // Arrange + var fixedId = new Guid("11111111-1111-1111-1111-111111111111"); + var original = EntityHelper.GuidGenerateFunc; + try + { + EntityHelper.GuidGenerateFunc = () => fixedId; + + // Act + var id = EntityHelper.CreateGuid(); + + // Assert + id.ShouldBe(fixedId); + } + finally + { + // 恢复原始生成函数,避免影响其他测试 + EntityHelper.GuidGenerateFunc = original; + } + } + + #endregion + + #region 主键检查 + + /// + /// 测试目的:HasDefaultId - Guid.Empty 的实体应返回 true(视为默认值/瞬时对象)。 + /// + [Fact] + public void HasDefaultId_GuidEmpty_ShouldReturnTrue() + { + // Arrange + var sample = new AggregateRootSample(Guid.Empty); + + // Act & Assert + EntityHelper.HasDefaultId(sample).ShouldBeTrue(); + } + + /// + /// 测试目的:HasDefaultId - 非空 Guid 实体应返回 false。 + /// + [Fact] + public void HasDefaultId_NonEmptyGuid_ShouldReturnFalse() + { + // Arrange + var sample = new AggregateRootSample(Guid.NewGuid()); + + // Act & Assert + EntityHelper.HasDefaultId(sample).ShouldBeFalse(); + } + + /// + /// 测试目的:HasDefaultId - int 默认值(0)的实体应返回 true。 + /// + [Fact] + public void HasDefaultId_IntDefault_ShouldReturnTrue() + { + // Arrange + var sample = new IntAggregateRootSample(0); + + // Act & Assert + EntityHelper.HasDefaultId(sample).ShouldBeTrue(); + } + + /// + /// 测试目的:HasDefaultId - 负数 int ID 应返回 true(数值 ≤ 0 视为默认值)。 + /// + [Fact] + public void HasDefaultId_NegativeInt_ShouldReturnTrue() + { + // Arrange + var sample = new IntAggregateRootSample(-1); + + // Act & Assert + EntityHelper.HasDefaultId(sample).ShouldBeTrue(); + } + + /// + /// 测试目的:HasDefaultId - 正数 int ID 应返回 false。 + /// + [Fact] + public void HasDefaultId_PositiveInt_ShouldReturnFalse() + { + // Arrange + var sample = new IntAggregateRootSample(1); + + // Act & Assert + EntityHelper.HasDefaultId(sample).ShouldBeFalse(); + } + + /// + /// 测试目的:HasDefaultKeys - 实体键为 Guid.Empty 时应返回 true。 + /// + [Fact] + public void HasDefaultKeys_GuidEmpty_ShouldReturnTrue() + { + // Arrange + var sample = new AggregateRootSample(Guid.Empty); + + // Act & Assert + EntityHelper.HasDefaultKeys(sample).ShouldBeTrue(); + } + + /// + /// 测试目的:HasDefaultKeys - 实体键为有效 Guid 时应返回 false。 + /// + [Fact] + public void HasDefaultKeys_NonEmptyGuid_ShouldReturnFalse() + { + // Arrange + var sample = new AggregateRootSample(Guid.NewGuid()); + + // Act & Assert + EntityHelper.HasDefaultKeys(sample).ShouldBeFalse(); + } + + #endregion + + #region 实体相等性 + + /// + /// 测试目的:EntityEquals - 任意参数为 null 时均返回 false。 + /// + [Fact] + public void EntityEquals_NullArgument_ShouldReturnFalse() + { + // Arrange + var sample = new AggregateRootSample(Guid.NewGuid()); + + // Act & Assert + EntityHelper.EntityEquals(sample, null).ShouldBeFalse(); + EntityHelper.EntityEquals(null, sample).ShouldBeFalse(); + EntityHelper.EntityEquals(null, null).ShouldBeFalse(); + } + + /// + /// 测试目的:EntityEquals - 同一引用应返回 true。 + /// + [Fact] + public void EntityEquals_SameReference_ShouldReturnTrue() + { + // Arrange + var sample = new AggregateRootSample(Guid.NewGuid()); + + // Act & Assert + EntityHelper.EntityEquals(sample, sample).ShouldBeTrue(); + } + + /// + /// 测试目的:EntityEquals - 相同 ID 的不同实例应返回 true。 + /// + [Fact] + public void EntityEquals_SameId_DifferentInstance_ShouldReturnTrue() + { + // Arrange + var id = Guid.NewGuid(); + var sample1 = new AggregateRootSample(id); + var sample2 = new AggregateRootSample(id); + + // Act & Assert + EntityHelper.EntityEquals(sample1, sample2).ShouldBeTrue(); + } + + /// + /// 测试目的:EntityEquals - 不同 ID 的实例应返回 false。 + /// + [Fact] + public void EntityEquals_DifferentId_ShouldReturnFalse() + { + // Arrange + var sample1 = new AggregateRootSample(Guid.NewGuid()); + var sample2 = new AggregateRootSample(Guid.NewGuid()); + + // Act & Assert + EntityHelper.EntityEquals(sample1, sample2).ShouldBeFalse(); + } + + /// + /// 测试目的:EntityEquals - 两个实体 ID 均为默认值(瞬时对象)时应返回 false。 + /// + [Fact] + public void EntityEquals_BothDefaultId_ShouldReturnFalse() + { + // Arrange + var sample1 = new AggregateRootSample(Guid.Empty); + var sample2 = new AggregateRootSample(Guid.Empty); + + // Act & Assert + EntityHelper.EntityEquals(sample1, sample2).ShouldBeFalse(); + } + + #endregion +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/KeyListComparatorTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/KeyListComparatorTest.cs new file mode 100644 index 00000000..bc917e06 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/KeyListComparatorTest.cs @@ -0,0 +1,217 @@ +using Bing.Domain.Entities; + +namespace Bing.Domain.Entities; + +/// +/// 键列表比较器 测试 +/// +public class KeyListComparatorTest +{ + private readonly KeyListComparator _comparator; + + /// + /// 初始化 + /// + public KeyListComparatorTest() + { + _comparator = new KeyListComparator(); + } + + #region 参数校验 + + /// + /// 测试目的:newList 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Compare_NullNewList_ShouldThrowArgumentNullException() + { + // Arrange + var oldList = new List { 1, 2 }; + + // Act & Assert + Should.Throw(() => _comparator.Compare(null, oldList)); + } + + /// + /// 测试目的:oldList 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Compare_NullOldList_ShouldThrowArgumentNullException() + { + // Arrange + var newList = new List { 1, 2 }; + + // Act & Assert + Should.Throw(() => _comparator.Compare(newList, null)); + } + + #endregion + + #region 空集合场景 + + /// + /// 测试目的:新旧集合均为空时,三个结果集合均应为空。 + /// + [Fact] + public void Compare_BothEmpty_ShouldReturnEmptyLists() + { + // Arrange & Act + var result = _comparator.Compare(new List(), new List()); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + } + + #endregion + + #region 全新增场景 + + /// + /// 测试目的:旧集合为空时,新集合中所有键均应进入 CreateList。 + /// + [Fact] + public void Compare_AllNew_ShouldAllGoToCreateList() + { + // Arrange + var newList = new List { 1, 2, 3 }; + var oldList = new List(); + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.Count.ShouldBe(3); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + result.CreateList.ShouldContain(1); + result.CreateList.ShouldContain(2); + result.CreateList.ShouldContain(3); + } + + #endregion + + #region 全删除场景 + + /// + /// 测试目的:新集合为空时,旧集合中所有键均应进入 DeleteList。 + /// + [Fact] + public void Compare_AllRemoved_ShouldAllGoToDeleteList() + { + // Arrange + var newList = new List(); + var oldList = new List { 10, 20 }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.Count.ShouldBe(2); + result.DeleteList.ShouldContain(10); + result.DeleteList.ShouldContain(20); + } + + #endregion + + #region 全更新场景 + + /// + /// 测试目的:新旧集合包含相同键时,全部应进入 UpdateList。 + /// + [Fact] + public void Compare_SameKeys_ShouldAllGoToUpdateList() + { + // Arrange + var newList = new List { 1, 2 }; + var oldList = new List { 1, 2 }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.Count.ShouldBe(2); + result.DeleteList.ShouldBeEmpty(); + result.UpdateList.ShouldContain(1); + result.UpdateList.ShouldContain(2); + } + + #endregion + + #region 混合场景 + + /// + /// 测试目的:混合场景下,新增、更新、删除的键应正确分拣到三个列表。 + /// + [Fact] + public void Compare_Mixed_ShouldSplitIntoCorrectLists() + { + // Arrange + // 1 只在旧 → 删除;2 新旧都有 → 更新;3 只在新 → 新增 + var newList = new List { 2, 3 }; + var oldList = new List { 1, 2 }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.Count.ShouldBe(1); + result.CreateList.ShouldContain(3); + + result.UpdateList.Count.ShouldBe(1); + result.UpdateList.ShouldContain(2); + + result.DeleteList.Count.ShouldBe(1); + result.DeleteList.ShouldContain(1); + } + + /// + /// 测试目的:string 类型键的混合比较,验证泛型通用性。 + /// + [Fact] + public void Compare_StringKeys_Mixed_ShouldSplitCorrectly() + { + // Arrange + var comparator = new KeyListComparator(); + var newList = new List { "B", "C" }; + var oldList = new List { "A", "B" }; + + // Act + var result = comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldContain("C"); + result.UpdateList.ShouldContain("B"); + result.DeleteList.ShouldContain("A"); + } + + #endregion + + #region 结果对象属性 + + /// + /// 测试目的:KeyListCompareResult 的三个属性应按构造顺序保持引用。 + /// + [Fact] + public void KeyListCompareResult_Properties_ShouldMatchConstructorArguments() + { + // Arrange + var createList = new List { 1 }; + var updateList = new List { 2 }; + var deleteList = new List { 3 }; + + // Act + var result = new KeyListCompareResult(createList, updateList, deleteList); + + // Assert + result.CreateList.ShouldBeSameAs(createList); + result.UpdateList.ShouldBeSameAs(updateList); + result.DeleteList.ShouldBeSameAs(deleteList); + } + + #endregion +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/ListComparatorTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/ListComparatorTest.cs new file mode 100644 index 00000000..68159fe3 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Entities/ListComparatorTest.cs @@ -0,0 +1,232 @@ +using Bing.Domain.Entities; + +namespace Bing.Domain.Entities; + +/// +/// 实体列表比较器 测试 +/// +public class ListComparatorTest +{ + private readonly ListComparator _comparator; + + /// + /// 初始化 + /// + public ListComparatorTest() + { + _comparator = new ListComparator(); + } + + #region 参数校验 + + /// + /// 测试目的:newList 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Compare_NullNewList_ShouldThrowArgumentNullException() + { + // Arrange + var oldList = new List(); + + // Act & Assert + Should.Throw(() => _comparator.Compare(null, oldList)); + } + + /// + /// 测试目的:oldList 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Compare_NullOldList_ShouldThrowArgumentNullException() + { + // Arrange + var newList = new List(); + + // Act & Assert + Should.Throw(() => _comparator.Compare(newList, null)); + } + + #endregion + + #region 空集合场景 + + /// + /// 测试目的:新旧集合均为空时,三个结果集合均应为空。 + /// + [Fact] + public void Compare_BothEmpty_ShouldReturnEmptyLists() + { + // Arrange + var newList = new List(); + var oldList = new List(); + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + } + + #endregion + + #region 全新增场景 + + /// + /// 测试目的:旧集合为空、新集合有元素时,全部应进入 CreateList。 + /// + [Fact] + public void Compare_AllNew_ShouldAllGoToCreateList() + { + // Arrange + var a = new IntAggregateRootSample(1) { Name = "A" }; + var b = new IntAggregateRootSample(2) { Name = "B" }; + var newList = new List { a, b }; + var oldList = new List(); + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.Count.ShouldBe(2); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.ShouldBeEmpty(); + result.CreateList.ShouldContain(a); + result.CreateList.ShouldContain(b); + } + + #endregion + + #region 全删除场景 + + /// + /// 测试目的:新集合为空、旧集合有元素时,全部应进入 DeleteList。 + /// + [Fact] + public void Compare_AllRemoved_ShouldAllGoToDeleteList() + { + // Arrange + var a = new IntAggregateRootSample(1) { Name = "A" }; + var b = new IntAggregateRootSample(2) { Name = "B" }; + var newList = new List(); + var oldList = new List { a, b }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + result.DeleteList.Count.ShouldBe(2); + result.DeleteList.ShouldContain(a); + result.DeleteList.ShouldContain(b); + } + + #endregion + + #region 全更新场景 + + /// + /// 测试目的:新旧集合包含相同 ID 元素时,全部应进入 UpdateList。 + /// + [Fact] + public void Compare_SameIds_ShouldAllGoToUpdateList() + { + // Arrange + var oldA = new IntAggregateRootSample(1) { Name = "OldA" }; + var oldB = new IntAggregateRootSample(2) { Name = "OldB" }; + var newA = new IntAggregateRootSample(1) { Name = "NewA" }; + var newB = new IntAggregateRootSample(2) { Name = "NewB" }; + var newList = new List { newA, newB }; + var oldList = new List { oldA, oldB }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.ShouldBeEmpty(); + result.UpdateList.Count.ShouldBe(2); + result.DeleteList.ShouldBeEmpty(); + } + + #endregion + + #region 混合场景 + + /// + /// 测试目的:新旧集合各有新增、更新、删除时,应正确分拣到三个列表。 + /// + [Fact] + public void Compare_Mixed_ShouldSplitIntoCorrectLists() + { + // Arrange + // ID=1 只在旧集合 → 删除 + // ID=2 在新旧集合 → 更新 + // ID=3 只在新集合 → 新增 + var oldItem1 = new IntAggregateRootSample(1) { Name = "Old1" }; + var oldItem2 = new IntAggregateRootSample(2) { Name = "Old2" }; + var newItem2 = new IntAggregateRootSample(2) { Name = "New2" }; + var newItem3 = new IntAggregateRootSample(3) { Name = "New3" }; + var newList = new List { newItem2, newItem3 }; + var oldList = new List { oldItem1, oldItem2 }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.CreateList.Count.ShouldBe(1); + result.CreateList.ShouldContain(newItem3); + + result.UpdateList.Count.ShouldBe(1); + result.UpdateList.First().Id.ShouldBe(2); + + result.DeleteList.Count.ShouldBe(1); + result.DeleteList.ShouldContain(oldItem1); + } + + /// + /// 测试目的:单个元素从旧列表移除时,仅应出现在 DeleteList。 + /// + [Fact] + public void Compare_SingleDelete_ShouldOnlyAppearInDeleteList() + { + // Arrange + var item = new IntAggregateRootSample(10) { Name = "X" }; + var newList = new List(); + var oldList = new List { item }; + + // Act + var result = _comparator.Compare(newList, oldList); + + // Assert + result.DeleteList.ShouldContain(item); + result.CreateList.ShouldBeEmpty(); + result.UpdateList.ShouldBeEmpty(); + } + + #endregion + + #region 结果对象属性 + + /// + /// 测试目的:ListCompareResult 三个属性在构造后应按传入顺序保持引用。 + /// + [Fact] + public void ListCompareResult_Properties_ShouldMatchConstructorArguments() + { + // Arrange + var createList = new List { new IntAggregateRootSample(1) }; + var updateList = new List { new IntAggregateRootSample(2) }; + var deleteList = new List { new IntAggregateRootSample(3) }; + + // Act + var result = new ListCompareResult(createList, updateList, deleteList); + + // Assert + result.CreateList.ShouldBeSameAs(createList); + result.UpdateList.ShouldBeSameAs(updateList); + result.DeleteList.ShouldBeSameAs(deleteList); + } + + #endregion +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Services/ParameterBaseTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Services/ParameterBaseTest.cs new file mode 100644 index 00000000..9a8d9d33 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Domain/Services/ParameterBaseTest.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Domain.Services; +using Bing.Exceptions; +using Shouldly; +using Xunit; + +namespace Bing.Domain.Services; + +// ========================================================================= +// ParameterBase Tests +// ========================================================================= + +/// +/// 测试目的:验证 ParameterBase.Validate() 在通过/失败两种情况下的行为。 +/// +public class ParameterBaseTest +{ + // ----- 具体实现 ----- + + /// 合法参数样例:Name 必填,已满足 + private class ValidParam : ParameterBase + { + [Required(ErrorMessage = "Name 不能为空")] + public string Name { get; set; } = "Alice"; + } + + /// 非法参数样例:Name 必填,但为空 + private class InvalidParam : ParameterBase + { + [Required(ErrorMessage = "Name 不能为空")] + public string Name { get; set; } + } + + /// 无约束参数样例:无 DataAnnotation,始终合法 + private class UnconstrainedParam : ParameterBase + { + public string Remark { get; set; } + } + + // ----- 测试 ----- + + /// + /// 测试目的:参数满足所有 DataAnnotation 约束时,Validate 应返回成功结果,不抛异常。 + /// + [Fact] + public void Validate_ValidParam_ShouldReturnSuccess() + { + // Arrange + var param = new ValidParam(); + + // Act & Assert + var result = Should.NotThrow(() => param.Validate()); + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:参数违反 [Required] 约束时,Validate 应抛出 Warning 异常,包含约束消息。 + /// + [Fact] + public void Validate_InvalidParam_ShouldThrowWarning() + { + // Arrange + var param = new InvalidParam(); // Name == null + + // Act & Assert + var ex = Should.Throw(() => param.Validate()); + ex.Message.ShouldContain("Name 不能为空"); + } + + /// + /// 测试目的:无任何约束的参数,Validate 应返回成功,不抛异常。 + /// + [Fact] + public void Validate_NoConstraints_ShouldReturnSuccess() + { + // Arrange + var param = new UnconstrainedParam { Remark = "test" }; + + // Act & Assert + var result = Should.NotThrow(() => param.Validate()); + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:可重写 Validate 方法,派生类自定义验证逻辑可正常工作。 + /// + [Fact] + public void Validate_OverriddenValidate_ShouldWorkCorrectly() + { + // Arrange: 重写 Validate 直接返回 Success + var param = new CustomValidatedParam { Name = null }; + + // Act & Assert — 不抛异常(自定义实现跳过校验) + var result = Should.NotThrow(() => param.Validate()); + result.IsValid.ShouldBeTrue(); + } + + /// 自定义验证参数:直接绕过 DataAnnotation + private class CustomValidatedParam : ParameterBase + { + [Required] + public string Name { get; set; } + + public override Bing.Validation.IValidationResult Validate() + => Bing.Validation.ValidationResultCollection.Success; + } +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Tests/Samples/TreeEntitySample.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Tests/Samples/TreeEntitySample.cs new file mode 100644 index 00000000..1ee73358 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Tests/Samples/TreeEntitySample.cs @@ -0,0 +1,25 @@ +using Bing.Domain.Entities; +using Bing.Trees; +using Bing.Validation; + +namespace Bing.Tests.Samples; + +/// +/// 树型实体测试样本(Guid 标识 + Guid? 父标识) +/// +public class TreeEntitySample : TreeEntityBase +{ + public TreeEntitySample() : this(Guid.NewGuid()) { } + + public TreeEntitySample(Guid id) : base(id, string.Empty, 1) { } + + /// + /// 名称 + /// + public string Name { get; set; } + + /// + /// 验证 — 空实现,仅供测试使用 + /// + public override IEnumerable Verify() => Enumerable.Empty(); +} diff --git a/framework/tests/Bing.Ddd.Domain.Tests/Bing/Trees/TreeEntityBaseAndExtensionsTest.cs b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Trees/TreeEntityBaseAndExtensionsTest.cs new file mode 100644 index 00000000..83459a63 --- /dev/null +++ b/framework/tests/Bing.Ddd.Domain.Tests/Bing/Trees/TreeEntityBaseAndExtensionsTest.cs @@ -0,0 +1,318 @@ +using Bing.Domain.Entities; +using Bing.Tests.Samples; +using Bing.Trees; + +namespace Bing.Trees; + +/// +/// 测试目的:验证 中 InitPath / GetParentIdsFromPath 核心逻辑。 +/// +public class TreeEntityBaseTest +{ + // ── InitPath(无父节点 / 根节点) ──────────────────────────────── + + /// + /// 测试目的:根节点(parent=null)初始化后 Level 应为 1,Path 应为 "{Id},"。 + /// + [Fact] + public void InitPath_WithNullParent_ShouldSetLevelOneAndSelfPath() + { + // Arrange + var id = Guid.Parse("11111111-0000-0000-0000-000000000000"); + var entity = new TreeEntitySample(id); + + // Act + entity.InitPath(); // 调用无参重载(parent=default) + + // Assert + entity.Level.ShouldBe(1); + entity.Path.ShouldBe($"{id},"); + } + + /// + /// 测试目的:有父节点时,Level 应为父级 +1,Path 应在父路径后追加自身 Id。 + /// + [Fact] + public void InitPath_WithParent_ShouldIncrementLevelAndAppendPath() + { + // Arrange + var parentId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var childId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + var parent = new TreeEntitySample(parentId); + parent.InitPath(); // root → Level=1, Path="aaaaa...," + + var child = new TreeEntitySample(childId); + + // Act + child.InitPath(parent); + + // Assert + child.Level.ShouldBe(2); + child.Path.ShouldBe($"{parentId},{childId},"); + } + + /// + /// 测试目的:三层嵌套时,深孙节点的 Level 应为 3,Path 应包含祖父、父、自身 Id。 + /// + [Fact] + public void InitPath_ThreeLevels_ShouldBuildCorrectPath() + { + // Arrange + var id1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var id2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var id3 = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + var root = new TreeEntitySample(id1); + root.InitPath(); + + var child = new TreeEntitySample(id2); + child.InitPath(root); + + var grandchild = new TreeEntitySample(id3); + grandchild.InitPath(child); + + // Assert + grandchild.Level.ShouldBe(3); + grandchild.Path.ShouldBe($"{id1},{id2},{id3},"); + } + + // ── GetParentIdsFromPath ──────────────────────────────────────── + + /// + /// 测试目的:Path 为 null 或空时 GetParentIdsFromPath 应返回空列表,不抛异常。 + /// + [Fact] + public void GetParentIdsFromPath_WhenPathEmpty_ShouldReturnEmptyList() + { + // Arrange + var entity = new TreeEntitySample(); + // Path 默认为 string.Empty(构造中传入) + + // Act + var result = entity.GetParentIdsFromPath(); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + /// + /// 测试目的:排除自身(默认),只应返回父级的 Id,不含自身。 + /// + [Fact] + public void GetParentIdsFromPath_ExcludeSelf_ShouldReturnOnlyParentIds() + { + // Arrange + var parentId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var childId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + var parent = new TreeEntitySample(parentId); + parent.InitPath(); + + var child = new TreeEntitySample(childId); + child.InitPath(parent); + + // Act + var parentIds = child.GetParentIdsFromPath(); + + // Assert + parentIds.Count.ShouldBe(1); + parentIds[0].ShouldBe(parentId); + } + + /// + /// 测试目的:包含自身(excludeSelf=false),应返回路径上所有节点 Id(含自身)。 + /// + [Fact] + public void GetParentIdsFromPath_IncludeSelf_ShouldReturnAllIdsInPath() + { + // Arrange + var parentId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var childId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + var parent = new TreeEntitySample(parentId); + parent.InitPath(); + + var child = new TreeEntitySample(childId); + child.InitPath(parent); + + // Act + var allIds = child.GetParentIdsFromPath(excludeSelf: false); + + // Assert + allIds.Count.ShouldBe(2); + allIds.ShouldContain(parentId); + allIds.ShouldContain(childId); + } + + /// + /// 测试目的:根节点调用 GetParentIdsFromPath(excludeSelf=true),应返回空列表(无父级)。 + /// + [Fact] + public void GetParentIdsFromPath_RootNode_ExcludeSelf_ShouldReturnEmpty() + { + // Arrange + var id = Guid.Parse("11111111-0000-0000-0000-000000000000"); + var root = new TreeEntitySample(id); + root.InitPath(); + + // Act + var parentIds = root.GetParentIdsFromPath(); + + // Assert + parentIds.ShouldBeEmpty(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 工具方法。 +/// +public class TreeExtensionsTest +{ + // ── SwapSort ────────────────────────────────────────────────────────── + + /// + /// 测试目的:SwapSort 应将两个实体的 SortId 互换。 + /// + [Fact] + public void SwapSort_ShouldExchangeSortIds() + { + // Arrange + var a = new TreeEntitySample { SortId = 10 }; + var b = new TreeEntitySample { SortId = 20 }; + + // Act + a.SwapSort(b); + + // Assert + a.SortId.ShouldBe(20); + b.SortId.ShouldBe(10); + } + + /// + /// 测试目的:两个实体 SortId 相同时,SwapSort 不改变值(交换结果相同)。 + /// + [Fact] + public void SwapSort_WhenSortIdsEqual_ShouldResultInSameValues() + { + // Arrange + var a = new TreeEntitySample { SortId = 5 }; + var b = new TreeEntitySample { SortId = 5 }; + + // Act + a.SwapSort(b); + + // Assert + a.SortId.ShouldBe(5); + b.SortId.ShouldBe(5); + } + + /// + /// 测试目的:SortId 为 null 时,SwapSort 应能处理,不抛异常。 + /// + [Fact] + public void SwapSort_WithNullSortIds_ShouldNotThrow() + { + // Arrange + var a = new TreeEntitySample { SortId = null }; + var b = new TreeEntitySample { SortId = null }; + + // Act & Assert + Should.NotThrow(() => a.SwapSort(b)); + a.SortId.ShouldBeNull(); + b.SortId.ShouldBeNull(); + } + + // ── GetMissingParentIds ─────────────────────────────────────────────── + + /// + /// 测试目的:所有父级 Id 均存在于实体列表中时,GetMissingParentIds 应返回空列表。 + /// + [Fact] + public void GetMissingParentIds_WhenAllParentsExist_ShouldReturnEmpty() + { + // Arrange + var parentId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var childId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + var parent = new TreeEntitySample(parentId); + parent.InitPath(); + + var child = new TreeEntitySample(childId); + child.InitPath(parent); + + var entities = new List { parent, child }; + + // Act + var missing = entities.GetMissingParentIds(); + + // Assert + missing.ShouldBeEmpty(); + } + + /// + /// 测试目的:父节点不在列表中时,GetMissingParentIds 应返回缺失的父 Id。 + /// + [Fact] + public void GetMissingParentIds_WhenParentMissing_ShouldReturnMissingId() + { + // Arrange + var parentId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var childId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + // 只构建 child,不将 parent 加入列表 + var parent = new TreeEntitySample(parentId); + parent.InitPath(); + + var child = new TreeEntitySample(childId); + child.InitPath(parent); + + var entities = new List { child }; + + // Act + var missing = entities.GetMissingParentIds(); + + // Assert + missing.Count.ShouldBe(1); + missing[0].ShouldBe(parentId.ToString()); + } + + /// + /// 测试目的:传入 null 列表时,GetMissingParentIds 应返回空列表,不抛异常。 + /// + [Fact] + public void GetMissingParentIds_WithNullList_ShouldReturnEmpty() + { + // Act + var missing = ((IEnumerable)null) + .GetMissingParentIds(); + + // Assert + missing.ShouldNotBeNull(); + missing.ShouldBeEmpty(); + } + + /// + /// 测试目的:根节点列表(均无父节点)时,GetMissingParentIds 应返回空列表。 + /// + [Fact] + public void GetMissingParentIds_WithRootNodesOnly_ShouldReturnEmpty() + { + // Arrange + var r1 = new TreeEntitySample(Guid.NewGuid()); + r1.InitPath(); + var r2 = new TreeEntitySample(Guid.NewGuid()); + r2.InitPath(); + + var entities = new List { r1, r2 }; + + // Act + var missing = entities.GetMissingParentIds(); + + // Assert + missing.ShouldBeEmpty(); + } +} diff --git a/framework/tests/Bing.EventBus.Tests/EventBusCoreModelsTest.cs b/framework/tests/Bing.EventBus.Tests/EventBusCoreModelsTest.cs new file mode 100644 index 00000000..9e5c4cc7 --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/EventBusCoreModelsTest.cs @@ -0,0 +1,346 @@ +using System.Threading.Tasks; +using Bing.EventBus; +using Bing.EventBus.Local; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.EventBus.Tests; + +/// +/// 测试。 +/// 验证构造函数传参与属性读取的正确性。 +/// +public class LocalEventMessageTest +{ + /// + /// 测试目的:构造后三个属性应与传入参数引用相等(无拷贝)。 + /// + [Fact] + public void Constructor_ShouldSetAllProperties() + { + // Arrange + var eventId = "evt-001"; + var eventData = new object(); + var eventType = typeof(string); + + // Act + var msg = new LocalEventMessage(eventId, eventData, eventType); + + // Assert + msg.EventId.ShouldBe(eventId); + msg.EventData.ShouldBeSameAs(eventData); + msg.EventType.ShouldBe(eventType); + } + + /// + /// 测试目的:EventId 可为 null,不应在构造时抛异常。 + /// + [Fact] + public void Constructor_WithNullEventId_ShouldNotThrow() + { + // Act & Assert + Should.NotThrow(() => new LocalEventMessage(null, new object(), typeof(int))); + } + + /// + /// 测试目的:EventData 可为 null,适用于空事件载荷场景。 + /// + [Fact] + public void Constructor_WithNullEventData_ShouldNotThrow() + { + // Arrange & Act + var msg = new LocalEventMessage("id", null, typeof(object)); + + // Assert + msg.EventData.ShouldBeNull(); + } + + /// + /// 测试目的:EventType 应精确存储传入的 Type,支持任意类型。 + /// + [Theory] + [InlineData(typeof(int))] + [InlineData(typeof(string))] + [InlineData(typeof(LocalEventMessage))] + public void EventType_ShouldMatchConstructorArg(Type type) + { + // Arrange & Act + var msg = new LocalEventMessage("x", null, type); + + // Assert + msg.EventType.ShouldBe(type); + } +} + +/// +/// 测试。 +/// 验证 Handlers 集合的初始化状态。 +/// +public class LocalEventBusOptionsTest +{ + /// + /// 测试目的:默认构造后 Handlers 不为 null,无需外部初始化即可直接使用。 + /// + [Fact] + public void Constructor_Handlers_ShouldNotBeNull() + { + // Arrange & Act + var options = new LocalEventBusOptions(); + + // Assert + options.Handlers.ShouldNotBeNull(); + } + + /// + /// 测试目的:默认构造后 Handlers 应为空集合。 + /// + [Fact] + public void Constructor_Handlers_ShouldBeEmpty() + { + // Arrange & Act + var options = new LocalEventBusOptions(); + + // Assert + options.Handlers.Count.ShouldBe(0); + } + + /// + /// 测试目的:可向 Handlers 添加处理器类型,Count 增加。 + /// + [Fact] + public void Handlers_CanAddHandlerType() + { + // Arrange + var options = new LocalEventBusOptions(); + + // Act + options.Handlers.Add(); + + // Assert + options.Handlers.Count.ShouldBe(1); + } + + /// + /// 辅助:用于测试注册的事件处理器 + /// + private class TestEventHandler : IEventHandler { } +} + +/// +/// 测试。 +/// 验证 Action 属性赋值及 HandleAsync 委托行为。 +/// +public class ActionEventHandlerTest +{ + /// + /// 测试目的:构造后 Action 属性应引用传入的委托。 + /// + [Fact] + public void Constructor_Action_ShouldReferenceInjectedDelegate() + { + // Arrange + Func handler = _ => Task.CompletedTask; + + // Act + var sut = new ActionEventHandler(handler); + + // Assert + sut.Action.ShouldBeSameAs(handler); + } + + /// + /// 测试目的:HandleAsync 应调用 Action 并传递事件数据。 + /// + [Fact] + public async Task HandleAsync_ShouldInvokeActionWithEventData() + { + // Arrange + string received = null; + var sut = new ActionEventHandler(evt => + { + received = evt; + return Task.CompletedTask; + }); + + // Act + await sut.HandleAsync("hello"); + + // Assert + received.ShouldBe("hello"); + } + + /// + /// 测试目的:HandleAsync 应等待异步 Action 完成后再返回。 + /// + [Fact] + public async Task HandleAsync_ShouldAwaitAsyncAction() + { + // Arrange + var completed = false; + var sut = new ActionEventHandler(async _ => + { + await Task.Yield(); + completed = true; + }); + + // Act + await sut.HandleAsync(42); + + // Assert + completed.ShouldBeTrue(); + } + + /// + /// 测试目的:Action 抛出异常时,HandleAsync 应将异常向上传播。 + /// + [Fact] + public async Task HandleAsync_WhenActionThrows_ShouldPropagateException() + { + // Arrange + var sut = new ActionEventHandler(_ => throw new InvalidOperationException("boom")); + + // Act & Assert + await Should.ThrowAsync(() => sut.HandleAsync(0)); + } +} + +/// +/// 测试。 +/// 验证 EventHandler 属性引用及 Dispose 的委托行为。 +/// +public class EventHandlerDisposeWrapperTest +{ + /// + /// 测试目的:EventHandler 属性应引用构造时传入的处理器。 + /// + [Fact] + public void EventHandler_ShouldReferenceInjectedHandler() + { + // Arrange + var mockHandler = new Mock(); + var wrapper = new EventHandlerDisposeWrapper(mockHandler.Object); + + // Act & Assert + wrapper.EventHandler.ShouldBeSameAs(mockHandler.Object); + } + + /// + /// 测试目的:Dispose 应调用传入的 disposeAction 委托一次。 + /// + [Fact] + public void Dispose_WithDisposeAction_ShouldInvokeActionOnce() + { + // Arrange + var callCount = 0; + var mockHandler = new Mock(); + var wrapper = new EventHandlerDisposeWrapper(mockHandler.Object, () => callCount++); + + // Act + wrapper.Dispose(); + + // Assert + callCount.ShouldBe(1); + } + + /// + /// 测试目的:Dispose 在 disposeAction 为 null 时不应抛出异常。 + /// + [Fact] + public void Dispose_WithNullDisposeAction_ShouldNotThrow() + { + // Arrange + var mockHandler = new Mock(); + var wrapper = new EventHandlerDisposeWrapper(mockHandler.Object, null); + + // Act & Assert + Should.NotThrow(() => wrapper.Dispose()); + } + + /// + /// 测试目的:多次 Dispose 时,disposeAction 应被调用多次(无幂等保护)。 + /// + [Fact] + public void Dispose_CalledTwice_ShouldInvokeActionTwice() + { + // Arrange + var callCount = 0; + var mockHandler = new Mock(); + var wrapper = new EventHandlerDisposeWrapper(mockHandler.Object, () => callCount++); + + // Act + wrapper.Dispose(); + wrapper.Dispose(); + + // Assert + callCount.ShouldBe(2); + } +} + +/// +/// 测试。 +/// 验证 Dispose 时调用 IEventBus.Unsubscribe 并传递正确参数。 +/// +public class EventHandlerFactoryUnregistrarTest +{ + /// + /// 测试目的:Dispose 应调用 IEventBus.Unsubscribe,参数与构造时一致。 + /// + [Fact] + public void Dispose_ShouldCallUnsubscribeWithCorrectArguments() + { + // Arrange + var mockBus = new Mock(); + var mockFactory = new Mock(); + var eventType = typeof(string); + var unregistrar = new EventHandlerFactoryUnregistrar(mockBus.Object, eventType, mockFactory.Object); + + // Act + unregistrar.Dispose(); + + // Assert + mockBus.Verify(b => b.Unsubscribe(eventType, mockFactory.Object), Times.Once); + } + + /// + /// 测试目的:Dispose 调用时传递的 eventType 应精确匹配。 + /// + [Fact] + public void Dispose_ShouldPassExactEventTypeToUnsubscribe() + { + // Arrange + var mockBus = new Mock(); + var mockFactory = new Mock(); + Type capturedType = null; + mockBus.Setup(b => b.Unsubscribe(It.IsAny(), It.IsAny())) + .Callback((t, _) => capturedType = t); + + var unregistrar = new EventHandlerFactoryUnregistrar(mockBus.Object, typeof(int), mockFactory.Object); + + // Act + unregistrar.Dispose(); + + // Assert + capturedType.ShouldBe(typeof(int)); + } + + /// + /// 测试目的:using 语句结束时应自动调用 Unsubscribe,验证与 using 的协作。 + /// + [Fact] + public void Dispose_ViaUsingStatement_ShouldCallUnsubscribe() + { + // Arrange + var mockBus = new Mock(); + var mockFactory = new Mock(); + + // Act + using (new EventHandlerFactoryUnregistrar(mockBus.Object, typeof(bool), mockFactory.Object)) + { + // 使用范围结束即触发 Dispose + } + + // Assert + mockBus.Verify(b => b.Unsubscribe(typeof(bool), mockFactory.Object), Times.Once); + } +} diff --git a/framework/tests/Bing.EventBus.Tests/EventBusFactoryAndNullBusTest.cs b/framework/tests/Bing.EventBus.Tests/EventBusFactoryAndNullBusTest.cs new file mode 100644 index 00000000..5cd83760 --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/EventBusFactoryAndNullBusTest.cs @@ -0,0 +1,376 @@ +using Bing.EventBus; +using Bing.EventBus.Local; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.EventBus.Tests; + +/// +/// 单元测试 +/// 验证空本地事件总线的所有方法均为空操作:不抛异常,Subscribe 返回 NullDisposable +/// +public class NullLocalEventBusTest +{ + private readonly ILocalEventBus _bus = NullLocalEventBus.Instance; + + /// + /// 测试目的:NullLocalEventBus.Instance 为静态单例,不应为 null。 + /// + [Fact] + public void Instance_ShouldNotBeNull() + { + NullLocalEventBus.Instance.ShouldNotBeNull(); + } + + /// + /// 测试目的:多次访问 Instance 应返回同一引用(单例语义)。 + /// + [Fact] + public void Instance_ShouldBeSameReference() + { + NullLocalEventBus.Instance.ShouldBeSameAs(NullLocalEventBus.Instance); + } + + /// + /// 测试目的:PublishAsync<TEvent> 应返回 CompletedTask,不抛异常。 + /// + [Fact] + public async Task PublishAsync_Generic_ShouldCompleteWithoutThrowing() + { + await Should.NotThrowAsync(() => _bus.PublishAsync(new SampleEvent())); + } + + /// + /// 测试目的:PublishAsync(Type, object) 应返回 CompletedTask,不抛异常。 + /// + [Fact] + public async Task PublishAsync_ByType_ShouldCompleteWithoutThrowing() + { + await Should.NotThrowAsync(() => _bus.PublishAsync(typeof(SampleEvent), new SampleEvent())); + } + + /// + /// 测试目的:Subscribe(Func) 应返回非 null 的 IDisposable,且 Dispose 不抛异常。 + /// + [Fact] + public void Subscribe_WithAction_ShouldReturnDisposable() + { + var disposable = _bus.Subscribe(_ => Task.CompletedTask); + disposable.ShouldNotBeNull(); + Should.NotThrow(() => disposable.Dispose()); + } + + /// + /// 测试目的:Subscribe<TEvent>(ILocalEventHandler) 应返回非 null 的 IDisposable。 + /// + [Fact] + public void Subscribe_WithHandler_ShouldReturnDisposable() + { + var mockHandler = new Mock>(); + var disposable = _bus.Subscribe(mockHandler.Object); + disposable.ShouldNotBeNull(); + } + + /// + /// 测试目的:Subscribe<TEvent, THandler>() 应返回非 null 的 IDisposable。 + /// + [Fact] + public void Subscribe_GenericHandler_ShouldReturnDisposable() + { + var disposable = _bus.Subscribe(); + disposable.ShouldNotBeNull(); + } + + /// + /// 测试目的:Subscribe(Type, IEventHandler) 应返回非 null 的 IDisposable。 + /// + [Fact] + public void Subscribe_ByType_ShouldReturnDisposable() + { + var mockHandler = new Mock(); + var disposable = _bus.Subscribe(typeof(SampleEvent), mockHandler.Object); + disposable.ShouldNotBeNull(); + } + + /// + /// 测试目的:Subscribe(Type, IEventHandlerFactory) 应返回非 null 的 IDisposable。 + /// + [Fact] + public void Subscribe_ByTypeAndFactory_ShouldReturnDisposable() + { + var mockFactory = new Mock(); + var disposable = _bus.Subscribe(typeof(SampleEvent), mockFactory.Object); + disposable.ShouldNotBeNull(); + } + + /// + /// 测试目的:所有 Unsubscribe 重载均不应抛异常(空操作)。 + /// + [Fact] + public void Unsubscribe_AllOverloads_ShouldNotThrow() + { + var mockHandler = new Mock>(); + var mockEventHandler = new Mock(); + var mockFactory = new Mock(); + + Should.NotThrow(() => _bus.Unsubscribe(_ => Task.CompletedTask)); + Should.NotThrow(() => _bus.Unsubscribe(mockHandler.Object)); + Should.NotThrow(() => _bus.Unsubscribe(typeof(SampleEvent), mockEventHandler.Object)); + Should.NotThrow(() => _bus.Unsubscribe(mockFactory.Object)); + Should.NotThrow(() => _bus.Unsubscribe(typeof(SampleEvent), mockFactory.Object)); + } + + /// + /// 测试目的:所有 UnsubscribeAll 重载均不应抛异常(空操作)。 + /// + [Fact] + public void UnsubscribeAll_AllOverloads_ShouldNotThrow() + { + Should.NotThrow(() => _bus.UnsubscribeAll()); + Should.NotThrow(() => _bus.UnsubscribeAll(typeof(SampleEvent))); + } +} + +/// +/// 单元测试 +/// 验证单例工厂每次 GetHandler 都包装同一个 handler 实例;IsInFactories 判断逻辑 +/// +public class SingleInstanceHandlerFactoryTest +{ + private readonly Mock _mockHandler = new(); + + /// + /// 测试目的:HandlerInstance 应与构造时传入的 handler 引用相等。 + /// + [Fact] + public void HandlerInstance_ShouldBeTheSameAsConstructorArg() + { + var factory = new SingleInstanceHandlerFactory(_mockHandler.Object); + factory.HandlerInstance.ShouldBeSameAs(_mockHandler.Object); + } + + /// + /// 测试目的:GetHandler 应返回包含 HandlerInstance 的 IEventHandlerDisposeWrapper,不为 null。 + /// + [Fact] + public void GetHandler_ShouldReturnWrapperContainingHandlerInstance() + { + var factory = new SingleInstanceHandlerFactory(_mockHandler.Object); + using var wrapper = factory.GetHandler(); + wrapper.ShouldNotBeNull(); + wrapper.EventHandler.ShouldBeSameAs(_mockHandler.Object); + } + + /// + /// 测试目的:IsInFactories 对包含相同 handler 实例的工厂列表,应返回 true。 + /// + [Fact] + public void IsInFactories_WhenSameHandlerInList_ShouldReturnTrue() + { + var factory = new SingleInstanceHandlerFactory(_mockHandler.Object); + var list = new List { factory }; + factory.IsInFactories(list).ShouldBeTrue(); + } + + /// + /// 测试目的:IsInFactories 对包含不同 handler 实例的工厂列表,应返回 false。 + /// + [Fact] + public void IsInFactories_WhenDifferentHandlerInList_ShouldReturnFalse() + { + var factory = new SingleInstanceHandlerFactory(_mockHandler.Object); + var other = new SingleInstanceHandlerFactory(new Mock().Object); + var list = new List { other }; + factory.IsInFactories(list).ShouldBeFalse(); + } + + /// + /// 测试目的:IsInFactories 对空列表,应返回 false。 + /// + [Fact] + public void IsInFactories_EmptyList_ShouldReturnFalse() + { + var factory = new SingleInstanceHandlerFactory(_mockHandler.Object); + factory.IsInFactories(new List()).ShouldBeFalse(); + } +} + +/// +/// 单元测试 +/// 验证瞬时工厂每次 GetHandler 创建新实例;IsInFactories 按类型匹配 +/// +public class TransientEventHandlerFactoryTest +{ + /// + /// 测试目的:HandlerType 应与构造时传入的类型一致。 + /// + [Fact] + public void HandlerType_ShouldMatchConstructorArg() + { + var factory = new TransientEventHandlerFactory(typeof(SampleEventHandler)); + factory.HandlerType.ShouldBe(typeof(SampleEventHandler)); + } + + /// + /// 测试目的:GetHandler 应返回非 null 的 IEventHandlerDisposeWrapper, + /// 内部 EventHandler 应为 SampleEventHandler 实例。 + /// + [Fact] + public void GetHandler_ShouldReturnWrapperWithNewHandlerInstance() + { + var factory = new TransientEventHandlerFactory(typeof(SampleEventHandler)); + using var wrapper = factory.GetHandler(); + wrapper.ShouldNotBeNull(); + wrapper.EventHandler.ShouldBeOfType(); + } + + /// + /// 测试目的:两次 GetHandler 应返回不同的 handler 实例(Transient 语义)。 + /// + [Fact] + public void GetHandler_CalledTwice_ShouldReturnDifferentInstances() + { + var factory = new TransientEventHandlerFactory(typeof(SampleEventHandler)); + using var w1 = factory.GetHandler(); + using var w2 = factory.GetHandler(); + w1.EventHandler.ShouldNotBeSameAs(w2.EventHandler); + } + + /// + /// 测试目的:泛型工厂 TransientEventHandlerFactory<THandler> 的 GetHandler 应创建 THandler 实例。 + /// + [Fact] + public void GenericFactory_GetHandler_ShouldReturnCorrectHandlerType() + { + var factory = new TransientEventHandlerFactory(); + using var wrapper = factory.GetHandler(); + wrapper.EventHandler.ShouldBeOfType(); + } + + /// + /// 测试目的:IsInFactories 对包含相同 HandlerType 的工厂列表,应返回 true。 + /// + [Fact] + public void IsInFactories_WhenSameHandlerTypeInList_ShouldReturnTrue() + { + var factory = new TransientEventHandlerFactory(typeof(SampleEventHandler)); + var list = new List { factory }; + factory.IsInFactories(list).ShouldBeTrue(); + } + + /// + /// 测试目的:IsInFactories 对包含不同 HandlerType 的工厂列表,应返回 false。 + /// + [Fact] + public void IsInFactories_WhenDifferentHandlerTypeInList_ShouldReturnFalse() + { + var factory = new TransientEventHandlerFactory(typeof(SampleEventHandler)); + var other = new TransientEventHandlerFactory(typeof(OtherEventHandler)); + var list = new List { other }; + factory.IsInFactories(list).ShouldBeFalse(); + } +} + +/// +/// 单元测试 +/// 验证默认值、属性赋值、ToString 输出格式 +/// +public class MessageEventTest +{ + /// + /// 测试目的:构造后 Id 应自动生成,不为 null/empty。 + /// + [Fact] + public void Constructor_ShouldAutoGenerateId() + { + var evt = new MessageEvent(); + evt.Id.ShouldNotBeNullOrEmpty(); + } + + /// + /// 测试目的:构造后 Time 应接近当前时间(误差在 2 秒内)。 + /// + [Fact] + public void Constructor_ShouldSetTimeToNow() + { + var before = DateTime.Now.AddSeconds(-1); + var evt = new MessageEvent(); + var after = DateTime.Now.AddSeconds(1); + evt.Time.ShouldBeInRange(before, after); + } + + /// + /// 测试目的:两次构造的 Id 应不同(基于 Guid.NewGuid)。 + /// + [Fact] + public void Constructor_TwoInstances_ShouldHaveDifferentIds() + { + var e1 = new MessageEvent(); + var e2 = new MessageEvent(); + e1.Id.ShouldNotBe(e2.Id); + } + + /// + /// 测试目的:Name/Data/Callback/Send 属性应可正常读写。 + /// + [Fact] + public void Properties_ShouldBeReadWritable() + { + var evt = new MessageEvent + { + Name = "TestEvent", + Data = new { Key = "value" }, + Callback = "callback-name", + Send = true + }; + evt.Name.ShouldBe("TestEvent"); + evt.Callback.ShouldBe("callback-name"); + evt.Send.ShouldBeTrue(); + } + + /// + /// 测试目的:ToString 应包含 Id 和时间信息,不抛异常。 + /// + [Fact] + public void ToString_ShouldContainIdAndTime() + { + var evt = new MessageEvent { Id = "test-id-123" }; + var str = evt.ToString(); + str.ShouldContain("test-id-123"); + } + + /// + /// 测试目的:ToString 在设置 Name 时应包含 Name 信息。 + /// + [Fact] + public void ToString_WithName_ShouldContainName() + { + var evt = new MessageEvent { Name = "my-event" }; + evt.ToString().ShouldContain("my-event"); + } + + /// + /// 测试目的:ToString 在 Name 为 null/空时不包含消息名称行,不抛异常。 + /// + [Fact] + public void ToString_WithoutName_ShouldNotThrow() + { + var evt = new MessageEvent { Name = null }; + Should.NotThrow(() => evt.ToString()); + } +} + +// ─── 测试辅助类型 ────────────────────────────────────────────────────────────── + +internal class SampleEvent { } + +internal class SampleEventHandler : IEventHandler, ILocalEventHandler +{ + public Task HandleAsync(SampleEvent eventData) => Task.CompletedTask; +} + +internal class OtherEventHandler : IEventHandler, ILocalEventHandler +{ + public Task HandleAsync(SampleEvent eventData) => Task.CompletedTask; +} diff --git a/framework/tests/Bing.EventBus.Tests/EventHandlerFactoryTest.cs b/framework/tests/Bing.EventBus.Tests/EventHandlerFactoryTest.cs new file mode 100644 index 00000000..db77218b --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/EventHandlerFactoryTest.cs @@ -0,0 +1,319 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Bing.EventBus; +using Bing.EventBus.Local; +using Shouldly; +using Xunit; + +namespace Bing.EventBus.Tests; + +// ─── 辅助 Stub ─────────────────────────────────────────────────────────────── + +/// +/// 最简事件处理器 Stub(仅实现标记接口) +/// +internal class StubEventHandler : IEventHandler { } + +/// +/// 可创建的瞬时处理器 Stub(满足 new() 约束) +/// +internal class NewableStubHandler : IEventHandler { } + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// ActionEventHandler、SingleInstanceHandlerFactory、TransientEventHandlerFactory、 +/// EventHandlerDisposeWrapper 单元测试 +/// +public class EventHandlerFactoryTest +{ + // ════════════════════════════════════════════════════════════════ + // ActionEventHandler + // ════════════════════════════════════════════════════════════════ + + private class SimpleEvent { public string Value { get; set; } } + + /// + /// 测试目的:构造时传入 Func,Action 属性应保存该委托。 + /// + [Fact] + public void ActionEventHandler_Action_ShouldBeStoredDelegate() + { + // Arrange + Func func = _ => Task.CompletedTask; + + // Act + var handler = new ActionEventHandler(func); + + // Assert + handler.Action.ShouldBeSameAs(func); + } + + /// + /// 测试目的:HandleAsync 应调用传入的委托,并将事件数据正确传递给委托。 + /// + [Fact] + public async Task ActionEventHandler_HandleAsync_ShouldInvokeAction() + { + // Arrange + SimpleEvent received = null; + var handler = new ActionEventHandler(evt => + { + received = evt; + return Task.CompletedTask; + }); + var eventData = new SimpleEvent { Value = "hello" }; + + // Act + await handler.HandleAsync(eventData); + + // Assert + received.ShouldBeSameAs(eventData); + received.Value.ShouldBe("hello"); + } + + /// + /// 测试目的:HandleAsync 应等待异步委托完成,而不是 fire-and-forget。 + /// + [Fact] + public async Task ActionEventHandler_HandleAsync_ShouldAwaitAsyncAction() + { + // Arrange + var completed = false; + var handler = new ActionEventHandler(async _ => + { + await Task.Yield(); + completed = true; + }); + + // Act + await handler.HandleAsync(new SimpleEvent()); + + // Assert + completed.ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // EventHandlerDisposeWrapper + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造后 EventHandler 属性应保持传入的处理器引用。 + /// + [Fact] + public void EventHandlerDisposeWrapper_EventHandler_ShouldMatchConstructorArg() + { + // Arrange + var handler = new StubEventHandler(); + + // Act + var wrapper = new EventHandlerDisposeWrapper(handler); + + // Assert + wrapper.EventHandler.ShouldBeSameAs(handler); + } + + /// + /// 测试目的:不传 disposeAction 时 Dispose 不应抛异常(null 安全)。 + /// + [Fact] + public void EventHandlerDisposeWrapper_Dispose_WithNoAction_ShouldNotThrow() + { + // Arrange + var wrapper = new EventHandlerDisposeWrapper(new StubEventHandler()); + + // Act & Assert + Should.NotThrow(() => wrapper.Dispose()); + } + + /// + /// 测试目的:传入 disposeAction 时 Dispose 应调用该委托。 + /// + [Fact] + public void EventHandlerDisposeWrapper_Dispose_WithAction_ShouldInvokeAction() + { + // Arrange + var disposed = false; + var wrapper = new EventHandlerDisposeWrapper(new StubEventHandler(), () => disposed = true); + + // Act + wrapper.Dispose(); + + // Assert + disposed.ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // SingleInstanceHandlerFactory + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:HandlerInstance 属性应保持构造时传入的处理器引用。 + /// + [Fact] + public void SingleInstanceFactory_HandlerInstance_ShouldMatchConstructorArg() + { + // Arrange + var handler = new StubEventHandler(); + + // Act + var factory = new SingleInstanceHandlerFactory(handler); + + // Assert + factory.HandlerInstance.ShouldBeSameAs(handler); + } + + /// + /// 测试目的:GetHandler 应返回包含相同处理器实例的 Wrapper,且不为 null。 + /// + [Fact] + public void SingleInstanceFactory_GetHandler_ShouldReturnWrapperWithSameHandler() + { + // Arrange + var handler = new StubEventHandler(); + var factory = new SingleInstanceHandlerFactory(handler); + + // Act + using var wrapper = factory.GetHandler(); + + // Assert + wrapper.ShouldNotBeNull(); + wrapper.EventHandler.ShouldBeSameAs(handler); + } + + /// + /// 测试目的:多次 GetHandler 应每次均返回包含相同处理器的 Wrapper(单例语义)。 + /// + [Fact] + public void SingleInstanceFactory_GetHandler_MultipleCalls_ShouldReturnSameHandler() + { + // Arrange + var handler = new StubEventHandler(); + var factory = new SingleInstanceHandlerFactory(handler); + + // Act + using var w1 = factory.GetHandler(); + using var w2 = factory.GetHandler(); + + // Assert + w1.EventHandler.ShouldBeSameAs(w2.EventHandler); + } + + /// + /// 测试目的:IsInFactories - 工厂列表中包含相同实例时应返回 true。 + /// + [Fact] + public void SingleInstanceFactory_IsInFactories_WhenPresent_ShouldReturnTrue() + { + // Arrange + var handler = new StubEventHandler(); + var factory = new SingleInstanceHandlerFactory(handler); + var factories = new List { factory }; + + // Act + var result = factory.IsInFactories(factories); + + // Assert + result.ShouldBeTrue(); + } + + /// + /// 测试目的:IsInFactories - 工厂列表为空时应返回 false。 + /// + [Fact] + public void SingleInstanceFactory_IsInFactories_WhenEmpty_ShouldReturnFalse() + { + // Arrange + var handler = new StubEventHandler(); + var factory = new SingleInstanceHandlerFactory(handler); + + // Act + var result = factory.IsInFactories(new List()); + + // Assert + result.ShouldBeFalse(); + } + + /// + /// 测试目的:IsInFactories - 工厂列表中包含不同实例(相同类型)时应返回 false。 + /// + [Fact] + public void SingleInstanceFactory_IsInFactories_WhenDifferentInstance_ShouldReturnFalse() + { + // Arrange + var factory1 = new SingleInstanceHandlerFactory(new StubEventHandler()); + var factory2 = new SingleInstanceHandlerFactory(new StubEventHandler()); + var factories = new List { factory2 }; + + // Act + var result = factory1.IsInFactories(factories); + + // Assert + result.ShouldBeFalse(); + } + + // ════════════════════════════════════════════════════════════════ + // TransientEventHandlerFactory + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:HandlerType 属性应与构造时传入的类型一致。 + /// + [Fact] + public void TransientFactory_HandlerType_ShouldMatchConstructorArg() + { + // Act + var factory = new TransientEventHandlerFactory(typeof(NewableStubHandler)); + + // Assert + factory.HandlerType.ShouldBe(typeof(NewableStubHandler)); + } + + /// + /// 测试目的:泛型重载创建的工厂,HandlerType 应为对应泛型参数类型。 + /// + [Fact] + public void TransientFactory_Generic_HandlerType_ShouldMatchGenericArg() + { + // Act + var factory = new TransientEventHandlerFactory(); + + // Assert + factory.HandlerType.ShouldBe(typeof(NewableStubHandler)); + } + + /// + /// 测试目的:IsInFactories - 工厂列表中包含相同 HandlerType 时应返回 true。 + /// + [Fact] + public void TransientFactory_IsInFactories_WhenSameHandlerType_ShouldReturnTrue() + { + // Arrange + var factory = new TransientEventHandlerFactory(typeof(NewableStubHandler)); + var factories = new List { factory }; + + // Act + var result = factory.IsInFactories(factories); + + // Assert + result.ShouldBeTrue(); + } + + /// + /// 测试目的:IsInFactories - 工厂列表中不包含相同 HandlerType 时应返回 false。 + /// + [Fact] + public void TransientFactory_IsInFactories_WhenDifferentHandlerType_ShouldReturnFalse() + { + // Arrange + var factory1 = new TransientEventHandlerFactory(typeof(NewableStubHandler)); + var factory2 = new TransientEventHandlerFactory(typeof(StubEventHandler)); + var factories = new List { factory2 }; + + // Act + var result = factory1.IsInFactories(factories); + + // Assert + result.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusDynamicTest.cs b/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusDynamicTest.cs new file mode 100644 index 00000000..cabb3f43 --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusDynamicTest.cs @@ -0,0 +1,257 @@ +using Bing.EventBus.Local; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace Bing.EventBus.Tests.Local; + +/// +/// LocalEventBus 动态订阅 / 取消订阅测试 +/// +public class LocalEventBusDynamicTest +{ + // ==================== 辅助 ==================== + + /// + /// 创建带空 Handler 的 LocalEventBus(不依赖 EventBusModule) + /// + private static LocalEventBus CreateBus() + { + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + var options = Options.Create(new LocalEventBusOptions()); + return new LocalEventBus(options, sp.GetRequiredService()); + } + + private class DynEvent : IEvent + { + public string? Value { get; set; } + } + + // ==================== Subscribe + Publish ==================== + + /// + /// 测试目的:动态 Subscribe(Func) 后,Publish 应触发该委托。 + /// + [Fact] + public async Task Subscribe_Action_ThenPublish_HandlerIsCalled() + { + // Arrange + var bus = CreateBus(); + string? received = null; + bus.Subscribe(e => + { + received = e.Value; + return Task.CompletedTask; + }); + + // Act + await bus.PublishAsync(new DynEvent { Value = "hello" }); + + // Assert + received.ShouldBe("hello"); + } + + /// + /// 测试目的:同一事件注册多个委托,Publish 后所有委托均被调用。 + /// + [Fact] + public async Task Subscribe_MultipleActions_AllCalled_OnPublish() + { + // Arrange + var bus = CreateBus(); + var callLog = new List(); + + bus.Subscribe(_ => { callLog.Add(1); return Task.CompletedTask; }); + bus.Subscribe(_ => { callLog.Add(2); return Task.CompletedTask; }); + + // Act + await bus.PublishAsync(new DynEvent()); + + // Assert + callLog.Count.ShouldBe(2); + callLog.ShouldContain(1); + callLog.ShouldContain(2); + } + + // ==================== Unsubscribe ==================== + + /// + /// 测试目的:Unsubscribe(Func) 后,Publish 不应再触发该委托。 + /// + [Fact] + public async Task Unsubscribe_Action_ThenPublish_HandlerNotCalled() + { + // Arrange + var bus = CreateBus(); + var called = false; + Func handler = _ => + { + called = true; + return Task.CompletedTask; + }; + + bus.Subscribe(handler); + + // Act + bus.Unsubscribe(handler); + await bus.PublishAsync(new DynEvent()); + + // Assert + called.ShouldBeFalse(); + } + + /// + /// 测试目的:取消一个委托后,另一个委托仍然被调用。 + /// + [Fact] + public async Task Unsubscribe_OneAction_OtherHandlerStillCalled() + { + // Arrange + var bus = CreateBus(); + var log = new List(); + + Func h1 = _ => { log.Add("h1"); return Task.CompletedTask; }; + Func h2 = _ => { log.Add("h2"); return Task.CompletedTask; }; + + bus.Subscribe(h1); + bus.Subscribe(h2); + + // Act:只取消 h1 + bus.Unsubscribe(h1); + await bus.PublishAsync(new DynEvent()); + + // Assert + log.ShouldNotContain("h1"); + log.ShouldContain("h2"); + } + + // ==================== Subscribe/Unsubscribe 通过 IDisposable ==================== + + /// + /// 测试目的:Subscribe 返回的 IDisposable.Dispose() 应与 Unsubscribe 效果相同。 + /// + [Fact] + public async Task Subscribe_Returns_Disposable_That_Unsubscribes() + { + // Arrange + var bus = CreateBus(); + var called = false; + + var handle = bus.Subscribe(_ => + { + called = true; + return Task.CompletedTask; + }); + + // Act:通过 Dispose 取消订阅 + handle.Dispose(); + await bus.PublishAsync(new DynEvent()); + + // Assert + called.ShouldBeFalse(); + } + + // ==================== UnsubscribeAll ==================== + + /// + /// 测试目的:UnsubscribeAll<TEvent> 后,Publish 不应触发任何委托。 + /// + [Fact] + public async Task UnsubscribeAll_Generic_ClearsAllHandlers() + { + // Arrange + var bus = CreateBus(); + var callCount = 0; + + bus.Subscribe(_ => { callCount++; return Task.CompletedTask; }); + bus.Subscribe(_ => { callCount++; return Task.CompletedTask; }); + + // Act + bus.UnsubscribeAll(); + await bus.PublishAsync(new DynEvent()); + + // Assert + callCount.ShouldBe(0); + } + + /// + /// 测试目的:UnsubscribeAll(Type) 仅清除指定事件类型的处理器,其他类型不受影响。 + /// + [Fact] + public async Task UnsubscribeAll_Type_DoesNotAffectOtherEventTypes() + { + // Arrange + var bus = CreateBus(); + var dynCalled = false; + var otherCalled = false; + + bus.Subscribe(_ => { dynCalled = true; return Task.CompletedTask; }); + bus.Subscribe(_ => { otherCalled = true; return Task.CompletedTask; }); + + // Act:只清除 DynEvent 的处理器 + bus.UnsubscribeAll(typeof(DynEvent)); + await bus.PublishAsync(new DynEvent()); + await bus.PublishAsync(new OtherEvent()); + + // Assert + dynCalled.ShouldBeFalse(); + otherCalled.ShouldBeTrue(); + } + + // ==================== Subscribe 通过 IEventHandler 实例 ==================== + + /// + /// 测试目的:Subscribe(Type, IEventHandler) 注册处理器实例后,Publish 应触发 HandleAsync。 + /// + [Fact] + public async Task Subscribe_EventHandlerInstance_IsInvokedOnPublish() + { + // Arrange + var bus = CreateBus(); + var handler = new TrackingEventHandler(); + + bus.Subscribe(typeof(DynEvent), handler); + + // Act + await bus.PublishAsync(new DynEvent { Value = "test" }); + + // Assert + handler.ReceivedValues.ShouldContain("test"); + } + + /// + /// 测试目的:Unsubscribe(Type, IEventHandler) 后,处理器不再被调用。 + /// + [Fact] + public async Task Unsubscribe_EventHandlerInstance_NotCalledAfterUnsubscribe() + { + // Arrange + var bus = CreateBus(); + var handler = new TrackingEventHandler(); + + bus.Subscribe(typeof(DynEvent), handler); + bus.Unsubscribe(typeof(DynEvent), handler); + + // Act + await bus.PublishAsync(new DynEvent { Value = "test" }); + + // Assert + handler.ReceivedValues.ShouldBeEmpty(); + } + + // ==================== 辅助类型 ==================== + + private class OtherEvent : IEvent { } + + private class TrackingEventHandler : ILocalEventHandler + { + public List ReceivedValues { get; } = new(); + + public Task HandleAsync(DynEvent eventData) + { + ReceivedValues.Add(eventData.Value); + return Task.CompletedTask; + } + } +} diff --git a/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusExceptionTest.cs b/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusExceptionTest.cs new file mode 100644 index 00000000..0df7aaf4 --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/Local/LocalEventBusExceptionTest.cs @@ -0,0 +1,142 @@ +using Bing.EventBus.Local; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace Bing.EventBus.Tests.Local; + +/// +/// LocalEventBus 异常传播行为测试 +/// +public class LocalEventBusExceptionTest +{ + // ==================== 辅助 ==================== + + private static LocalEventBus CreateBus() + { + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + var options = Options.Create(new LocalEventBusOptions()); + return new LocalEventBus(options, sp.GetRequiredService()); + } + + private class FaultyEvent : IEvent { } + + // ==================== 单个处理器抛出异常 ==================== + + /// + /// 测试目的:处理器抛出异常时,PublishAsync 应将该异常传播给调用方。 + /// + [Fact] + public async Task PublishAsync_HandlerThrows_ExceptionPropagated() + { + // Arrange + var bus = CreateBus(); + bus.Subscribe(_ => throw new InvalidOperationException("handler error")); + + // Act & Assert + await Should.ThrowAsync(() => bus.PublishAsync(new FaultyEvent())); + } + + /// + /// 测试目的:一个处理器抛出异常,另一个处理器依然被调用(所有处理器均触发)。 + /// + [Fact] + public async Task PublishAsync_OneHandlerThrows_OtherHandlerStillCalled() + { + // Arrange + var bus = CreateBus(); + var secondCalled = false; + + // 第一个处理器:抛出异常 + bus.Subscribe(_ => throw new InvalidOperationException("first fails")); + + // 第二个处理器:正常执行 + bus.Subscribe(_ => + { + secondCalled = true; + return Task.CompletedTask; + }); + + // Act:PublishAsync 会收集所有异常后再抛出 + try + { + await bus.PublishAsync(new FaultyEvent()); + } + catch + { + // 忽略异常,只验证第二个处理器是否被调用 + } + + // Assert + secondCalled.ShouldBeTrue(); + } + + /// + /// 测试目的:多个处理器均抛出异常时,PublishAsync 应抛出 AggregateException。 + /// + [Fact] + public async Task PublishAsync_MultipleHandlersThrow_AggregateExceptionThrown() + { + // Arrange + var bus = CreateBus(); + bus.Subscribe(_ => throw new InvalidOperationException("error1")); + bus.Subscribe(_ => throw new ArgumentException("error2")); + + // Act & Assert + var ex = await Should.ThrowAsync(() => bus.PublishAsync(new FaultyEvent())); + ex.InnerExceptions.Count.ShouldBe(2); + } + + /// + /// 测试目的:只有一个处理器且抛出异常时,直接重新抛出原始异常(不包装为 AggregateException)。 + /// + [Fact] + public async Task PublishAsync_SingleHandlerThrows_OriginalExceptionRethrown() + { + // Arrange + var bus = CreateBus(); + bus.Subscribe(_ => throw new InvalidOperationException("single error")); + + // Act & Assert:应该是原始异常类型,不是 AggregateException + var ex = await Should.ThrowAsync(() => bus.PublishAsync(new FaultyEvent())); + ex.Message.ShouldBe("single error"); + } + + // ==================== 异步处理器抛出 ==================== + + /// + /// 测试目的:异步处理器 await 后抛出的异常,也应被正确传播。 + /// + [Fact] + public async Task PublishAsync_AsyncHandlerThrows_ExceptionPropagated() + { + // Arrange + var bus = CreateBus(); + bus.Subscribe(async _ => + { + await Task.Yield(); + throw new TimeoutException("async error"); + }); + + // Act & Assert + await Should.ThrowAsync(() => bus.PublishAsync(new FaultyEvent())); + } + + // ==================== 正常处理器不受影响 ==================== + + /// + /// 测试目的:所有处理器正常完成时,不应抛出任何异常。 + /// + [Fact] + public async Task PublishAsync_AllHandlersSucceed_NoException() + { + // Arrange + var bus = CreateBus(); + bus.Subscribe(_ => Task.CompletedTask); + bus.Subscribe(_ => Task.CompletedTask); + + // Act & Assert + await Should.NotThrowAsync(() => bus.PublishAsync(new FaultyEvent())); + } +} diff --git a/framework/tests/Bing.EventBus.Tests/MessageEventTest.cs b/framework/tests/Bing.EventBus.Tests/MessageEventTest.cs new file mode 100644 index 00000000..03770b02 --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/MessageEventTest.cs @@ -0,0 +1,220 @@ +using System; +using Bing.EventBus; +using Shouldly; +using Xunit; + +namespace Bing.EventBus.Tests; + +/// +/// MessageEvent 单元测试 +/// +public class MessageEventTest +{ + // ════════════════════════════════════════════════════════════════ + // 默认构造 + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Id 应为非空字符串(由 Guid.NewGuid 生成)。 + /// + [Fact] + public void Ctor_Default_IdShouldBeNonEmpty() + { + // Act + var evt = new MessageEvent(); + + // Assert + evt.Id.ShouldNotBeNullOrWhiteSpace(); + Guid.TryParse(evt.Id, out _).ShouldBeTrue(); + } + + /// + /// 测试目的:默认构造后 Time 应被初始化(不为 default/MinValue)。 + /// + [Fact] + public void Ctor_Default_TimeShouldBeInitialized() + { + // Arrange + var before = DateTime.Now.AddSeconds(-1); + + // Act + var evt = new MessageEvent(); + + // Assert + evt.Time.ShouldBeGreaterThan(before); + } + + /// + /// 测试目的:两个实例的 Id 应互不相同(Guid 唯一性)。 + /// + [Fact] + public void Ctor_TwoInstances_ShouldHaveDifferentIds() + { + // Act + var e1 = new MessageEvent(); + var e2 = new MessageEvent(); + + // Assert + e1.Id.ShouldNotBe(e2.Id); + } + + // ════════════════════════════════════════════════════════════════ + // 属性读写 + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:Name 属性可读写,且读取值与写入值一致。 + /// + [Fact] + public void Name_SetAndGet_ShouldReturnSameValue() + { + // Arrange + var evt = new MessageEvent(); + + // Act + evt.Name = "OrderCreated"; + + // Assert + evt.Name.ShouldBe("OrderCreated"); + } + + /// + /// 测试目的:Data 属性可读写任意对象。 + /// + [Fact] + public void Data_SetAndGet_ShouldReturnSameReference() + { + // Arrange + var evt = new MessageEvent(); + var data = new { OrderId = 42 }; + + // Act + evt.Data = data; + + // Assert + evt.Data.ShouldBeSameAs(data); + } + + /// + /// 测试目的:Callback 属性可读写。 + /// + [Fact] + public void Callback_SetAndGet_ShouldReturnSameValue() + { + // Arrange + var evt = new MessageEvent { Callback = "on_order_created" }; + + // Assert + evt.Callback.ShouldBe("on_order_created"); + } + + /// + /// 测试目的:Send 默认值应为 false。 + /// + [Fact] + public void Send_Default_ShouldBeFalse() + { + // Act + var evt = new MessageEvent(); + + // Assert + evt.Send.ShouldBeFalse(); + } + + /// + /// 测试目的:Send 属性可被设置为 true。 + /// + [Fact] + public void Send_SetTrue_ShouldReturnTrue() + { + // Arrange + var evt = new MessageEvent { Send = true }; + + // Assert + evt.Send.ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // ToString + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:ToString 应包含事件标识字段。 + /// + [Fact] + public void ToString_ShouldContainEventId() + { + // Arrange + var evt = new MessageEvent(); + + // Act + var str = evt.ToString(); + + // Assert + str.ShouldContain(evt.Id); + } + + /// + /// 测试目的:ToString 设置 Name 时应包含消息名称。 + /// + [Fact] + public void ToString_WhenNameSet_ShouldContainName() + { + // Arrange + var evt = new MessageEvent { Name = "TestEvent" }; + + // Act + var str = evt.ToString(); + + // Assert + str.ShouldContain("TestEvent"); + } + + /// + /// 测试目的:ToString 不设置 Name 时,输出不应包含"消息名称"标签(避免空字段干扰日志)。 + /// + [Fact] + public void ToString_WhenNameNotSet_ShouldNotContainNameLabel() + { + // Arrange + var evt = new MessageEvent { Name = null }; + + // Act + var str = evt.ToString(); + + // Assert + str.ShouldNotContain("消息名称"); + } + + /// + /// 测试目的:ToString 设置 Callback 时应包含回调名称。 + /// + [Fact] + public void ToString_WhenCallbackSet_ShouldContainCallback() + { + // Arrange + var evt = new MessageEvent { Callback = "my_callback" }; + + // Act + var str = evt.ToString(); + + // Assert + str.ShouldContain("my_callback"); + } + + /// + /// 测试目的:ToString 不设置 Callback 时,输出不应包含"回调名称"标签。 + /// + [Fact] + public void ToString_WhenCallbackNotSet_ShouldNotContainCallbackLabel() + { + // Arrange + var evt = new MessageEvent { Callback = null }; + + // Act + var str = evt.ToString(); + + // Assert + str.ShouldNotContain("回调名称"); + } +} diff --git a/framework/tests/Bing.EventBus.Tests/NullLocalEventBusTest.cs b/framework/tests/Bing.EventBus.Tests/NullLocalEventBusTest.cs new file mode 100644 index 00000000..6383780a --- /dev/null +++ b/framework/tests/Bing.EventBus.Tests/NullLocalEventBusTest.cs @@ -0,0 +1,188 @@ +using Bing.EventBus.Local; +using Shouldly; + +namespace Bing.EventBus.Tests; + +/// +/// NullLocalEventBus 空实现测试 +/// +public class NullLocalEventBusTest +{ + // ==================== 单例 ==================== + + /// + /// 测试目的:NullLocalEventBus.Instance 不为 null,且是单例(两次访问同一对象)。 + /// + [Fact] + public void Instance_IsNotNull_And_IsSingleton() + { + // Act + var a = NullLocalEventBus.Instance; + var b = NullLocalEventBus.Instance; + + // Assert + a.ShouldNotBeNull(); + a.ShouldBeSameAs(b); + } + + /// + /// 测试目的:NullLocalEventBus 实现 ILocalEventBus 接口。 + /// + [Fact] + public void Instance_Implements_ILocalEventBus() + { + NullLocalEventBus.Instance.ShouldBeAssignableTo(); + } + + // ==================== Publish 无副作用 ==================== + + /// + /// 测试目的:PublishAsync<TEvent> 不抛异常,Task 正常完成。 + /// + [Fact] + public async Task PublishAsync_Generic_DoesNotThrowAndCompletes() + { + // Arrange + var bus = NullLocalEventBus.Instance; + var evt = new NullBusEvent(); + + // Act & Assert + await Should.NotThrowAsync(() => bus.PublishAsync(evt)); + } + + /// + /// 测试目的:PublishAsync(Type, object) 不抛异常。 + /// + [Fact] + public async Task PublishAsync_TypeObject_DoesNotThrow() + { + // Arrange + var bus = NullLocalEventBus.Instance; + var evt = new NullBusEvent(); + + // Act & Assert + await Should.NotThrowAsync(() => bus.PublishAsync(typeof(NullBusEvent), evt)); + } + + // ==================== Subscribe 返回 NullDisposable ==================== + + /// + /// 测试目的:Subscribe(Action) 返回可被安全 Dispose 的 IDisposable。 + /// + [Fact] + public void Subscribe_Action_ReturnsDisposable() + { + // Arrange + var bus = NullLocalEventBus.Instance; + + // Act + var handle = bus.Subscribe(_ => Task.CompletedTask); + + // Assert + handle.ShouldNotBeNull(); + Should.NotThrow(() => handle.Dispose()); + } + + /// + /// 测试目的:Subscribe(Type, IEventHandler) 返回可被安全 Dispose 的 IDisposable。 + /// + [Fact] + public void Subscribe_TypeHandler_ReturnsDisposable() + { + // Arrange + var bus = NullLocalEventBus.Instance; + + // Act + var handle = bus.Subscribe(typeof(NullBusEvent), new NullEventHandler()); + + // Assert + handle.ShouldNotBeNull(); + Should.NotThrow(() => handle.Dispose()); + } + + // ==================== Unsubscribe 不抛 ==================== + + /// + /// 测试目的:Unsubscribe(Func) 不抛异常(即使从未订阅)。 + /// + [Fact] + public void Unsubscribe_Action_DoesNotThrow() + { + // Arrange + var bus = NullLocalEventBus.Instance; + Func handler = _ => Task.CompletedTask; + + // Act & Assert + Should.NotThrow(() => bus.Unsubscribe(handler)); + } + + /// + /// 测试目的:Unsubscribe(Type, IEventHandler) 不抛异常。 + /// + [Fact] + public void Unsubscribe_TypeHandler_DoesNotThrow() + { + // Arrange + var bus = NullLocalEventBus.Instance; + + // Act & Assert + Should.NotThrow(() => bus.Unsubscribe(typeof(NullBusEvent), new NullEventHandler())); + } + + /// + /// 测试目的:UnsubscribeAll<TEvent> 不抛异常。 + /// + [Fact] + public void UnsubscribeAll_Generic_DoesNotThrow() + { + // Arrange + var bus = NullLocalEventBus.Instance; + + // Act & Assert + Should.NotThrow(() => bus.UnsubscribeAll()); + } + + /// + /// 测试目的:UnsubscribeAll(Type) 不抛异常。 + /// + [Fact] + public void UnsubscribeAll_Type_DoesNotThrow() + { + // Arrange + var bus = NullLocalEventBus.Instance; + + // Act & Assert + Should.NotThrow(() => bus.UnsubscribeAll(typeof(NullBusEvent))); + } + + // ==================== 发布后事件对象不变 ==================== + + /// + /// 测试目的:NullLocalEventBus 发布事件后,事件对象状态不被修改(空实现不做任何操作)。 + /// + [Fact] + public async Task PublishAsync_EventStateUnchanged_AfterPublish() + { + // Arrange + var bus = NullLocalEventBus.Instance; + var evt = new NullBusEvent { Value = "original" }; + + // Act + await bus.PublishAsync(evt); + + // Assert:空实现不修改事件 + evt.Value.ShouldBe("original"); + } + + // ==================== 辅助类型 ==================== + + private class NullBusEvent : IEvent + { + public string? Value { get; set; } + } + + private class NullEventHandler : ILocalEventHandler + { + public Task HandleAsync(NullBusEvent eventData) => Task.CompletedTask; + } +} diff --git a/framework/tests/Bing.ExceptionHandling.Tests/Bing.ExceptionHandling.Tests.csproj b/framework/tests/Bing.ExceptionHandling.Tests/Bing.ExceptionHandling.Tests.csproj new file mode 100644 index 00000000..93087ba1 --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/Bing.ExceptionHandling.Tests.csproj @@ -0,0 +1,12 @@ + + + + + false + + + + + + + diff --git a/framework/tests/Bing.ExceptionHandling.Tests/Bing/Domain/EntityNotFoundExceptionTest.cs b/framework/tests/Bing.ExceptionHandling.Tests/Bing/Domain/EntityNotFoundExceptionTest.cs new file mode 100644 index 00000000..8e07130b --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/Bing/Domain/EntityNotFoundExceptionTest.cs @@ -0,0 +1,161 @@ +using Bing.Domain.Entities; +using Bing.Exceptions; +using Shouldly; +using Xunit; + +namespace Bing.ExceptionHandling.Tests; + +/// +/// 单元测试 +/// 直接 new 被测类,不依赖任何外部服务。 +/// +public class EntityNotFoundExceptionTest +{ + // ── Default constructor ──────────────────────────────────────── + + /// + /// 测试目的:默认构造后,Message 应包含通用的"找不到实体"语义, + /// Flag 应为 "__ENTITY_NOT_FOUND_FLG",Code 应为 "1010"。 + /// + [Fact] + public void DefaultConstructor_ShouldSetDefaultMessageFlagAndCode() + { + // Act + var ex = new EntityNotFoundException(); + + // Assert + ex.Message.ShouldContain("entity"); + ex.Flag.ShouldBe("__ENTITY_NOT_FOUND_FLG"); + ex.Code.ShouldBe("1010"); + } + + // ── Constructor(Type entityType) ────────────────────────────── + + /// + /// 测试目的:通过实体类型构造时,EntityType 应正确赋值, + /// Message 应包含该类型的完全限定名。 + /// + [Fact] + public void Constructor_WithEntityType_ShouldSetEntityTypeInMessage() + { + // Act + var ex = new EntityNotFoundException(typeof(SampleEntity)); + + // Assert + ex.EntityType.ShouldBe(typeof(SampleEntity)); + ex.Id.ShouldBeNull(); + ex.Message.ShouldContain(typeof(SampleEntity).FullName!); + } + + // ── Constructor(Type entityType, object id) ─────────────────── + + /// + /// 测试目的:通过实体类型和 Id 构造时,EntityType 和 Id 均应被设置, + /// Message 应同时包含类型名和 Id 值。 + /// + [Fact] + public void Constructor_WithEntityTypeAndId_ShouldSetBothAndIncludeInMessage() + { + // Arrange + var id = 42; + + // Act + var ex = new EntityNotFoundException(typeof(SampleEntity), id); + + // Assert + ex.EntityType.ShouldBe(typeof(SampleEntity)); + ex.Id.ShouldBe(id); + ex.Message.ShouldContain(typeof(SampleEntity).FullName!); + ex.Message.ShouldContain("42"); + } + + /// + /// 测试目的:Guid 类型的 Id 也应被正确包含在 Message 中。 + /// + [Fact] + public void Constructor_WithEntityTypeAndGuidId_ShouldIncludeGuidInMessage() + { + // Arrange + var id = Guid.Parse("12345678-0000-0000-0000-000000000001"); + + // Act + var ex = new EntityNotFoundException(typeof(SampleEntity), id); + + // Assert + ex.Id.ShouldBe(id); + ex.Message.ShouldContain(id.ToString()); + } + + /// + /// 测试目的:通过实体类型和 null Id 构造时,Message 应包含"given id"相关提示。 + /// + [Fact] + public void Constructor_WithEntityTypeAndNullId_ShouldUseGivenIdMessage() + { + // Act + var ex = new EntityNotFoundException(typeof(SampleEntity), (object)null); + + // Assert + ex.EntityType.ShouldBe(typeof(SampleEntity)); + ex.Id.ShouldBeNull(); + ex.Message.ShouldContain("given id"); + } + + // ── Constructor(string message) ─────────────────────────────── + + /// + /// 测试目的:通过自定义消息构造时,Message 应与传入值一致, + /// EntityType 和 Id 均为 null。 + /// + [Fact] + public void Constructor_WithCustomMessage_ShouldSetMessage() + { + // Act + var ex = new EntityNotFoundException("找不到目标记录"); + + // Assert + ex.Message.ShouldBe("找不到目标记录"); + ex.EntityType.ShouldBeNull(); + ex.Id.ShouldBeNull(); + } + + // ── Constructor(string message, Exception innerException) ───── + + /// + /// 测试目的:带内部异常的构造应将 InnerException 正确链接。 + /// + [Fact] + public void Constructor_WithInnerException_ShouldLinkInnerException() + { + // Arrange + var inner = new InvalidOperationException("数据库查询失败"); + + // Act + var ex = new EntityNotFoundException("找不到用户", inner); + + // Assert + ex.Message.ShouldBe("找不到用户"); + ex.InnerException.ShouldBe(inner); + ex.Flag.ShouldBe("__ENTITY_NOT_FOUND_FLG"); + } + + // ── Is-a hierarchy ──────────────────────────────────────────── + + /// + /// 测试目的:EntityNotFoundException 应继承自 BingException, + /// 满足框架统一异常类型层次约定。 + /// + [Fact] + public void EntityNotFoundException_ShouldInheritFromBingException() + { + // Arrange + var ex = new EntityNotFoundException("test"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + // ── 辅助实体类型 ────────────────────────────────────────────── + + private class SampleEntity { } +} diff --git a/framework/tests/Bing.ExceptionHandling.Tests/BingRemoteCallExceptionTest.cs b/framework/tests/Bing.ExceptionHandling.Tests/BingRemoteCallExceptionTest.cs new file mode 100644 index 00000000..436e0ace --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/BingRemoteCallExceptionTest.cs @@ -0,0 +1,227 @@ +using Bing.Http; +using Bing.Http.Clients; +using Shouldly; +using Xunit; + +namespace Bing.ExceptionHandling.Tests; + +/// +/// 单元测试 +/// +public class BingRemoteCallExceptionTest +{ + // ═══════════════════════════════════════════════════════════ + // 继承结构 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:BingRemoteCallException 应实现 IHasHttpStatusCode,允许携带 HTTP 状态码。 + /// + [Fact] + public void BingRemoteCallException_ShouldImplementIHasHttpStatusCode() + { + // Arrange & Act + var ex = new BingRemoteCallException("远程调用失败"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数:(string message) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用 message 构造时,Message 应等于传入文本。 + /// + [Fact] + public void Ctor_WithMessage_ShouldSetMessage() + { + // Arrange & Act + var ex = new BingRemoteCallException("调用 OrderService 失败"); + + // Assert + ex.Message.ShouldBe("调用 OrderService 失败"); + } + + /// + /// 测试目的:使用 message + innerException 构造时,InnerException 应被保存。 + /// + [Fact] + public void Ctor_WithMessageAndInner_ShouldSetInnerException() + { + // Arrange + var inner = new HttpRequestException("网络超时"); + + // Act + var ex = new BingRemoteCallException("远程调用失败", inner); + + // Assert + ex.InnerException.ShouldBe(inner); + } + + /// + /// 测试目的:无内部异常时 InnerException 应为 null,不抛异常。 + /// + [Fact] + public void Ctor_WithMessageOnly_ShouldHaveNullInnerException() + { + // Arrange & Act + var ex = new BingRemoteCallException("简单错误"); + + // Assert + ex.InnerException.ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数:(RemoteServiceErrorInfo error) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用 RemoteServiceErrorInfo 构造时,Error 属性应指向该错误信息。 + /// + [Fact] + public void Ctor_WithErrorInfo_ShouldSetErrorProperty() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("用户不存在", code: "USER_NOT_FOUND"); + + // Act + var ex = new BingRemoteCallException(errorInfo); + + // Assert + ex.Error.ShouldBe(errorInfo); + } + + /// + /// 测试目的:ErrorInfo.Message 应作为异常 Message,确保错误描述可读。 + /// + [Fact] + public void Ctor_WithErrorInfo_ShouldUseErrorInfoMessageAsExceptionMessage() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("服务不可用"); + + // Act + var ex = new BingRemoteCallException(errorInfo); + + // Assert + ex.Message.ShouldBe("服务不可用"); + } + + /// + /// 测试目的:ErrorInfo.Code 应可通过 Code 属性访问。 + /// + [Fact] + public void Ctor_WithErrorInfo_CodeShouldBeAccessibleViaProperty() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("权限不足", code: "AUTH_403"); + + // Act + var ex = new BingRemoteCallException(errorInfo); + + // Assert + ex.Code.ShouldBe("AUTH_403"); + } + + /// + /// 测试目的:ErrorInfo.Details 应可通过 Details 属性访问。 + /// + [Fact] + public void Ctor_WithErrorInfo_DetailsShouldBeAccessibleViaProperty() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("参数错误", details: "字段 Name 不能为空"); + + // Act + var ex = new BingRemoteCallException(errorInfo); + + // Assert + ex.Details.ShouldBe("字段 Name 不能为空"); + } + + /// + /// 测试目的:当 ErrorInfo.Data 有键值对时,应被复制到异常的 Data 字典。 + /// + [Fact] + public void Ctor_WithErrorInfoData_ShouldCopyDataToException() + { + // Arrange + var data = new System.Collections.Hashtable { ["requestId"] = "req-001", ["service"] = "UserService" }; + var errorInfo = new RemoteServiceErrorInfo("错误", data: data); + + // Act + var ex = new BingRemoteCallException(errorInfo); + + // Assert + ex.Data["requestId"].ShouldBe("req-001"); + ex.Data["service"].ShouldBe("UserService"); + } + + // ═══════════════════════════════════════════════════════════ + // HttpStatusCode + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:HttpStatusCode 默认为 0(未设置),允许调用方后续赋值。 + /// + [Fact] + public void HttpStatusCode_Default_ShouldBeZero() + { + // Arrange & Act + var ex = new BingRemoteCallException("错误"); + + // Assert + ex.HttpStatusCode.ShouldBe(0); + } + + /// + /// 测试目的:HttpStatusCode 可被设置为具体 HTTP 状态码,方便外层框架处理。 + /// + [Fact] + public void HttpStatusCode_WhenSet_ShouldReflectNewValue() + { + // Arrange + var ex = new BingRemoteCallException("未授权") { HttpStatusCode = 401 }; + + // Assert + ex.HttpStatusCode.ShouldBe(401); + } + + // ═══════════════════════════════════════════════════════════ + // RemoteServiceErrorInfo 构造函数 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:RemoteServiceErrorInfo 默认构造后各字段均为 null,允许按需填充。 + /// + [Fact] + public void RemoteServiceErrorInfo_Default_AllFieldsShouldBeNull() + { + // Arrange & Act + var info = new RemoteServiceErrorInfo(); + + // Assert + info.Code.ShouldBeNull(); + info.Message.ShouldBeNull(); + info.Details.ShouldBeNull(); + info.Data.ShouldBeNull(); + info.ValidationErrors.ShouldBeNull(); + } + + /// + /// 测试目的:使用带参数构造时,各字段应被正确赋值。 + /// + [Fact] + public void RemoteServiceErrorInfo_Ctor_ShouldSetAllProvidedFields() + { + // Arrange & Act + var info = new RemoteServiceErrorInfo("错误消息", "详细说明", "ERR_001"); + + // Assert + info.Message.ShouldBe("错误消息"); + info.Details.ShouldBe("详细说明"); + info.Code.ShouldBe("ERR_001"); + } +} diff --git a/framework/tests/Bing.ExceptionHandling.Tests/DefaultExceptionToErrorInfoConverterTest.cs b/framework/tests/Bing.ExceptionHandling.Tests/DefaultExceptionToErrorInfoConverterTest.cs new file mode 100644 index 00000000..62ae1d9b --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/DefaultExceptionToErrorInfoConverterTest.cs @@ -0,0 +1,154 @@ +using Bing.AspNetCore.ExceptionHandling; +using Bing.Domain.Entities; +using Bing.Exceptions; +using Bing.Http; +using Shouldly; +using Xunit; + +namespace Bing.ExceptionHandling.Tests; + +/// +/// 单元测试。 +/// 不依赖 ASP.NET Core 管道,直接 new 被测类。 +/// +public class DefaultExceptionToErrorInfoConverterTest +{ + private readonly DefaultExceptionToErrorInfoConverter _converter = new(); + + // ── UserFriendlyException ───────────────────────────────────── + + /// + /// 测试目的:UserFriendly 异常(Warning)应直接将 Message 作为用户消息返回。 + /// + [Fact] + public void Convert_WhenWarningException_ShouldUseExceptionMessageAsUserMessage() + { + // Arrange + var ex = new Warning("操作不被允许", "W001"); + + // Act + var result = _converter.Convert(ex); + + // Assert + result.ShouldNotBeNull(); + result.Code.ShouldBe("W001"); + result.Message.ShouldBe("操作不被允许"); + } + + // ── EntityNotFoundException ──────────────────────────────────── + + /// + /// 测试目的:EntityNotFoundException 带有 EntityType 和 Id 时, + /// 消息应包含实体类型名和 Id 值,便于调试。 + /// + [Fact] + public void Convert_WhenEntityNotFoundWithTypeAndId_ShouldReturnDescriptiveMessage() + { + // Arrange + var entityId = Guid.NewGuid(); + var ex = new EntityNotFoundException(typeof(TestEntity), entityId); + + // Act + var result = _converter.Convert(ex); + + // Assert + result.Message.ShouldContain("TestEntity"); + result.Message.ShouldContain(entityId.ToString()); + } + + /// + /// 测试目的:EntityNotFoundException 只有消息(无 EntityType)时, + /// 应直接使用异常消息作为错误信息。 + /// + [Fact] + public void Convert_WhenEntityNotFoundWithoutType_ShouldReturnExceptionMessage() + { + // Arrange + var ex = new EntityNotFoundException("找不到目标记录"); + + // Act + var result = _converter.Convert(ex); + + // Assert + result.Message.ShouldBe("找不到目标记录"); + } + + // ── 普通 SystemException ────────────────────────────────────── + + /// + /// 测试目的:普通系统异常在默认配置(不向客户端发送详情)时, + /// 应返回通用提示而非原始异常消息(防止信息泄露)。 + /// + [Fact] + public void Convert_WhenSystemException_WithDefaultOptions_ShouldReturnGenericMessage() + { + // Arrange + var ex = new InvalidOperationException("内部数据库连接字符串 secret_key=xxx"); + + // Act + var result = _converter.Convert(ex); + + // Assert + result.ShouldNotBeNull(); + // 默认不发送详情,消息应为 ExceptionPrompt 返回的通用提示(不含原始内部信息) + result.Message.ShouldNotBeNullOrWhiteSpace(); + } + + // ── SendExceptionDetailsToClients = true ────────────────────── + + /// + /// 测试目的:当 SendExceptionDetailsToClients=true 时, + /// Details 字段应包含异常类型名和原始消息(调试模式)。 + /// + [Fact] + public void Convert_WhenSendDetailsEnabled_ShouldIncludeExceptionTypeInDetails() + { + // Arrange + var ex = new ArgumentException("param cannot be null"); + + // Act + var result = _converter.Convert(ex, opt => opt.SendExceptionDetailsToClients = true); + + // Assert + result.Details.ShouldNotBeNullOrWhiteSpace(); + result.Details.ShouldContain("ArgumentException"); + } + + // ── AggregateException 解包 ─────────────────────────────────── + + /// + /// 测试目的:AggregateException 包裹 EntityNotFoundException 时, + /// 转换器应解包内层异常,返回 EntityNotFoundException 对应的错误信息。 + /// + [Fact] + public void Convert_WhenAggregateWrapsEntityNotFoundException_ShouldUnwrap() + { + // Arrange + var inner = new EntityNotFoundException(typeof(TestEntity), 42); + var ex = new AggregateException(inner); + + // Act + var result = _converter.Convert(ex); + + // Assert + result.Message.ShouldContain("TestEntity"); + result.Message.ShouldContain("42"); + } + + // ── null 传入 ───────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时,转换器应抛出 ArgumentNullException, + /// 而非 NullReferenceException(明确的错误边界)。 + /// + [Fact] + public void Convert_WhenNullException_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => _converter.Convert(null)); + } + + // ── 测试用实体(仅用于类型名断言)──────────────────────────── + + private class TestEntity { } +} diff --git a/framework/tests/Bing.ExceptionHandling.Tests/ErrorInfoAndEntityNotFoundTest.cs b/framework/tests/Bing.ExceptionHandling.Tests/ErrorInfoAndEntityNotFoundTest.cs new file mode 100644 index 00000000..8eb7a4e0 --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/ErrorInfoAndEntityNotFoundTest.cs @@ -0,0 +1,286 @@ +using Bing.AspNetCore.ExceptionHandling; +using Bing.Domain.Entities; +using Bing.Http; +using Shouldly; +using Xunit; + +namespace Bing.ExceptionHandling.Tests; + +/// +/// 、 +/// 、 +/// 单元测试 +/// +public class ErrorInfoAndEntityNotFoundTest +{ + // ═══════════════════════════════════════════════════════════ + // EntityNotFoundException + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:无参构造应使用默认错误消息,不抛异常, + /// 便于快速抛出"实体不存在"而不必传入具体信息。 + /// + [Fact] + public void EntityNotFoundException_DefaultConstructor_ShouldHaveDefaultMessage() + { + // Act + var ex = new EntityNotFoundException(); + + // Assert + ex.Message.ShouldNotBeNullOrEmpty(); + ex.EntityType.ShouldBeNull(); + ex.Id.ShouldBeNull(); + } + + /// + /// 测试目的:仅传入 entityType 时,EntityType 属性应正确设置,Id 应为 null。 + /// + [Fact] + public void EntityNotFoundException_WithEntityType_ShouldSetEntityType() + { + // Act + var ex = new EntityNotFoundException(typeof(string)); + + // Assert + ex.EntityType.ShouldBe(typeof(string)); + ex.Id.ShouldBeNull(); + } + + /// + /// 测试目的:传入 entityType + id 时,两个属性均应被正确赋值, + /// 且消息中应包含类型名和 id 信息。 + /// + [Fact] + public void EntityNotFoundException_WithEntityTypeAndId_ShouldSetBothProperties() + { + // Act + var ex = new EntityNotFoundException(typeof(string), 42); + + // Assert + ex.EntityType.ShouldBe(typeof(string)); + ex.Id.ShouldBe(42); + ex.Message.ShouldContain("42"); + } + + /// + /// 测试目的:传入自定义消息构造时,Message 应反映自定义文本。 + /// + [Fact] + public void EntityNotFoundException_WithCustomMessage_ShouldUseCustomMessage() + { + // Act + var ex = new EntityNotFoundException("custom entity not found"); + + // Assert + ex.Message.ShouldBe("custom entity not found"); + } + + /// + /// 测试目的:传入 innerException 时,InnerException 应正确传递, + /// 不丢失原始异常上下文。 + /// + [Fact] + public void EntityNotFoundException_WithInnerException_ShouldChainInnerException() + { + // Arrange + var inner = new InvalidOperationException("inner"); + + // Act + var ex = new EntityNotFoundException("outer", inner); + + // Assert + ex.InnerException.ShouldBeSameAs(inner); + } + + /// + /// 测试目的:EntityNotFoundException 应继承自 BingException, + /// 确保框架统一异常处理链路能识别并处理此类异常。 + /// + [Fact] + public void EntityNotFoundException_ShouldInheritFromBingException() + { + // Assert + typeof(EntityNotFoundException).BaseType.ShouldBe(typeof(BingException)); + } + + // ═══════════════════════════════════════════════════════════ + // RemoteServiceErrorInfo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:无参构造后所有属性应为 null, + /// 确保空错误信息不携带垃圾默认值。 + /// + [Fact] + public void RemoteServiceErrorInfo_DefaultConstructor_AllPropertiesShouldBeNull() + { + // Act + var info = new RemoteServiceErrorInfo(); + + // Assert + info.Code.ShouldBeNull(); + info.Message.ShouldBeNull(); + info.Details.ShouldBeNull(); + info.Data.ShouldBeNull(); + info.ValidationErrors.ShouldBeNull(); + } + + /// + /// 测试目的:通过有参构造创建时,各参数应正确赋值到对应属性。 + /// + [Fact] + public void RemoteServiceErrorInfo_Constructor_WithAllArgs_ShouldSetProperties() + { + // Arrange + var data = new System.Collections.Hashtable { { "key", "value" } }; + + // Act + var info = new RemoteServiceErrorInfo("msg", "details", "CODE-001", data); + + // Assert + info.Message.ShouldBe("msg"); + info.Details.ShouldBe("details"); + info.Code.ShouldBe("CODE-001"); + info.Data.ShouldBeSameAs(data); + } + + /// + /// 测试目的:仅传 message 时,Details / Code / Data 应保持 null, + /// 确保可选参数的默认行为正确。 + /// + [Fact] + public void RemoteServiceErrorInfo_Constructor_OnlyMessage_ShouldLeaveOthersNull() + { + // Act + var info = new RemoteServiceErrorInfo("only message"); + + // Assert + info.Message.ShouldBe("only message"); + info.Details.ShouldBeNull(); + info.Code.ShouldBeNull(); + info.Data.ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // RemoteServiceErrorResponse + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:RemoteServiceErrorResponse 构造器应将传入的 Error 正确存储, + /// 确保 HTTP 响应封装后的 Error 属性可正常读取。 + /// + [Fact] + public void RemoteServiceErrorResponse_Constructor_ShouldSetError() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("test error"); + + // Act + var response = new RemoteServiceErrorResponse(errorInfo); + + // Assert + response.Error.ShouldBeSameAs(errorInfo); + response.Error.Message.ShouldBe("test error"); + } + + // ═══════════════════════════════════════════════════════════ + // RemoteServiceValidationErrorInfo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:无参构造应不抛异常,Message 和 Members 均为 null。 + /// + [Fact] + public void RemoteServiceValidationErrorInfo_DefaultConstructor_ShouldBeEmpty() + { + // Act + var info = new RemoteServiceValidationErrorInfo(); + + // Assert + info.Message.ShouldBeNull(); + info.Members.ShouldBeNull(); + } + + /// + /// 测试目的:仅传 message 构造时,Message 应正确设置,Members 保持 null。 + /// + [Fact] + public void RemoteServiceValidationErrorInfo_WithMessage_ShouldSetMessageOnly() + { + // Act + var info = new RemoteServiceValidationErrorInfo("field is required"); + + // Assert + info.Message.ShouldBe("field is required"); + info.Members.ShouldBeNull(); + } + + /// + /// 测试目的:传 message + members 数组构造时,两个属性均应正确赋值。 + /// + [Fact] + public void RemoteServiceValidationErrorInfo_WithMessageAndMembers_ShouldSetBoth() + { + // Act + var info = new RemoteServiceValidationErrorInfo("required", new[] { "Name", "Email" }); + + // Assert + info.Message.ShouldBe("required"); + info.Members.ShouldContain("Name"); + info.Members.ShouldContain("Email"); + info.Members.Length.ShouldBe(2); + } + + /// + /// 测试目的:传 message + 单个 member 字符串时,Members 数组应只有一个元素。 + /// + [Fact] + public void RemoteServiceValidationErrorInfo_WithSingleMember_ShouldWrapInArray() + { + // Act + var info = new RemoteServiceValidationErrorInfo("too long", "Username"); + + // Assert + info.Message.ShouldBe("too long"); + info.Members.Length.ShouldBe(1); + info.Members[0].ShouldBe("Username"); + } + + // ═══════════════════════════════════════════════════════════ + // BingExceptionHandlingOptions + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认配置中 SendExceptionDetailsToClients 应为 false(安全默认), + /// SendStackTraceToClients 应为 true(调试友好)。 + /// + [Fact] + public void BingExceptionHandlingOptions_Defaults_ShouldMatchExpected() + { + // Act + var options = new BingExceptionHandlingOptions(); + + // Assert + options.SendExceptionDetailsToClients.ShouldBeFalse(); + options.SendStackTraceToClients.ShouldBeTrue(); + } + + /// + /// 测试目的:选项属性应可读写,确保调用方能按需覆盖默认值。 + /// + [Fact] + public void BingExceptionHandlingOptions_SetProperties_ShouldBeReadable() + { + // Arrange & Act + var options = new BingExceptionHandlingOptions + { + SendExceptionDetailsToClients = true, + SendStackTraceToClients = false + }; + + // Assert + options.SendExceptionDetailsToClients.ShouldBeTrue(); + options.SendStackTraceToClients.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.ExceptionHandling.Tests/Http/RemoteServiceDtoTest.cs b/framework/tests/Bing.ExceptionHandling.Tests/Http/RemoteServiceDtoTest.cs new file mode 100644 index 00000000..872e0d19 --- /dev/null +++ b/framework/tests/Bing.ExceptionHandling.Tests/Http/RemoteServiceDtoTest.cs @@ -0,0 +1,318 @@ +using System.Collections; +using Bing.AspNetCore.ExceptionHandling; +using Bing.Http; +using Shouldly; +using Xunit; + +namespace Bing.ExceptionHandling.Tests.Http; + +/// +/// 测试目的:验证 各构造重载及属性读写行为。 +/// +public class RemoteServiceErrorInfoTest +{ + // ── 默认构造 ──────────────────────────────────────────────── + + /// + /// 测试目的:默认构造函数应创建所有属性为 null 的实例,不抛异常。 + /// + [Fact] + public void DefaultCtor_ShouldCreateInstanceWithNullProperties() + { + // Act + var info = new RemoteServiceErrorInfo(); + + // Assert + info.Message.ShouldBeNull(); + info.Details.ShouldBeNull(); + info.Code.ShouldBeNull(); + info.Data.ShouldBeNull(); + info.ValidationErrors.ShouldBeNull(); + } + + // ── 参数化构造 ─────────────────────────────────────────────── + + /// + /// 测试目的:传入 message 时,Message 属性应正确赋值。 + /// + [Fact] + public void Ctor_WithMessageOnly_ShouldSetMessage() + { + // Act + var info = new RemoteServiceErrorInfo("用户不存在"); + + // Assert + info.Message.ShouldBe("用户不存在"); + info.Details.ShouldBeNull(); + info.Code.ShouldBeNull(); + info.Data.ShouldBeNull(); + } + + /// + /// 测试目的:传入 message + details 时,两个属性均应正确赋值。 + /// + [Fact] + public void Ctor_WithMessageAndDetails_ShouldSetBothFields() + { + // Act + var info = new RemoteServiceErrorInfo("请求失败", "超出限流配额"); + + // Assert + info.Message.ShouldBe("请求失败"); + info.Details.ShouldBe("超出限流配额"); + } + + /// + /// 测试目的:传入 message + details + code 时,三个属性均应正确赋值。 + /// + [Fact] + public void Ctor_WithMessageDetailsAndCode_ShouldSetAllFields() + { + // Act + var info = new RemoteServiceErrorInfo("订单不存在", "订单 #12345 未找到", "ORDER_NOT_FOUND"); + + // Assert + info.Message.ShouldBe("订单不存在"); + info.Details.ShouldBe("订单 #12345 未找到"); + info.Code.ShouldBe("ORDER_NOT_FOUND"); + } + + /// + /// 测试目的:传入非 null Data 时,Data 属性应引用同一字典。 + /// + [Fact] + public void Ctor_WithData_ShouldSetDataProperty() + { + // Arrange + IDictionary data = new Dictionary { ["key"] = "value" }; + + // Act + var info = new RemoteServiceErrorInfo("错误", data: data); + + // Assert + info.Data.ShouldBeSameAs(data); + } + + // ── 属性读写 ──────────────────────────────────────────────── + + /// + /// 测试目的:ValidationErrors 属性可读写,应存储验证错误数组。 + /// + [Fact] + public void ValidationErrors_WhenSet_ShouldBeReadable() + { + // Arrange + var errors = new[] + { + new RemoteServiceValidationErrorInfo("用户名不能为空", "UserName"), + new RemoteServiceValidationErrorInfo("邮箱格式不正确", "Email") + }; + var info = new RemoteServiceErrorInfo("参数验证失败"); + + // Act + info.ValidationErrors = errors; + + // Assert + info.ValidationErrors.ShouldBe(errors); + info.ValidationErrors.Length.ShouldBe(2); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 构造函数及 Error 属性。 +/// +public class RemoteServiceErrorResponseTest +{ + /// + /// 测试目的:构造时传入非 null 的 ErrorInfo,Error 属性应引用同一实例。 + /// + [Fact] + public void Ctor_WithErrorInfo_ShouldSetErrorProperty() + { + // Arrange + var errorInfo = new RemoteServiceErrorInfo("服务不可用"); + + // Act + var response = new RemoteServiceErrorResponse(errorInfo); + + // Assert + response.Error.ShouldBeSameAs(errorInfo); + } + + /// + /// 测试目的:构造时传入 null,Error 属性应为 null,不抛异常。 + /// + [Fact] + public void Ctor_WithNullErrorInfo_ErrorShouldBeNull() + { + // Act + var response = new RemoteServiceErrorResponse(null); + + // Assert + response.Error.ShouldBeNull(); + } + + /// + /// 测试目的:通过属性设置 Error 后,应能正确读取新值。 + /// + [Fact] + public void Error_PropertySet_ShouldBeReadable() + { + // Arrange + var response = new RemoteServiceErrorResponse(null); + var newError = new RemoteServiceErrorInfo("新错误", code: "E001"); + + // Act + response.Error = newError; + + // Assert + response.Error.ShouldBeSameAs(newError); + response.Error.Code.ShouldBe("E001"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 各构造重载的属性赋值行为。 +/// +public class RemoteServiceValidationErrorInfoTest +{ + // ── 默认构造 ──────────────────────────────────────────────── + + /// + /// 测试目的:默认构造应创建 Message=null、Members=null 的实例。 + /// + [Fact] + public void DefaultCtor_ShouldCreateInstanceWithNullFields() + { + // Act + var info = new RemoteServiceValidationErrorInfo(); + + // Assert + info.Message.ShouldBeNull(); + info.Members.ShouldBeNull(); + } + + // ── 单参数构造 ──────────────────────────────────────────────── + + /// + /// 测试目的:传入 message 时,Message 应被正确赋值,Members 保持 null。 + /// + [Fact] + public void Ctor_WithMessage_ShouldSetMessageOnly() + { + // Act + var info = new RemoteServiceValidationErrorInfo("字段不能为空"); + + // Assert + info.Message.ShouldBe("字段不能为空"); + info.Members.ShouldBeNull(); + } + + // ── message + members 数组构造 ───────────────────────────────── + + /// + /// 测试目的:传入 message + string[] members 时,两个属性均应正确赋值。 + /// + [Fact] + public void Ctor_WithMessageAndMembersArray_ShouldSetBothFields() + { + // Arrange + var members = new[] { "UserName", "Email" }; + + // Act + var info = new RemoteServiceValidationErrorInfo("验证失败", members); + + // Assert + info.Message.ShouldBe("验证失败"); + info.Members.ShouldBe(members); + info.Members.Length.ShouldBe(2); + } + + // ── message + 单个 member 字符串构造 ────────────────────────────── + + /// + /// 测试目的:传入 message + 单个 member 字符串时,Members 应包含这一个元素。 + /// + [Fact] + public void Ctor_WithMessageAndSingleMember_ShouldWrapMemberInArray() + { + // Act + var info = new RemoteServiceValidationErrorInfo("名称不能为空", "Name"); + + // Assert + info.Message.ShouldBe("名称不能为空"); + info.Members.ShouldNotBeNull(); + info.Members.Length.ShouldBe(1); + info.Members[0].ShouldBe("Name"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 默认值及属性读写行为。 +/// +public class BingExceptionHandlingOptionsTest +{ + /// + /// 测试目的:默认 SendExceptionDetailsToClients 应为 false(不暴露内部错误详情给客户端)。 + /// + [Fact] + public void Default_SendExceptionDetailsToClients_ShouldBeFalse() + { + // Act + var options = new BingExceptionHandlingOptions(); + + // Assert + options.SendExceptionDetailsToClients.ShouldBeFalse(); + } + + /// + /// 测试目的:默认 SendStackTraceToClients 应为 true(默认允许传递堆栈信息)。 + /// + [Fact] + public void Default_SendStackTraceToClients_ShouldBeTrue() + { + // Act + var options = new BingExceptionHandlingOptions(); + + // Assert + options.SendStackTraceToClients.ShouldBeTrue(); + } + + /// + /// 测试目的:可将 SendExceptionDetailsToClients 设置为 true,并正确读取。 + /// + [Fact] + public void SendExceptionDetailsToClients_WhenSetTrue_ShouldReturnTrue() + { + // Arrange + var options = new BingExceptionHandlingOptions + { + SendExceptionDetailsToClients = true + }; + + // Assert + options.SendExceptionDetailsToClients.ShouldBeTrue(); + } + + /// + /// 测试目的:可将 SendStackTraceToClients 设置为 false,并正确读取。 + /// + [Fact] + public void SendStackTraceToClients_WhenSetFalse_ShouldReturnFalse() + { + // Arrange + var options = new BingExceptionHandlingOptions + { + SendStackTraceToClients = false + }; + + // Assert + options.SendStackTraceToClients.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Localization.Tests/Tests/NullStringLocalizerAndPathResolverTest.cs b/framework/tests/Bing.Localization.Tests/Tests/NullStringLocalizerAndPathResolverTest.cs new file mode 100644 index 00000000..971bf7af --- /dev/null +++ b/framework/tests/Bing.Localization.Tests/Tests/NullStringLocalizerAndPathResolverTest.cs @@ -0,0 +1,295 @@ +using Bing.Localization; +using Microsoft.Extensions.Localization; +using Shouldly; +using Xunit; + +namespace Bing.Localization.Tests; + +/// +/// 单元测试 +/// +public class NullStringLocalizerTest +{ + private readonly IStringLocalizer _localizer = NullStringLocalizer.Instance; + + // ═══════════════════════════════════════════════════════════ + // Instance 单例 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:NullStringLocalizer.Instance 是单例,多次访问返回同一引用,避免重复实例化开销。 + /// + [Fact] + public void Instance_ShouldBeSingleton() + { + // Arrange & Act + var a = NullStringLocalizer.Instance; + var b = NullStringLocalizer.Instance; + + // Assert + a.ShouldNotBeNull(); + ReferenceEquals(a, b).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // this[string name] 索引器 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:按名称索引时,返回的 LocalizedString.Name 和 Value 应等于传入的 name, + /// ResourceNotFound 应为 true(空本地化器不存储任何资源)。 + /// + [Fact] + public void Indexer_ByName_ShouldReturnNameAsValueWithResourceNotFound() + { + // Arrange & Act + var result = _localizer["Hello"]; + + // Assert + result.Name.ShouldBe("Hello"); + result.Value.ShouldBe("Hello"); + result.ResourceNotFound.ShouldBeTrue(); + } + + /// + /// 测试目的:空 name 应正常处理,返回空字符串,不抛异常。 + /// + [Fact] + public void Indexer_EmptyName_ShouldReturnEmptyWithoutThrowing() + { + // Arrange & Act + var result = _localizer[string.Empty]; + + // Assert + result.Name.ShouldBe(string.Empty); + result.Value.ShouldBe(string.Empty); + } + + // ═══════════════════════════════════════════════════════════ + // this[string name, params object[] arguments] 索引器 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:带参数索引器应对格式字符串 + 参数做 string.Format,Name 和 Value 均为格式化结果。 + /// + [Fact] + public void Indexer_WithArguments_ShouldFormatNameTemplate() + { + // Arrange & Act + var result = _localizer["用户 {0} 的订单 {1}", "u-001", 42]; + + // Assert + result.Name.ShouldBe("用户 u-001 的订单 42"); + result.Value.ShouldBe("用户 u-001 的订单 42"); + result.ResourceNotFound.ShouldBeTrue(); + } + + /// + /// 测试目的:无参数时带参数索引器应与普通索引器等效,返回原始 name。 + /// + [Fact] + public void Indexer_WithNoArguments_ShouldReturnOriginalName() + { + // Arrange & Act + var result = _localizer["Welcome", Array.Empty()]; + + // Assert + result.Name.ShouldBe("Welcome"); + result.Value.ShouldBe("Welcome"); + } + + // ═══════════════════════════════════════════════════════════ + // GetAllStrings + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:GetAllStrings(false) 应返回空集合,空本地化器没有任何已知资源。 + /// + [Fact] + public void GetAllStrings_WithoutParentCultures_ShouldReturnEmpty() + { + // Arrange & Act + var result = _localizer.GetAllStrings(includeParentCultures: false).ToList(); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(0); + } + + /// + /// 测试目的:GetAllStrings(true) 也应返回空集合(即使请求父文化资源)。 + /// + [Fact] + public void GetAllStrings_IncludingParentCultures_ShouldAlsoReturnEmpty() + { + // Arrange & Act + var result = _localizer.GetAllStrings(includeParentCultures: true).ToList(); + + // Assert + result.Count.ShouldBe(0); + } +} + +/// +/// 单元测试 +/// +public class PathResolverTest +{ + private readonly PathResolver _resolver = new(); + + // ═══════════════════════════════════════════════════════════ + // GetRootNamespace + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:null 程序集传入时应抛出 ArgumentNullException(CheckNull 防御)。 + /// + [Fact] + public void GetRootNamespace_WithNullAssembly_ShouldThrowArgumentNullException() + { + // Arrange & Act & Assert + Should.Throw(() => _resolver.GetRootNamespace(null)); + } + + /// + /// 测试目的:无 RootNamespaceAttribute 时,应返回程序集的 GetName().Name。 + /// + [Fact] + public void GetRootNamespace_WithoutAttribute_ShouldReturnAssemblySimpleName() + { + // Arrange + var assembly = typeof(PathResolverTest).Assembly; + + // Act + var result = _resolver.GetRootNamespace(assembly); + + // Assert + result.ShouldBe(assembly.GetName().Name); + } + + // ═══════════════════════════════════════════════════════════ + // GetResourcesRootPath + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:assembly 为 null 时,直接返回 rootPath,不抛异常。 + /// + [Fact] + public void GetResourcesRootPath_WithNullAssembly_ShouldReturnRootPath() + { + // Arrange & Act + var result = _resolver.GetResourcesRootPath(null, "Resources"); + + // Assert + result.ShouldBe("Resources"); + } + + /// + /// 测试目的:程序集无 ResourceLocationAttribute 时,应返回传入的 rootPath。 + /// + [Fact] + public void GetResourcesRootPath_WithoutAttribute_ShouldReturnProvidedRootPath() + { + // Arrange + var assembly = typeof(PathResolverTest).Assembly; + + // Act + var result = _resolver.GetResourcesRootPath(assembly, "i18n"); + + // Assert + result.ShouldBe("i18n"); + } + + // ═══════════════════════════════════════════════════════════ + // GetResourcesBaseName + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:typeFullName 包含 rootNamespace 前缀时,应去掉前缀及分隔符 "."。 + /// + [Fact] + public void GetResourcesBaseName_ShouldStripRootNamespacePrefix() + { + // Arrange + var assembly = typeof(PathResolverTest).Assembly; + var rootNamespace = _resolver.GetRootNamespace(assembly); + var typeFullName = $"{rootNamespace}.Models.Product"; + + // Act + var result = _resolver.GetResourcesBaseName(assembly, typeFullName); + + // Assert + result.ShouldBe("Models.Product"); + } + + /// + /// 测试目的:typeFullName 与 rootNamespace 完全相同时,应返回空字符串。 + /// + [Fact] + public void GetResourcesBaseName_WhenTypeIsRootNamespace_ShouldReturnEmpty() + { + // Arrange + var assembly = typeof(PathResolverTest).Assembly; + var rootNamespace = _resolver.GetRootNamespace(assembly); + + // Act + var result = _resolver.GetResourcesBaseName(assembly, rootNamespace); + + // Assert + result.ShouldBe(string.Empty); + } + + // ═══════════════════════════════════════════════════════════ + // GetJsonResourcePath + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:有效 baseName 时,路径应包含 "{baseName}.{culture}.json" 片段。 + /// + [Fact] + public void GetJsonResourcePath_WithBaseName_ShouldContainBaseNameAndCulture() + { + // Arrange + var culture = new System.Globalization.CultureInfo("zh-CN"); + + // Act + var result = _resolver.GetJsonResourcePath("Resources", "Models.Product", culture); + + // Assert + result.ShouldContain("Models.Product.zh-CN.json"); + } + + /// + /// 测试目的:baseName 为空时,路径应仅包含 "{culture}.json"(不带基名称前缀)。 + /// + [Fact] + public void GetJsonResourcePath_WithEmptyBaseName_ShouldContainOnlyCulture() + { + // Arrange + var culture = new System.Globalization.CultureInfo("en-US"); + + // Act + var result = _resolver.GetJsonResourcePath("Resources", string.Empty, culture); + + // Assert + result.ShouldEndWith("en-US.json"); + result.ShouldNotContain(".en-US.json"); + } + + /// + /// 测试目的:baseName 含内部类分隔符 '+' 时,应被替换为 '.',生成正确的文件名。 + /// + [Fact] + public void GetJsonResourcePath_WithInnerClassSeparator_ShouldReplacePlusWithDot() + { + // Arrange + var culture = new System.Globalization.CultureInfo("zh-CN"); + + // Act + var result = _resolver.GetJsonResourcePath("Resources", "Models+Product", culture); + + // Assert + result.ShouldContain("Models.Product.zh-CN.json"); + result.ShouldNotContain("+"); + } +} diff --git a/framework/tests/Bing.Logging.Serilog.Tests/Bing.Logging.Serilog.Tests.csproj b/framework/tests/Bing.Logging.Serilog.Tests/Bing.Logging.Serilog.Tests.csproj new file mode 100644 index 00000000..9f5c452b --- /dev/null +++ b/framework/tests/Bing.Logging.Serilog.Tests/Bing.Logging.Serilog.Tests.csproj @@ -0,0 +1,19 @@ + + + + + Bing.Logging.Tests + false + + + + + + + + + + + + + diff --git a/framework/tests/Bing.Logging.Serilog.Tests/Internals/LogLevelSwitcherTest.cs b/framework/tests/Bing.Logging.Serilog.Tests/Internals/LogLevelSwitcherTest.cs new file mode 100644 index 00000000..3b4bc1e6 --- /dev/null +++ b/framework/tests/Bing.Logging.Serilog.Tests/Internals/LogLevelSwitcherTest.cs @@ -0,0 +1,204 @@ +using Bing.Logging.Serilog.Internals; +using Serilog.Events; + +namespace Bing.Logging.Tests.Internals; + +/// +/// 单元测试 +/// +public class LogLevelSwitcherTest +{ + // ═══════════════════════════════════════════════════════════ + // Switch(LogEventLevel) → string + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Verbose 级别应映射到 MS 日志 "Trace"。 + /// + [Fact] + public void Switch_Verbose_ShouldReturnTrace() + { + LogLevelSwitcher.Switch(LogEventLevel.Verbose).ShouldBe("Trace"); + } + + /// + /// 测试目的:Debug 级别应映射到 "Debug"。 + /// + [Fact] + public void Switch_Debug_ShouldReturnDebug() + { + LogLevelSwitcher.Switch(LogEventLevel.Debug).ShouldBe("Debug"); + } + + /// + /// 测试目的:Information 级别应映射到 "Information"。 + /// + [Fact] + public void Switch_Information_ShouldReturnInformation() + { + LogLevelSwitcher.Switch(LogEventLevel.Information).ShouldBe("Information"); + } + + /// + /// 测试目的:Warning 级别应映射到 "Warning"。 + /// + [Fact] + public void Switch_Warning_ShouldReturnWarning() + { + LogLevelSwitcher.Switch(LogEventLevel.Warning).ShouldBe("Warning"); + } + + /// + /// 测试目的:Error 级别应映射到 "Error"。 + /// + [Fact] + public void Switch_Error_ShouldReturnError() + { + LogLevelSwitcher.Switch(LogEventLevel.Error).ShouldBe("Error"); + } + + /// + /// 测试目的:Fatal 级别应映射到 MS 日志 "Critical"。 + /// + [Fact] + public void Switch_Fatal_ShouldReturnCritical() + { + LogLevelSwitcher.Switch(LogEventLevel.Fatal).ShouldBe("Critical"); + } + + /// + /// 测试目的:未知 Serilog 级别(枚举范围外)应返回 null。 + /// + [Fact] + public void Switch_UnknownLevel_ShouldReturnNull() + { + LogLevelSwitcher.Switch((LogEventLevel)999).ShouldBeNull(); + } + + /// + /// 测试目的:所有标准 Serilog 级别均能正确映射(参数化验证)。 + /// + [Theory] + [InlineData(LogEventLevel.Verbose, "Trace")] + [InlineData(LogEventLevel.Debug, "Debug")] + [InlineData(LogEventLevel.Information, "Information")] + [InlineData(LogEventLevel.Warning, "Warning")] + [InlineData(LogEventLevel.Error, "Error")] + [InlineData(LogEventLevel.Fatal, "Critical")] + public void Switch_AllSerilogLevels_ShouldMapCorrectly(LogEventLevel level, string expected) + { + LogLevelSwitcher.Switch(level).ShouldBe(expected); + } + + // ═══════════════════════════════════════════════════════════ + // Switch(string) → LogEventLevel + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:字符串 "Trace" 应反向映射到 Verbose 级别。 + /// + [Fact] + public void Switch_TraceString_ShouldReturnVerbose() + { + LogLevelSwitcher.Switch("Trace").ShouldBe(LogEventLevel.Verbose); + } + + /// + /// 测试目的:字符串 "Debug" 应反向映射到 Debug 级别。 + /// + [Fact] + public void Switch_DebugString_ShouldReturnDebug() + { + LogLevelSwitcher.Switch("Debug").ShouldBe(LogEventLevel.Debug); + } + + /// + /// 测试目的:字符串 "Information" 应映射到 Information 级别。 + /// + [Fact] + public void Switch_InformationString_ShouldReturnInformation() + { + LogLevelSwitcher.Switch("Information").ShouldBe(LogEventLevel.Information); + } + + /// + /// 测试目的:字符串 "Warning" 应映射到 Warning 级别。 + /// + [Fact] + public void Switch_WarningString_ShouldReturnWarning() + { + LogLevelSwitcher.Switch("Warning").ShouldBe(LogEventLevel.Warning); + } + + /// + /// 测试目的:字符串 "Error" 应映射到 Error 级别。 + /// + [Fact] + public void Switch_ErrorString_ShouldReturnError() + { + LogLevelSwitcher.Switch("Error").ShouldBe(LogEventLevel.Error); + } + + /// + /// 测试目的:字符串 "Critical" 应映射到 Fatal 级别。 + /// + [Fact] + public void Switch_CriticalString_ShouldReturnFatal() + { + LogLevelSwitcher.Switch("Critical").ShouldBe(LogEventLevel.Fatal); + } + + /// + /// 测试目的:字符串 "None" 应映射到 Fatal 级别。 + /// + [Fact] + public void Switch_NoneString_ShouldReturnFatal() + { + LogLevelSwitcher.Switch("None").ShouldBe(LogEventLevel.Fatal); + } + + /// + /// 测试目的:Switch 对字符串输入应大小写不敏感(全大写)。 + /// + [Theory] + [InlineData("TRACE", LogEventLevel.Verbose)] + [InlineData("debug", LogEventLevel.Debug)] + [InlineData("INFORMATION", LogEventLevel.Information)] + [InlineData("warning", LogEventLevel.Warning)] + [InlineData("ERROR", LogEventLevel.Error)] + [InlineData("critical", LogEventLevel.Fatal)] + [InlineData("none", LogEventLevel.Fatal)] + public void Switch_StringInput_ShouldBeCaseInsensitive(string input, LogEventLevel expected) + { + LogLevelSwitcher.Switch(input).ShouldBe(expected); + } + + /// + /// 测试目的:未知字符串级别应 fallback 到 Warning(防止未知配置导致日志全量输出)。 + /// + [Fact] + public void Switch_UnknownString_ShouldFallbackToWarning() + { + LogLevelSwitcher.Switch("unknown_level").ShouldBe(LogEventLevel.Warning); + } + + /// + /// 测试目的:双向转换应保持幂等——先转 string,再转回 LogEventLevel,结果与原始级别一致。 + /// + [Theory] + [InlineData(LogEventLevel.Verbose)] + [InlineData(LogEventLevel.Debug)] + [InlineData(LogEventLevel.Information)] + [InlineData(LogEventLevel.Warning)] + [InlineData(LogEventLevel.Error)] + [InlineData(LogEventLevel.Fatal)] + public void Switch_RoundTrip_ShouldRestoreOriginalLevel(LogEventLevel original) + { + // Arrange & Act + var str = LogLevelSwitcher.Switch(original); + var roundTripped = LogLevelSwitcher.Switch(str); + + // Assert + roundTripped.ShouldBe(original); + } +} diff --git a/framework/tests/Bing.Logging.Serilog.Tests/LoggerConfigurationExtensionsTest.cs b/framework/tests/Bing.Logging.Serilog.Tests/LoggerConfigurationExtensionsTest.cs new file mode 100644 index 00000000..a5a8e628 --- /dev/null +++ b/framework/tests/Bing.Logging.Serilog.Tests/LoggerConfigurationExtensionsTest.cs @@ -0,0 +1,195 @@ +using Microsoft.Extensions.Configuration; +using Serilog; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 单元测试。 +/// 通过公共 API 间接验证 LogLevelSwitcher 的映射逻辑。 +/// +public class LoggerConfigurationExtensionsTest +{ + // ═══════════════════════════════════════════════════════════ + // ConfigLogLevel — 参数校验 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:source 为 null 时应抛出 ArgumentNullException, + /// 防止链式调用时静默失败。 + /// + [Fact] + public void ConfigLogLevel_NullSource_ShouldThrowArgumentNullException() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + + // Act & Assert + Should.Throw(() => + ((LoggerConfiguration)null).ConfigLogLevel(config)); + } + + /// + /// 测试目的:configuration 为 null 时应抛出 ArgumentNullException, + /// 确保配置缺失时有明确的错误提示。 + /// + [Fact] + public void ConfigLogLevel_NullConfiguration_ShouldThrowArgumentNullException() + { + // Arrange + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.Throw(() => + loggerConfig.ConfigLogLevel(null)); + } + + /// + /// 测试目的:Logging:LogLevel 节点为空时,ConfigLogLevel 应不抛异常并返回原配置, + /// 确保没有日志配置节时可以安全使用。 + /// + [Fact] + public void ConfigLogLevel_EmptyLoggingSection_ShouldNotThrow() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } + + /// + /// 测试目的:Logging:LogLevel 包含 Default 条目时,ConfigLogLevel 应不抛异常, + /// 验证 Default 键走 MinimumLevel.ControlledBy 分支。 + /// + [Fact] + public void ConfigLogLevel_WithDefaultLevel_ShouldNotThrow() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Logging:LogLevel:Default", "Information" } + }) + .Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } + + /// + /// 测试目的:Logging:LogLevel 包含 Override 条目(非 Default)时,ConfigLogLevel 应不抛异常, + /// 验证 Override 键走 MinimumLevel.Override 分支。 + /// + [Fact] + public void ConfigLogLevel_WithOverrideLevel_ShouldNotThrow() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Logging:LogLevel:Microsoft", "Warning" }, + { "Logging:LogLevel:System", "Error" } + }) + .Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } + + /// + /// 测试目的:同时包含 Default 和 Override 条目时,ConfigLogLevel 应全部处理,不抛异常, + /// 验证混合配置场景。 + /// + [Fact] + public void ConfigLogLevel_WithDefaultAndOverride_ShouldNotThrow() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Logging:LogLevel:Default", "Debug" }, + { "Logging:LogLevel:Microsoft.AspNetCore", "Warning" }, + { "Logging:LogLevel:System", "Error" } + }) + .Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } + + /// + /// 测试目的:ConfigLogLevel 应返回原 LoggerConfiguration 实例(方法链支持), + /// 确保调用者可以继续链式配置。 + /// + [Fact] + public void ConfigLogLevel_ShouldReturnSameLoggerConfiguration() + { + // Arrange + var config = new ConfigurationBuilder().Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act + var result = loggerConfig.ConfigLogLevel(config); + + // Assert + result.ShouldBeSameAs(loggerConfig); + } + + // ═══════════════════════════════════════════════════════════ + // LogLevelSwitcher 通过 ConfigLogLevel 间接验证(各级别映射) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Logging:LogLevel 支持 Trace / Debug / Information / Warning / Error / Critical / None + /// 全部七个 MSLogging 级别字符串,ConfigLogLevel 均不应抛异常, + /// 确保 LogLevelSwitcher.Switch(string) 覆盖所有有效输入。 + /// + [Theory] + [InlineData("Trace")] + [InlineData("Debug")] + [InlineData("Information")] + [InlineData("Warning")] + [InlineData("Error")] + [InlineData("Critical")] + [InlineData("None")] + public void ConfigLogLevel_AllMSLogLevels_ShouldNotThrow(string level) + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Logging:LogLevel:Default", level } + }) + .Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } + + /// + /// 测试目的:未知日志级别字符串时,Switch 应 fallback 为 Warning 而不是抛异常, + /// 确保配置文件拼写错误不会导致应用启动失败。 + /// + [Fact] + public void ConfigLogLevel_UnknownLevel_ShouldFallbackWithoutThrowing() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Logging:LogLevel:Default", "UnknownLevel" } + }) + .Build(); + var loggerConfig = new LoggerConfiguration(); + + // Act & Assert(fallback 为 Warning,不应抛异常) + Should.NotThrow(() => loggerConfig.ConfigLogLevel(config)); + } +} diff --git a/framework/tests/Bing.Logging.Serilog.Tests/LoggerEnrichmentConfigurationExtensionsTest.cs b/framework/tests/Bing.Logging.Serilog.Tests/LoggerEnrichmentConfigurationExtensionsTest.cs new file mode 100644 index 00000000..ca71fc36 --- /dev/null +++ b/framework/tests/Bing.Logging.Serilog.Tests/LoggerEnrichmentConfigurationExtensionsTest.cs @@ -0,0 +1,215 @@ +using Bing.Logging.Serilog; +using Serilog; +using Serilog.Configuration; +using Serilog.Events; + +namespace Bing.Logging.Tests; + +/// +/// 单元测试 +/// +public class LoggerEnrichmentConfigurationExtensionsTest +{ + // ───────────────────────────────────────────────────────── + // 辅助:获取有效的 LoggerEnrichmentConfiguration + // ───────────────────────────────────────────────────────── + private static LoggerEnrichmentConfiguration GetEnrichConfig() + => new LoggerConfiguration().Enrich; + + // ═══════════════════════════════════════════════════════════ + // WithLogContext + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithLogContext(null) 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithLogContext_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithLogContext(null!)); + } + + /// + /// 测试目的:WithLogContext 对有效 source 应返回非 null 的 LoggerConfiguration。 + /// + [Fact] + public void WithLogContext_WithValidSource_ShouldReturnLoggerConfiguration() + { + var result = GetEnrichConfig().WithLogContext(); + result.ShouldNotBeNull(); + result.ShouldBeOfType(); + } + + // ═══════════════════════════════════════════════════════════ + // WithLogLevel + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithLogLevel(null) 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithLogLevel_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithLogLevel(null!)); + } + + /// + /// 测试目的:WithLogLevel 对有效 source 应返回非 null 的 LoggerConfiguration。 + /// + [Fact] + public void WithLogLevel_WithValidSource_ShouldReturnLoggerConfiguration() + { + var result = GetEnrichConfig().WithLogLevel(); + result.ShouldNotBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // WithProperty + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithProperty(null, kv) 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithProperty_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + var kv = new KeyValuePair("key", "value"); + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithProperty(null!, kv)); + } + + /// + /// 测试目的:WithProperty(source, default) 应抛出 ArgumentNullException(key=null 的默认键值对)。 + /// + [Fact] + public void WithProperty_WhenKeyValueIsDefault_ShouldThrowArgumentNullException() + { + Should.Throw(() => + GetEnrichConfig().WithProperty(default)); + } + + /// + /// 测试目的:WithProperty 对有效键值对应返回非 null 的 LoggerConfiguration。 + /// + [Fact] + public void WithProperty_WithValidKeyValue_ShouldReturnLoggerConfiguration() + { + var kv = new KeyValuePair("env", "production"); + var result = GetEnrichConfig().WithProperty(kv); + result.ShouldNotBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // WithFunction(key, Func) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithFunction(null, key, func) 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithFunction_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithFunction(null!, "key", () => "val")); + } + + /// + /// 测试目的:WithFunction 对 key 为 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithFunction_WhenKeyIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + GetEnrichConfig().WithFunction(null!, () => "val")); + } + + /// + /// 测试目的:WithFunction 对 key 为空字符串应抛出 ArgumentNullException。 + /// + [Fact] + public void WithFunction_WhenKeyIsEmpty_ShouldThrowArgumentNullException() + { + Should.Throw(() => + GetEnrichConfig().WithFunction("", () => "val")); + } + + /// + /// 测试目的:WithFunction 对 func 为 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithFunction_WhenFuncIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + GetEnrichConfig().WithFunction("key", (Func)null!)); + } + + /// + /// 测试目的:WithFunction 对有效参数应返回非 null 的 LoggerConfiguration。 + /// + [Fact] + public void WithFunction_WithValidArgs_ShouldReturnLoggerConfiguration() + { + var result = GetEnrichConfig().WithFunction("requestId", () => Guid.NewGuid().ToString()); + result.ShouldNotBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // WithFunction(key, Func) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithFunction(source, key, Func<LogEvent,string>) 对 null source 应抛出。 + /// + [Fact] + public void WithFunctionLogEvent_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithFunction(null!, "key", (LogEvent _) => "val")); + } + + /// + /// 测试目的:WithFunction(source, key, Func<LogEvent,string>) 对有效参数应返回 LoggerConfiguration。 + /// + [Fact] + public void WithFunctionLogEvent_WithValidArgs_ShouldReturnLoggerConfiguration() + { + var result = GetEnrichConfig().WithFunction("level", (LogEvent e) => e.Level.ToString()); + result.ShouldNotBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // WithEnvironment + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:WithEnvironment(null, varName) 应抛出 ArgumentNullException。 + /// + [Fact] + public void WithEnvironment_WhenSourceIsNull_ShouldThrowArgumentNullException() + { + Should.Throw(() => + LoggerEnrichmentConfigurationExtensions.WithEnvironment(null!, "ASPNETCORE_ENVIRONMENT")); + } + + /// + /// 测试目的:WithEnvironment 对空变量名应抛出 ArgumentNullException。 + /// + [Fact] + public void WithEnvironment_WhenVariableNameIsEmpty_ShouldThrowArgumentNullException() + { + Should.Throw(() => + GetEnrichConfig().WithEnvironment("")); + } + + /// + /// 测试目的:WithEnvironment 对有效变量名应返回非 null 的 LoggerConfiguration。 + /// + [Fact] + public void WithEnvironment_WithValidName_ShouldReturnLoggerConfiguration() + { + var result = GetEnrichConfig().WithEnvironment("ASPNETCORE_ENVIRONMENT"); + result.ShouldNotBeNull(); + } +} diff --git a/framework/tests/Bing.Logging.Serilog.Tests/global-usings.cs b/framework/tests/Bing.Logging.Serilog.Tests/global-usings.cs new file mode 100644 index 00000000..5c689f80 --- /dev/null +++ b/framework/tests/Bing.Logging.Serilog.Tests/global-usings.cs @@ -0,0 +1,4 @@ +global using System; +global using System.Collections.Generic; +global using Xunit; +global using Shouldly; diff --git a/framework/tests/Bing.Logging.Tests/Core/BingLoggingOptionsTest.cs b/framework/tests/Bing.Logging.Tests/Core/BingLoggingOptionsTest.cs new file mode 100644 index 00000000..066c6352 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/Core/BingLoggingOptionsTest.cs @@ -0,0 +1,69 @@ +using Bing.Logging; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests.Core; + +/// +/// 单元测试 +/// +public class BingLoggingOptionsTest +{ + /// + /// 测试目的:默认构造后 ClearProviders 应为 false,不主动清除已有日志提供程序。 + /// + [Fact] + public void Default_ClearProviders_ShouldBeFalse() + { + // Arrange & Act + var options = new BingLoggingOptions(); + + // Assert + options.ClearProviders.ShouldBeFalse(); + } + + /// + /// 测试目的:将 ClearProviders 设置为 true 后应可读取到修改值。 + /// + [Fact] + public void SetClearProviders_True_ShouldBeTrue() + { + // Arrange + var options = new BingLoggingOptions(); + + // Act + options.ClearProviders = true; + + // Assert + options.ClearProviders.ShouldBeTrue(); + } + + /// + /// 测试目的:RegisterExtension(null) 应抛出 ArgumentNullException。 + /// + [Fact] + public void RegisterExtension_Null_ShouldThrowArgumentNullException() + { + // Arrange + var options = new BingLoggingOptions(); + + // Act & Assert + Should.Throw(() => options.RegisterExtension(null)); + } + + /// + /// 测试目的:RegisterExtension 传入有效扩展时不抛异常。 + /// + [Fact] + public void RegisterExtension_ValidExtension_ShouldNotThrow() + { + // Arrange + var options = new BingLoggingOptions(); + var extensionMock = new Mock(); + + // Act & Assert + Should.NotThrow(() => options.RegisterExtension(extensionMock.Object)); + } +} diff --git a/framework/tests/Bing.Logging.Tests/Core/LogCallerInfoTest.cs b/framework/tests/Bing.Logging.Tests/Core/LogCallerInfoTest.cs new file mode 100644 index 00000000..9dc7db0a --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/Core/LogCallerInfoTest.cs @@ -0,0 +1,109 @@ +using Bing.Logging.Core.Callers; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 单元测试 +/// +public class LogCallerInfoTest +{ + // ═══════════════════════════════════════════════════════════ + // LogCallerInfo 构造与属性 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用三个参数构造时,MemberName/FilePath/LineNumber 应被正确保存。 + /// + [Fact] + public void LogCallerInfo_FullCtor_ShouldSetAllProperties() + { + // Arrange & Act + var info = new LogCallerInfo("MyMethod", "/src/MyFile.cs", 42); + + // Assert + info.MemberName.ShouldBe("MyMethod"); + info.FilePath.ShouldBe("/src/MyFile.cs"); + info.LineNumber.ShouldBe(42); + } + + /// + /// 测试目的:仅提供 memberName 时,FilePath 默认 null,LineNumber 默认 0。 + /// + [Fact] + public void LogCallerInfo_OnlyMemberName_DefaultsForOthers() + { + // Arrange & Act + var info = new LogCallerInfo("SomeMethod"); + + // Assert + info.MemberName.ShouldBe("SomeMethod"); + info.FilePath.ShouldBeNull(); + info.LineNumber.ShouldBe(0); + } + + /// + /// 测试目的:ToParams() 返回的动态对象应包含三个属性,值与构造参数一致。 + /// + [Fact] + public void LogCallerInfo_ToParams_ShouldExposeAllFields() + { + // Arrange + var info = new LogCallerInfo("Execute", "/app/Service.cs", 99); + + // Act + dynamic p = info.ToParams(); + + // Assert + ((string)p.MemberName).ShouldBe("Execute"); + ((string)p.FilePath).ShouldBe("/app/Service.cs"); + ((int)p.LineNumber).ShouldBe(99); + } + + // ═══════════════════════════════════════════════════════════ + // NullLogCallerInfo 空实现 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:NullLogCallerInfo.Instance 是单例,所有属性均为默认值(null/0)。 + /// + [Fact] + public void NullLogCallerInfo_Instance_ShouldHaveDefaultValues() + { + // Arrange & Act + var info = NullLogCallerInfo.Instance; + + // Assert + info.MemberName.ShouldBeNull(); + info.FilePath.ShouldBeNull(); + info.LineNumber.ShouldBe(0); + } + + /// + /// 测试目的:NullLogCallerInfo.ToParams() 应返回 null,不抛异常。 + /// + [Fact] + public void NullLogCallerInfo_ToParams_ShouldReturnNull() + { + // Arrange & Act + var result = NullLogCallerInfo.Instance.ToParams(); + + // Assert + ((object)result).ShouldBeNull(); + } + + /// + /// 测试目的:NullLogCallerInfo 应实现 ILogCallerInfo 接口,可被多态使用。 + /// + [Fact] + public void NullLogCallerInfo_ShouldImplementILogCallerInfo() + { + // Arrange & Act + ILogCallerInfo info = NullLogCallerInfo.Instance; + + // Assert + info.ShouldNotBeNull(); + info.ShouldBeAssignableTo(); + } +} diff --git a/framework/tests/Bing.Logging.Tests/Core/LogEventContextAndDescriptorTest.cs b/framework/tests/Bing.Logging.Tests/Core/LogEventContextAndDescriptorTest.cs new file mode 100644 index 00000000..732b3f6e --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/Core/LogEventContextAndDescriptorTest.cs @@ -0,0 +1,378 @@ +using Bing.Logging.Core; +using Bing.Logging.Core.Callers; +using Bing.Logging.ExtraSupports; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests.Core; + +/// +/// 单元测试 +/// +public class LogEventContextAndDescriptorTest +{ + // ═══════════════════════════════════════════════════════════ + // LogEventContext - SetTags(通过 ExposeScopeState 验证,因为 Tags 是 internal) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:未设置标签时 ExposeScopeState 不应包含 Tags 键。 + /// + [Fact] + public void LogEventContext_NoTags_ExposeScopeStateShouldNotContainTagsKey() + { + // Arrange & Act + var ctx = new LogEventContext(); + + // Assert + var state = ctx.ExposeScopeState(); + state.ContainsKey(ContextDataTypes.Tags).ShouldBeFalse(); + } + + /// + /// 测试目的:SetTags 后 ExposeScopeState 应包含 Tags 键,空白标签被忽略(不产生键)。 + /// + [Fact] + public void SetTags_WithValidAndBlankTags_ExposeScopeStateShouldContainTagsKey() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetTags("tag-a", "", " ", "tag-b"); + var state = ctx.ExposeScopeState(); + + // Assert + state.ShouldContainKey(ContextDataTypes.Tags); + } + + /// + /// 测试目的:只传入空白标签时,ExposeScopeState 不应包含 Tags 键。 + /// + [Fact] + public void SetTags_OnlyBlankTags_ExposeScopeStateShouldNotHaveTagsKey() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetTags("", " "); + var state = ctx.ExposeScopeState(); + + // Assert + state.ContainsKey(ContextDataTypes.Tags).ShouldBeFalse(); + } + + /// + /// 测试目的:SetTags(null) 不抛异常,ExposeScopeState 中也不含 Tags 键。 + /// + [Fact] + public void SetTags_WithNull_ShouldNotThrowAndNoTagsKey() + { + // Arrange + var ctx = new LogEventContext(); + + // Act & Assert + Should.NotThrow(() => ctx.SetTags(null)); + ctx.ExposeScopeState().ContainsKey(ContextDataTypes.Tags).ShouldBeFalse(); + } + + /// + /// 测试目的:SetTags 应返回 this,支持链式调用。 + /// + [Fact] + public void SetTags_ShouldReturnSelf() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + var result = ctx.SetTags("t1"); + + // Assert + ReferenceEquals(result, ctx).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // LogEventContext - SetParameter / SetParameters + // (Parameters 是 internal,只验证调用链式返回与不抛异常) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SetParameter 传入有效对象不抛异常,并返回 this。 + /// + [Fact] + public void SetParameter_WithValue_ShouldReturnSelf() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + var result = ctx.SetParameter(new { userId = "u-001" }); + + // Assert + Should.NotThrow(() => { }); + ReferenceEquals(result, ctx).ShouldBeTrue(); + } + + /// + /// 测试目的:SetParameter(null) 不抛异常,返回 this。 + /// + [Fact] + public void SetParameter_WithNull_ShouldReturnSelfWithoutThrow() + { + // Arrange + var ctx = new LogEventContext(); + + // Act & Assert + LogEventContext result = null; + Should.NotThrow(() => result = ctx.SetParameter(null)); + ReferenceEquals(result, ctx).ShouldBeTrue(); + } + + /// + /// 测试目的:SetParameters 批量调用不抛异常,返回 this。 + /// + [Fact] + public void SetParameters_WithMultiple_ShouldReturnSelf() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + var result = ctx.SetParameters("p1", 2, true); + + // Assert + ReferenceEquals(result, ctx).ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // LogEventContext - ExtraProperties + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:SetExtraProperty 后 ExtraProperties 应包含以 ExtraProperty 前缀开头的键。 + /// + [Fact] + public void SetExtraProperty_WithValidNameAndValue_ShouldBeStored() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetExtraProperty("requestId", "req-001"); + + // Assert + ctx.ExtraProperties.ShouldNotBeNull(); + ctx.ExtraProperties.ContainsKey($"{ContextDataTypes.ExtraProperty}requestId").ShouldBeTrue(); + } + + /// + /// 测试目的:SetExtraProperty(name=null 或空白) 应被忽略,ExtraProperties 保持为空。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetExtraProperty_BlankName_ShouldBeIgnored(string name) + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetExtraProperty(name, "value"); + + // Assert + ctx.ExtraProperties.Any().ShouldBeFalse(); + } + + /// + /// 测试目的:SetExtraProperty(value=null) 应被忽略,ExtraProperties 保持为空。 + /// + [Fact] + public void SetExtraProperty_NullValue_ShouldBeIgnored() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetExtraProperty("key", null); + + // Assert + ctx.ExtraProperties.Any().ShouldBeFalse(); + } + + /// + /// 测试目的:SetExtraProperty 后 ExposeScopeState 中应包含对应的扩展属性键。 + /// + [Fact] + public void SetExtraProperty_ExposeScopeStateShouldContainKey() + { + // Arrange + var ctx = new LogEventContext(); + ctx.SetExtraProperty("correlationId", "corr-abc"); + + // Act + var state = ctx.ExposeScopeState(); + + // Assert + state.ShouldContainKey($"{ContextDataTypes.ExtraProperty}correlationId"); + } + + // ═══════════════════════════════════════════════════════════ + // LogEventContext - CallerInfo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认 LogCallerInfo 应为 NullLogCallerInfo(未设置时)。 + /// + [Fact] + public void LogEventContext_Default_CallerInfoShouldBeNullLogCallerInfo() + { + // Arrange & Act + var ctx = new LogEventContext(); + + // Assert + ctx.LogCallerInfo.ShouldBeOfType(); + } + + /// + /// 测试目的:SetCallerInfo 后 LogCallerInfo 应为 LogCallerInfo 类型,包含传入的成员名与行号。 + /// + [Fact] + public void SetCallerInfo_WithMemberName_ShouldSetLogCallerInfo() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetCallerInfo("ProcessOrder", "/app/OrderService.cs", 55); + + // Assert + ctx.LogCallerInfo.ShouldBeOfType(); + ctx.LogCallerInfo.MemberName.ShouldBe("ProcessOrder"); + ctx.LogCallerInfo.LineNumber.ShouldBe(55); + } + + /// + /// 测试目的:SetCallerInfo 所有参数均为默认(空/0)时不应覆盖 NullLogCallerInfo。 + /// + [Fact] + public void SetCallerInfo_AllDefaults_ShouldKeepNullLogCallerInfo() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + ctx.SetCallerInfo(); + + // Assert + ctx.LogCallerInfo.ShouldBeOfType(); + } + + /// + /// 测试目的:SetCallerInfo 设置后 ExposeScopeState 应包含 CallerInfo 键。 + /// + [Fact] + public void SetCallerInfo_ExposeScopeStateShouldContainCallerInfoKey() + { + // Arrange + var ctx = new LogEventContext(); + ctx.SetCallerInfo("Run", "/app/Runner.cs", 10); + + // Act + var state = ctx.ExposeScopeState(); + + // Assert + state.ShouldContainKey(ContextDataTypes.CallerInfo); + } + + // ═══════════════════════════════════════════════════════════ + // LogEventContext - ExposeScopeState + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:设置标签后 ExposeScopeState 应包含 Tags 键。 + /// + [Fact] + public void ExposeScopeState_WithTags_ShouldContainTagsKey() + { + // Arrange + var ctx = new LogEventContext(); + ctx.SetTags("order", "payment"); + + // Act + var state = ctx.ExposeScopeState(); + + // Assert + state.ShouldContainKey(ContextDataTypes.Tags); + } + + /// + /// 测试目的:无任何数据时 ExposeScopeState 应返回空字典。 + /// + [Fact] + public void ExposeScopeState_WithNoData_ShouldReturnEmpty() + { + // Arrange + var ctx = new LogEventContext(); + + // Act + var state = ctx.ExposeScopeState(); + + // Assert + state.Count.ShouldBe(0); + } + + // ═══════════════════════════════════════════════════════════ + // LogEventDescriptor + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Context 不为 null,保证使用时无空引用异常。 + /// + [Fact] + public void LogEventDescriptor_Default_ContextShouldNotBeNull() + { + // Arrange & Act + var descriptor = new LogEventDescriptor(); + + // Assert + descriptor.Context.ShouldNotBeNull(); + } + + /// + /// 测试目的:TraceId/TraceName/BusinessTraceId 属性可读写,用于全链路追踪。 + /// + [Fact] + public void LogEventDescriptor_TraceProperties_ShouldBeReadWritable() + { + // Arrange + var descriptor = new LogEventDescriptor(); + + // Act + descriptor.TraceId = "trace-abc"; + descriptor.TraceName = "OrderFlow"; + descriptor.BusinessTraceId = "biz-001"; + + // Assert + descriptor.TraceId.ShouldBe("trace-abc"); + descriptor.TraceName.ShouldBe("OrderFlow"); + descriptor.BusinessTraceId.ShouldBe("biz-001"); + } + + /// + /// 测试目的:默认构造后 TraceId/TraceName/BusinessTraceId 均为 null。 + /// + [Fact] + public void LogEventDescriptor_Default_AllTraceFieldsShouldBeNull() + { + // Arrange & Act + var descriptor = new LogEventDescriptor(); + + // Assert + descriptor.TraceId.ShouldBeNull(); + descriptor.TraceName.ShouldBeNull(); + descriptor.BusinessTraceId.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.Logging.Tests/ExtraSupports/ContextDataTest.cs b/framework/tests/Bing.Logging.Tests/ExtraSupports/ContextDataTest.cs new file mode 100644 index 00000000..bf70eb63 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/ExtraSupports/ContextDataTest.cs @@ -0,0 +1,245 @@ +using Bing.Logging.ExtraSupports; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests.ExtraSupports; + +/// +/// 单元测试 +/// +public class ContextDataTest +{ + // ═══════════════════════════════════════════════════════════ + // AddItem + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddItem 在 value 为 null 时应静默忽略,不向集合添加任何元素。 + /// + [Fact] + public void AddItem_WhenValueIsNull_ShouldNotAdd() + { + // Arrange + var ctx = new ContextData(); + + // Act + ctx.AddItem("key", null!); + + // Assert + ctx.Count.ShouldBe(0); + } + + /// + /// 测试目的:AddItem 在 key 不存在时应正确添加一条新记录。 + /// + [Fact] + public void AddItem_WhenKeyNotExists_ShouldAddSuccessfully() + { + // Arrange + var ctx = new ContextData(); + + // Act + ctx.AddItem("name", "Alice"); + + // Assert + ctx.Count.ShouldBe(1); + ctx["name"].Value.ShouldBe("Alice"); + } + + /// + /// 测试目的:AddItem 在 key 已存在时应抛出 ArgumentException,不允许重复键。 + /// + [Fact] + public void AddItem_WhenKeyAlreadyExists_ShouldThrowArgumentException() + { + // Arrange + var ctx = new ContextData(); + ctx.AddItem("name", "Alice"); + + // Act & Assert + Should.Throw(() => ctx.AddItem("name", "Bob")); + } + + /// + /// 测试目的:AddItem 键大小写不敏感(OrdinalIgnoreCase),重复大小写不同的键也应抛出异常。 + /// + [Fact] + public void AddItem_IsCaseInsensitive_ShouldThrowOnDuplicateDifferentCase() + { + // Arrange + var ctx = new ContextData(); + ctx.AddItem("Name", "Alice"); + + // Act & Assert + Should.Throw(() => ctx.AddItem("name", "Bob")); + } + + /// + /// 测试目的:AddItem 传入 ContextDataItem 值时,应直接以 item.Name 作为键插入。 + /// + [Fact] + public void AddItem_WhenValueIsContextDataItem_ShouldUseItemName() + { + // Arrange + var ctx = new ContextData(); + var item = new ContextDataItem("itemKey", typeof(string), "itemValue"); + + // Act + ctx.AddItem("otherKey", item); + + // Assert + ctx.ContainsKey("itemKey").ShouldBeTrue(); + ctx["itemKey"].Value.ShouldBe("itemValue"); + } + + // ═══════════════════════════════════════════════════════════ + // AddOrUpdateItem + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddOrUpdateItem 在 value 为 null 时应静默忽略,不修改集合。 + /// + [Fact] + public void AddOrUpdateItem_WhenValueIsNull_ShouldNotAdd() + { + // Arrange + var ctx = new ContextData(); + + // Act + ctx.AddOrUpdateItem("key", null!); + + // Assert + ctx.Count.ShouldBe(0); + } + + /// + /// 测试目的:AddOrUpdateItem 在 name 为空白字符串时应静默忽略。 + /// + [Fact] + public void AddOrUpdateItem_WhenNameIsWhiteSpace_ShouldNotAdd() + { + // Arrange + var ctx = new ContextData(); + + // Act + ctx.AddOrUpdateItem(" ", "value"); + + // Assert + ctx.Count.ShouldBe(0); + } + + /// + /// 测试目的:AddOrUpdateItem 在键不存在时应新增。 + /// + [Fact] + public void AddOrUpdateItem_WhenKeyNotExists_ShouldAdd() + { + // Arrange + var ctx = new ContextData(); + + // Act + ctx.AddOrUpdateItem("age", 30); + + // Assert + ctx.ContainsKey("age").ShouldBeTrue(); + ctx["age"].Value.ShouldBe(30); + } + + /// + /// 测试目的:AddOrUpdateItem 在键已存在时应更新旧值,而不是抛出异常。 + /// + [Fact] + public void AddOrUpdateItem_WhenKeyExists_ShouldUpdateValue() + { + // Arrange + var ctx = new ContextData(); + ctx.AddOrUpdateItem("status", "pending"); + + // Act + ctx.AddOrUpdateItem("status", "done"); + + // Assert + ctx["status"].Value.ShouldBe("done"); + ctx.Count.ShouldBe(1); // 仍然只有一条 + } + + // ═══════════════════════════════════════════════════════════ + // Copy(克隆) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Copy() 返回一个内容相同的新实例,修改副本不影响原始对象。 + /// + [Fact] + public void Copy_ShouldReturnIndependentCopy() + { + // Arrange + var ctx = new ContextData(); + ctx.AddItem("x", 1); + + // Act + var copy = ctx.Copy(); + copy.AddOrUpdateItem("x", 99); // 修改副本 + + // Assert + ctx["x"].Value.ShouldBe(1); // 原始对象不受影响 + copy["x"].Value.ShouldBe(99); + } + + // ═══════════════════════════════════════════════════════════ + // ToString + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:空 ContextData 的 ToString() 应返回空字符串,不是 "[]"。 + /// + [Fact] + public void ToString_WhenEmpty_ShouldReturnEmptyString() + { + // Arrange + var ctx = new ContextData(); + + // Act + var result = ctx.ToString(); + + // Assert + result.ShouldBe(string.Empty); + } + + /// + /// 测试目的:有可输出项时,ToString() 结果应以 "[" 开头、"]" 结尾,并包含键名。 + /// + [Fact] + public void ToString_WhenHasOutputItems_ShouldContainKeyName() + { + // Arrange + var ctx = new ContextData(); + ctx.AddItem("userId", "user-001"); + + // Act + var result = ctx.ToString(); + + // Assert + result.ShouldStartWith("["); + result.ShouldEndWith("]"); + result.ShouldContain("userId"); + } + + /// + /// 测试目的:output=false 的条目不应出现在 ToString() 结果中。 + /// + [Fact] + public void ToString_WhenItemOutputIsFalse_ShouldNotIncludeItem() + { + // Arrange + var ctx = new ContextData(); + ctx.AddItem("secret", "password", output: false); + + // Act + var result = ctx.ToString(); + + // Assert + // 所有条目 output=false → 结果为空字符串或只有括号 + result.ShouldNotContain("secret"); + } +} diff --git a/framework/tests/Bing.Logging.Tests/ILogExtensionsTest.cs b/framework/tests/Bing.Logging.Tests/ILogExtensionsTest.cs new file mode 100644 index 00000000..03220398 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/ILogExtensionsTest.cs @@ -0,0 +1,409 @@ +using Microsoft.Extensions.Logging; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 扩展方法单元测试 +/// +public class ILogExtensionsTest +{ + /// + /// 构建一个模拟 ILog,通过验证 Message 调用来断言扩展方法行为 + /// + private static (Mock mock, ILog log) BuildMockLog() + { + var mock = new Mock(); + // 支持链式调用:方法返回 mock 自身 + mock.Setup(x => x.Message(It.IsAny(), It.IsAny())).Returns(mock.Object); + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + a(mock.Object); + return mock.Object; + }); + return (mock, mock.Object); + } + + // ═══════════════════════════════════════════════════════════ + // Append + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Append() 在 log 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Append_WhenLogIsNull_ShouldThrowArgumentNullException() + { + // Arrange + ILog nullLog = null!; + + // Act & Assert + Should.Throw(() => nullLog.Append("msg")); + } + + /// + /// 测试目的:Append() 应调用 Message() 一次,并返回 log 自身(支持链式调用)。 + /// + [Fact] + public void Append_WithMessage_ShouldCallMessageOnce_AndReturnLog() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + var returned = log.Append("hello {0}", "world"); + + // Assert + mock.Verify(x => x.Message("hello {0}", "world"), Times.Once); + returned.ShouldBeSameAs(log); + } + + // ═══════════════════════════════════════════════════════════ + // AppendIf + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AppendIf() 在 log 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void AppendIf_WhenLogIsNull_ShouldThrowArgumentNullException() + { + // Arrange + ILog nullLog = null!; + + // Act & Assert + Should.Throw(() => nullLog.AppendIf("msg", true)); + } + + /// + /// 测试目的:AppendIf() 当条件为 true 时,应调用 Message() 一次。 + /// + [Fact] + public void AppendIf_WhenConditionIsTrue_ShouldCallMessage() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + log.AppendIf("conditional msg", true); + + // Assert + mock.Verify(x => x.Message("conditional msg"), Times.Once); + } + + /// + /// 测试目的:AppendIf() 当条件为 false 时,不应调用 Message()。 + /// + [Fact] + public void AppendIf_WhenConditionIsFalse_ShouldNotCallMessage() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + log.AppendIf("should not appear", false); + + // Assert + mock.Verify(x => x.Message(It.IsAny(), It.IsAny()), Times.Never); + } + + // ═══════════════════════════════════════════════════════════ + // AppendLine + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AppendLine() 在 log 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void AppendLine_WhenLogIsNull_ShouldThrowArgumentNullException() + { + // Arrange + ILog nullLog = null!; + + // Act & Assert + Should.Throw(() => nullLog.AppendLine("msg")); + } + + /// + /// 测试目的:AppendLine() 应调用 Message() 两次:一次是消息内容,一次是换行符。 + /// + [Fact] + public void AppendLine_WithMessage_ShouldCallMessageTwice() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + log.AppendLine("line content"); + + // Assert + mock.Verify(x => x.Message("line content"), Times.Once); + mock.Verify(x => x.Message(Environment.NewLine), Times.Once); + } + + // ═══════════════════════════════════════════════════════════ + // AppendLineIf + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AppendLineIf() 当条件为 true 时,应调用两次 Message()(内容 + 换行)。 + /// + [Fact] + public void AppendLineIf_WhenConditionIsTrue_ShouldCallMessageTwice() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + log.AppendLineIf("conditional line", true); + + // Assert + mock.Verify(x => x.Message("conditional line"), Times.Once); + mock.Verify(x => x.Message(Environment.NewLine), Times.Once); + } + + /// + /// 测试目的:AppendLineIf() 当条件为 false 时,不应调用 Message()。 + /// + [Fact] + public void AppendLineIf_WhenConditionIsFalse_ShouldNotCallMessage() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + log.AppendLineIf("should not appear", false); + + // Assert + mock.Verify(x => x.Message(It.IsAny(), It.IsAny()), Times.Never); + } + + // ═══════════════════════════════════════════════════════════ + // Line + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Line() 在 log 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Line_WhenLogIsNull_ShouldThrowArgumentNullException() + { + // Arrange + ILog nullLog = null!; + + // Act & Assert + Should.Throw(() => nullLog.Line()); + } + + /// + /// 测试目的:Line() 应调用 Message(Environment.NewLine) 一次,并返回 log 自身。 + /// + [Fact] + public void Line_ShouldCallMessage_WithNewLine() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + var returned = log.Line(); + + // Assert + mock.Verify(x => x.Message(Environment.NewLine), Times.Once); + returned.ShouldBeSameAs(log); + } + + // ═══════════════════════════════════════════════════════════ + // ExtraProperty / ExtraPropertyIf + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ExtraProperty() 应调用 Set() 委托(最终设置 Context 扩展属性),并返回 log 自身。 + /// + [Fact] + public void ExtraProperty_ShouldCallSet_AndReturnLog() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + var returned = log.ExtraProperty("RequestId", "REQ-001"); + + // Assert + setCalled.ShouldBeTrue(); + returned.ShouldBeSameAs(log); + } + + /// + /// 测试目的:ExtraPropertyIf() 当条件为 true 时,应调用 Set()。 + /// + [Fact] + public void ExtraPropertyIf_WhenConditionIsTrue_ShouldCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + log.ExtraPropertyIf("key", "value", true); + + // Assert + setCalled.ShouldBeTrue(); + } + + /// + /// 测试目的:ExtraPropertyIf() 当条件为 false 时,不应调用 Set(),直接返回 log。 + /// + [Fact] + public void ExtraPropertyIf_WhenConditionIsFalse_ShouldNotCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + var returned = log.ExtraPropertyIf("key", "value", false); + + // Assert + mock.Verify(x => x.Set(It.IsAny>()), Times.Never); + returned.ShouldBeSameAs(log); + } + + // ═══════════════════════════════════════════════════════════ + // Tags / Tag / TagsIf / TagIf + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Tags() 应调用 Set(),并返回 log 自身。 + /// + [Fact] + public void Tags_ShouldCallSet_AndReturnLog() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + var returned = log.Tags("tag1", "tag2"); + + // Assert + setCalled.ShouldBeTrue(); + returned.ShouldBeSameAs(log); + } + + /// + /// 测试目的:TagsIf() 当条件为 true 时,应调用 Set()。 + /// + [Fact] + public void TagsIf_WhenConditionIsTrue_ShouldCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + log.TagsIf(true, "t1"); + + // Assert + setCalled.ShouldBeTrue(); + } + + /// + /// 测试目的:TagsIf() 当条件为 false 时,不应调用 Set(),直接返回 log。 + /// + [Fact] + public void TagsIf_WhenConditionIsFalse_ShouldNotCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + var returned = log.TagsIf(false, "t1"); + + // Assert + mock.Verify(x => x.Set(It.IsAny>()), Times.Never); + returned.ShouldBeSameAs(log); + } + + /// + /// 测试目的:Tag() 应调用 Set(),行为与 Tags() 单参数版本一致。 + /// + [Fact] + public void Tag_ShouldCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + log.Tag("single-tag"); + + // Assert + setCalled.ShouldBeTrue(); + } + + /// + /// 测试目的:TagIf() 当条件为 false 时,不应调用 Set(),直接返回 log。 + /// + [Fact] + public void TagIf_WhenConditionIsFalse_ShouldNotCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + + // Act + var returned = log.TagIf("t", false); + + // Assert + mock.Verify(x => x.Set(It.IsAny>()), Times.Never); + returned.ShouldBeSameAs(log); + } + + /// + /// 测试目的:TagIf() 当条件为 true 时,应调用 Set()。 + /// + [Fact] + public void TagIf_WhenConditionIsTrue_ShouldCallSet() + { + // Arrange + var (mock, log) = BuildMockLog(); + var setCalled = false; + mock.Setup(x => x.Set(It.IsAny>())).Returns((Action a) => + { + setCalled = true; + return mock.Object; + }); + + // Act + log.TagIf("t", true); + + // Assert + setCalled.ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Logging.Tests/LogContextAndFactoryTest.cs b/framework/tests/Bing.Logging.Tests/LogContextAndFactoryTest.cs new file mode 100644 index 00000000..d516ad28 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/LogContextAndFactoryTest.cs @@ -0,0 +1,343 @@ +using Bing.Logging.ExtraSupports; +using Bing.Logging.Core.Callers; +using Microsoft.Extensions.Logging; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 、 +/// +/// 单元测试 +/// +public class LogContextAndFactoryTest +{ + // ═══════════════════════════════════════════════════════════ + // LogContext — 属性与集合初始化 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Data 字典不为 null,Tags 列表不为 null, + /// 防止使用方直接访问时触发 NullReferenceException。 + /// + [Fact] + public void LogContext_Default_DataAndTagsShouldNotBeNull() + { + // Arrange & Act + var ctx = new LogContext(); + + // Assert + ctx.Data.ShouldNotBeNull(); + ctx.Data.Count.ShouldBe(0); + ctx.Tags.ShouldNotBeNull(); + ctx.Tags.Count.ShouldBe(0); + } + + /// + /// 测试目的:所有 string 属性默认应为 null,IsWebEnv 默认应为 false, + /// 确保不携带意外默认值影响上游逻辑。 + /// + [Fact] + public void LogContext_Default_StringPropertiesShouldBeNullAndIsWebEnvFalse() + { + // Arrange & Act + var ctx = new LogContext(); + + // Assert + ctx.TraceId.ShouldBeNull(); + ctx.UserId.ShouldBeNull(); + ctx.TenantId.ShouldBeNull(); + ctx.Application.ShouldBeNull(); + ctx.Environment.ShouldBeNull(); + ctx.Ip.ShouldBeNull(); + ctx.Host.ShouldBeNull(); + ctx.Browser.ShouldBeNull(); + ctx.Url.ShouldBeNull(); + ctx.SessionId.ShouldBeNull(); + ctx.IsWebEnv.ShouldBeFalse(); + } + + /// + /// 测试目的:属性赋值后应能正确读取,确保 LogContext 的属性是可读写的 POCO。 + /// + [Fact] + public void LogContext_SetProperties_ShouldReturnAssignedValues() + { + // Arrange & Act + var ctx = new LogContext + { + TraceId = "trace-001", + UserId = "user-001", + TenantId = "tenant-a", + Application = "MyApp", + Environment = "Prod", + IsWebEnv = true + }; + + // Assert + ctx.TraceId.ShouldBe("trace-001"); + ctx.UserId.ShouldBe("user-001"); + ctx.TenantId.ShouldBe("tenant-a"); + ctx.Application.ShouldBe("MyApp"); + ctx.Environment.ShouldBe("Prod"); + ctx.IsWebEnv.ShouldBeTrue(); + } + + /// + /// 测试目的:LogContext.Current 是 AsyncLocal,在主线程设置后可读取到相同引用。 + /// + [Fact] + public void LogContext_Current_SetAndGet_ShouldReturnSameInstance() + { + // Arrange + var original = LogContext.Current; + var ctx = new LogContext { TraceId = "async-trace" }; + try + { + // Act + LogContext.Current = ctx; + + // Assert + LogContext.Current.ShouldBeSameAs(ctx); + LogContext.Current.TraceId.ShouldBe("async-trace"); + } + finally + { + LogContext.Current = original; + } + } + + /// + /// 测试目的:Tags 可以正常添加条目并读取,确保标签功能可用。 + /// + [Fact] + public void LogContext_Tags_AddTag_ShouldBeReadable() + { + // Arrange + var ctx = new LogContext(); + + // Act + ctx.Tags.Add("tag1"); + ctx.Tags.Add("tag2"); + + // Assert + ctx.Tags.Count.ShouldBe(2); + ctx.Tags.ShouldContain("tag1"); + ctx.Tags.ShouldContain("tag2"); + } + + /// + /// 测试目的:Data 字典可以正常添加 KV 并读取,确保扩展数据功能可用。 + /// + [Fact] + public void LogContext_Data_AddEntry_ShouldBeReadable() + { + // Arrange + var ctx = new LogContext(); + + // Act + ctx.Data["key1"] = "value1"; + + // Assert + ctx.Data["key1"].ShouldBe("value1"); + } + + // ═══════════════════════════════════════════════════════════ + // LogContextAccessor + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 LogContext.Current 为 null 时,LogContextAccessor.Context 应创建并返回新实例, + /// 确保不返回 null。 + /// + [Fact] + public void LogContextAccessor_Context_WhenCurrentIsNull_ShouldCreateNew() + { + // Arrange + var original = LogContext.Current; + LogContext.Current = null; + try + { + var accessor = new LogContextAccessor(); + + // Act + var ctx = accessor.Context; + + // Assert + ctx.ShouldNotBeNull(); + } + finally + { + LogContext.Current = original; + } + } + + /// + /// 测试目的:新创建的 LogContext 应包含非空的 TraceId, + /// 确保分布式追踪字段不为空。 + /// + [Fact] + public void LogContextAccessor_Context_ShouldHaveNonNullTraceId() + { + // Arrange + var original = LogContext.Current; + LogContext.Current = null; + try + { + var accessor = new LogContextAccessor(); + + // Act + var ctx = accessor.Context; + + // Assert + ctx.TraceId.ShouldNotBeNullOrEmpty(); + } + finally + { + LogContext.Current = original; + } + } + + // ═══════════════════════════════════════════════════════════ + // LogFactory + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入 null ILoggerFactory 时,构造器应抛出 ArgumentNullException, + /// 防止在运行时遇到意外 NRE。 + /// + [Fact] + public void LogFactory_Constructor_NullLoggerFactory_ShouldThrow() + { + // Arrange + var mockAccessor = new Mock(); + + // Act & Assert + Should.Throw(() => + new LogFactory(null, mockAccessor.Object)); + } + + /// + /// 测试目的:CreateLog(string) 应返回非 null 的 ILog 实例, + /// 确保工厂方法按 categoryName 正确创建日志操作对象。 + /// + [Fact] + public void LogFactory_CreateLog_WithCategoryName_ShouldReturnNonNullLog() + { + // Arrange + var mockLoggerFactory = new Mock(); + var mockLogger = new Mock(); + mockLoggerFactory + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(mockLogger.Object); + var mockAccessor = new Mock(); + var factory = new LogFactory(mockLoggerFactory.Object, mockAccessor.Object); + + // Act + var log = factory.CreateLog("TestCategory"); + + // Assert + log.ShouldNotBeNull(); + } + + /// + /// 测试目的:CreateLog(Type) 应返回非 null 的 ILog 实例, + /// 确保按类型创建日志对象的路径正确。 + /// + [Fact] + public void LogFactory_CreateLog_WithType_ShouldReturnNonNullLog() + { + // Arrange + var mockLoggerFactory = new Mock(); + var mockLogger = new Mock(); + mockLoggerFactory + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(mockLogger.Object); + var mockAccessor = new Mock(); + var factory = new LogFactory(mockLoggerFactory.Object, mockAccessor.Object); + + // Act + var log = factory.CreateLog(typeof(LogFactory)); + + // Assert + log.ShouldNotBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // BingLoggingBuilder + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造器应将 IServiceCollection 正确存储到 Services 属性, + /// 确保扩展方法可以通过 builder.Services 注册依赖。 + /// + [Fact] + public void BingLoggingBuilder_Constructor_ShouldSetServicesProperty() + { + // Arrange + var mockServices = new Mock(); + + // Act + var builder = new BingLoggingBuilder(mockServices.Object); + + // Assert + builder.Services.ShouldBeSameAs(mockServices.Object); + } + + // ═══════════════════════════════════════════════════════════ + // ContextDataTypes — 常量值验证 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ContextDataTypes 中的常量前缀应保持稳定, + /// 防止意外重命名导致 Serilog/日志提供程序的扩展属性键失配。 + /// + [Fact] + public void ContextDataTypes_Constants_ShouldMatchExpectedValues() + { + // Assert + ContextDataTypes.ExtraProperty.ShouldBe("__BING_EXTRA_PROPERTY_"); + ContextDataTypes.Tags.ShouldBe("__BING_TAGS"); + ContextDataTypes.CallerInfo.ShouldBe("__BING_CALLER_IFNO"); + } + + // ═══════════════════════════════════════════════════════════ + // NullLogCallerInfo — 结构体 null 值语义 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:NullLogCallerInfo.Instance 所有属性应返回 null/0/null, + /// 用于调用者信息不可用时的安全兜底。 + /// + [Fact] + public void NullLogCallerInfo_Instance_AllPropertiesShouldBeNullOrDefault() + { + // Arrange & Act + var info = NullLogCallerInfo.Instance; + + // Assert + info.MemberName.ShouldBeNull(); + info.FilePath.ShouldBeNull(); + info.LineNumber.ShouldBe(0); + info.ToParams().ShouldBeNull(); + } + + /// + /// 测试目的:NullLogCallerInfo.Instance 多次访问应返回值语义相同的结构, + /// 确保 struct 使用安全一致。 + /// + [Fact] + public void NullLogCallerInfo_Instance_MultipleAccess_ShouldBeConsistent() + { + // Arrange & Act + var a = NullLogCallerInfo.Instance; + var b = NullLogCallerInfo.Instance; + + // Assert + a.MemberName.ShouldBe(b.MemberName); + a.FilePath.ShouldBe(b.FilePath); + a.LineNumber.ShouldBe(b.LineNumber); + } +} diff --git a/framework/tests/Bing.Logging.Tests/LogTest.Extend.cs b/framework/tests/Bing.Logging.Tests/LogTest.Extend.cs new file mode 100644 index 00000000..4b1c8486 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/LogTest.Extend.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 日志操作测试 - 异常与事件标识 +/// +public partial class LogTest +{ + #region Exception + + /// + /// 测试目的:通过 Exception() 设置异常后调用 LogError,异常对象应传递给底层 Logger。 + /// + [Fact] + public void Test_LogError_WithException_ShouldPassExceptionToLogger() + { + // Arrange + var exception = new InvalidOperationException("测试异常"); + + // Act + _log.Exception(exception).LogError(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny>(), + exception, + It.IsAny, Exception, string>>())); + } + + /// + /// 测试目的:通过 Exception() + Message() 组合调用 LogError,异常与消息均应传递给 Logger。 + /// + [Fact] + public void Test_LogError_WithExceptionAndMessage_ShouldPassBothToLogger() + { + // Arrange + var exception = new InvalidOperationException("操作失败"); + + // Act + _log.Message("发生错误 {msg}", "详情") + .Exception(exception) + .LogError(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Error, + 0, + exception, + It.IsAny(), + It.IsAny())); + } + + /// + /// 测试目的:通过 Exception() 设置异常后调用 LogWarning,异常应传递到 Warning 级别。 + /// + [Fact] + public void Test_LogWarning_WithException_ShouldPassExceptionToLogger() + { + // Arrange + var exception = new ArgumentException("参数异常"); + + // Act + _log.Exception(exception).LogWarning(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny>(), + exception, + It.IsAny, Exception, string>>())); + } + + /// + /// 测试目的:通过 Exception() 设置异常后调用 LogCritical,异常应传递到 Critical 级别。 + /// + [Fact] + public void Test_LogCritical_WithException_ShouldPassExceptionToLogger() + { + // Arrange + var exception = new OutOfMemoryException("内存不足"); + + // Act + _log.Exception(exception).LogCritical(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Critical, + It.IsAny(), + It.IsAny>(), + exception, + It.IsAny, Exception, string>>())); + } + + #endregion + + #region EventId + + /// + /// 测试目的:通过 EventId() 设置事件ID + Message() 调用 LogInformation,EventId 应传递到 Logger。 + /// + [Fact] + public void Test_LogInformation_WithEventId_ShouldPassEventIdToLogger() + { + // Arrange + var eventId = new EventId(1001, "UserCreated"); + + // Act + _log.Message("用户 {name} 已创建", "Alice") + .EventId(eventId) + .LogInformation(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Information, + eventId, + null, + It.IsAny(), + It.IsAny())); + } + + /// + /// 测试目的:通过 EventId() 设置事件ID + Property() 调用 LogDebug,EventId 应传递到 Logger(带属性路径)。 + /// + [Fact] + public void Test_LogDebug_WithEventId_ShouldPassEventIdToLogger() + { + // Arrange + var eventId = new EventId(2001, "OrderProcessed"); + + // Act + _log.Property("OrderId", "ORD-001") + .EventId(eventId) + .LogDebug(); + + // Assert + _mockLogger.Verify(t => t.Log( + LogLevel.Debug, + eventId, + It.Is>(d => d.ContainsKey("OrderId")), + null, + It.IsAny, Exception, string>>())); + } + + #endregion + + #region 状态重置行为 + + /// + /// 测试目的:连续两次 LogError 调用,第二次不应携带第一次设置的 Message(状态在每次调用后重置)。 + /// + [Fact] + public void Test_Log_ShouldClearState_AfterEachWrite() + { + // Arrange + var callCount = 0; + _mockLogger.Setup(t => t.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => callCount++); + + // Act - 第一次写日志 + _log.Message("第一条消息").LogError(); + + // Act - 第二次写日志(不设置 Message) + _log.Property("Key", "Value").LogError(); + + // Assert - 两次均调用了底层 Logger + callCount.ShouldBe(0); // Property-only 走 state 路径,调用第一个重载 + _mockLogger.Verify(t => t.Log( + LogLevel.Error, 0, It.IsAny(), "第一条消息", + It.IsAny()), Times.Once); + _mockLogger.Verify(t => t.Log( + LogLevel.Error, It.IsAny(), + It.Is>(d => d.ContainsKey("Key")), + null, + It.IsAny, Exception, string>>()), Times.Once); + } + + #endregion + + #region 完整流式链 + + /// + /// 测试目的:Message + Property + Exception + EventId 完整链式调用,所有参数均正确传递给 Logger。 + /// + [Fact] + public void Test_FullChain_Message_Property_Exception_EventId() + { + // Arrange + var exception = new Exception("链式异常"); + var eventId = new EventId(9999, "FullChain"); + + // Act + _log.Message("处理结果: {result}", "成功") + .Property("RequestId", "REQ-001") + .Exception(exception) + .EventId(eventId) + .LogError(); + + // Assert - 有 Message 走 message 路径 + _mockLogger.Verify(t => t.Log( + LogLevel.Error, + eventId, + exception, + It.Is(s => s.Contains("RequestId") && s.Contains("result")), + It.IsAny())); + } + + #endregion +} diff --git a/framework/tests/Bing.Logging.Tests/LoggerWrapperTest.cs b/framework/tests/Bing.Logging.Tests/LoggerWrapperTest.cs new file mode 100644 index 00000000..2b50d40b --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/LoggerWrapperTest.cs @@ -0,0 +1,160 @@ +using Bing.Logging.Core.Callers; +using Microsoft.Extensions.Logging; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Logging.Tests; + +/// +/// 单元测试 +/// +public class LoggerWrapperTest +{ + // ═══════════════════════════════════════════════════════════ + // 构造器校验 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入 null ILogger 时,构造器应抛出 ArgumentNullException, + /// 防止在后续调用时触发 NullReferenceException。 + /// + [Fact] + public void Constructor_NullLogger_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new LoggerWrapper(null)); + } + + // ═══════════════════════════════════════════════════════════ + // IsEnabled 委托 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:IsEnabled 应委托给底层 ILogger,返回 ILogger 的判断结果, + /// 确保级别过滤行为与底层提供程序一致。 + /// + [Theory] + [InlineData(LogLevel.Trace, true)] + [InlineData(LogLevel.Information, false)] + [InlineData(LogLevel.Critical, true)] + public void IsEnabled_ShouldDelegateToUnderlyingLogger(LogLevel level, bool expected) + { + // Arrange + var mockLogger = new Mock(); + mockLogger.Setup(l => l.IsEnabled(level)).Returns(expected); + var wrapper = new LoggerWrapper(mockLogger.Object); + + // Act + var result = wrapper.IsEnabled(level); + + // Assert + result.ShouldBe(expected); + mockLogger.Verify(l => l.IsEnabled(level), Times.Once); + } + + // ═══════════════════════════════════════════════════════════ + // BeginScope 委托 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:BeginScope 应委托给底层 ILogger 并返回其 IDisposable, + /// 确保结构化日志作用域行为不被截断。 + /// + [Fact] + public void BeginScope_ShouldDelegateToUnderlyingLogger() + { + // Arrange + var mockLogger = new Mock(); + var mockScope = new Mock(); + mockLogger + .Setup(l => l.BeginScope(It.IsAny())) + .Returns(mockScope.Object); + var wrapper = new LoggerWrapper(mockLogger.Object); + + // Act + var scope = wrapper.BeginScope("test-scope"); + + // Assert + scope.ShouldBeSameAs(mockScope.Object); + mockLogger.Verify(l => l.BeginScope(It.IsAny()), Times.Once); + } + + // ═══════════════════════════════════════════════════════════ + // Log 委托(Log) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Log<TState> 应委托给底层 ILogger.Log 一次, + /// 确保日志写入操作不丢失。 + /// + [Fact] + public void Log_ShouldDelegateToUnderlyingLogger() + { + // Arrange + var mockLogger = new Mock(); + var wrapper = new LoggerWrapper(mockLogger.Object); + var eventId = new EventId(1, "test"); + + // Act + wrapper.Log(LogLevel.Information, eventId, "state", null, (s, e) => s); + + // Assert + mockLogger.Verify( + l => l.Log( + LogLevel.Information, + eventId, + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + // ═══════════════════════════════════════════════════════════ + // 各级别便捷方法委托 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:LogTrace / LogDebug / LogInformation / LogWarning / LogError / LogCritical + /// 均应委托给底层 ILogger 写入对应级别的日志, + /// 确保便捷方法不会静默丢弃日志。 + /// + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + public void LogAtLevel_ShouldDelegateToUnderlyingLoggerWithCorrectLevel(LogLevel level) + { + // Arrange + var mockLogger = new Mock(); + mockLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + var wrapper = new LoggerWrapper(mockLogger.Object); + var eventId = new EventId(0); + + // Act + switch (level) + { + case LogLevel.Trace: wrapper.LogTrace(eventId, null, "trace"); break; + case LogLevel.Debug: wrapper.LogDebug(eventId, null, "debug"); break; + case LogLevel.Information: wrapper.LogInformation(eventId, null, "info"); break; + case LogLevel.Warning: wrapper.LogWarning(eventId, null, "warn"); break; + case LogLevel.Error: wrapper.LogError(eventId, null, "error"); break; + case LogLevel.Critical: wrapper.LogCritical(eventId, null, "critical"); break; + } + + // Assert — 只要底层 Log 被调用了对应级别即可 + mockLogger.Verify( + l => l.Log( + level, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once, + $"LogLevel.{level} 应委托到底层 ILogger.Log"); + } +} diff --git a/framework/tests/Bing.Logging.Tests/NullLogTest.cs b/framework/tests/Bing.Logging.Tests/NullLogTest.cs new file mode 100644 index 00000000..791004d3 --- /dev/null +++ b/framework/tests/Bing.Logging.Tests/NullLogTest.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; + +namespace Bing.Logging.Tests; + +/// +/// 日志操作测试 - 空日志(NullLog) +/// +public class NullLogTest +{ + #region NullLog 单例 + + /// + /// 测试目的:NullLog.Instance 不为 null,且多次访问返回同一实例。 + /// + [Fact] + public void Instance_ShouldNotBeNull_AndSingleton() + { + // Arrange & Act + var instance1 = NullLog.Instance; + var instance2 = NullLog.Instance; + + // Assert + instance1.ShouldNotBeNull(); + instance1.ShouldBeSameAs(instance2); + } + + /// + /// 测试目的:NullLog<T>.Instance 不为 null,且实现 ILog<T> 接口。 + /// + [Fact] + public void GenericInstance_ShouldNotBeNull_AndImplementInterface() + { + // Arrange & Act + var instance = NullLog.Instance; + + // Assert + instance.ShouldNotBeNull(); + instance.ShouldBeAssignableTo>(); + } + + /// + /// 测试目的:Log.Null 静态属性应为 NullLog 实例。 + /// + [Fact] + public void Log_Null_ShouldBeNullLogInstance() + { + // Arrange & Act + var logNull = Log.Null; + + // Assert + logNull.ShouldNotBeNull(); + logNull.ShouldBeSameAs(NullLog.Instance); + } + + #endregion + + #region IsEnabled + + /// + /// 测试目的:NullLog.IsEnabled 对所有日志级别均返回 false(空实现,不实际记录)。 + /// + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] + [InlineData(LogLevel.Error)] + [InlineData(LogLevel.Critical)] + [InlineData(LogLevel.None)] + public void IsEnabled_AllLevels_ShouldReturnFalse(LogLevel level) + { + // Arrange + var log = NullLog.Instance; + + // Act & Assert + log.IsEnabled(level).ShouldBeFalse(); + } + + #endregion + + #region 流式调用不抛异常 + + /// + /// 测试目的:NullLog 的所有 Log 级别方法均不抛出异常,返回值为自身(支持链式调用)。 + /// + [Fact] + public void LogMethods_ShouldNotThrow_AndReturnSelf() + { + // Arrange + var log = NullLog.Instance; + + // Act & Assert + Should.NotThrow(() => + { + ILog result; + result = log.LogTrace(); + result.ShouldBeSameAs(log); + + result = log.LogDebug(); + result.ShouldBeSameAs(log); + + result = log.LogInformation(); + result.ShouldBeSameAs(log); + + result = log.LogWarning(); + result.ShouldBeSameAs(log); + + result = log.LogError(); + result.ShouldBeSameAs(log); + + result = log.LogCritical(); + result.ShouldBeSameAs(log); + }); + } + + /// + /// 测试目的:NullLog 的流式设置方法(Message/Property/State/Exception/EventId)均返回自身不抛异常。 + /// + [Fact] + public void FluentSetters_ShouldNotThrow_AndReturnSelf() + { + // Arrange + var log = NullLog.Instance; + var ex = new Exception("test"); + var eventId = new Microsoft.Extensions.Logging.EventId(1, "TestEvent"); + + // Act & Assert + Should.NotThrow(() => + { + var result = log + .Message("hello {0}", 42) + .Property("key", "value") + .State(new { Name = "test" }) + .Exception(ex) + .EventId(eventId); + + result.ShouldBeSameAs(log); + }); + } + + /// + /// 测试目的:NullLog.Exception(null) 不抛异常。 + /// + [Fact] + public void Exception_Null_ShouldNotThrow() + { + // Arrange + var log = NullLog.Instance; + + // Act & Assert + Should.NotThrow(() => log.Exception(null)); + } + + /// + /// 测试目的:NullLog.BeginScope 返回可 Dispose 的对象(不抛异常,且 Dispose 不抛异常)。 + /// + [Fact] + public void BeginScope_ShouldReturnDisposable_AndDisposeNotThrow() + { + // Arrange + var log = NullLog.Instance; + + // Act + IDisposable scope = null; + Should.NotThrow(() => scope = log.BeginScope("test-scope")); + + // Assert + scope.ShouldNotBeNull(); + Should.NotThrow(() => scope.Dispose()); + } + + /// + /// 测试目的:完整链式调用 NullLog 不抛异常(模拟真实调用模式)。 + /// + [Fact] + public void FullChain_ShouldNotThrow() + { + // Arrange + var log = NullLog.Instance; + + // Act & Assert + Should.NotThrow(() => + { + log.Message("订单 {OrderId} 已创建", 123) + .Property("UserId", "u-001") + .State(new { OrderId = 123 }) + .Exception(new InvalidOperationException("测试")) + .LogError(); + }); + } + + #endregion +} diff --git a/framework/tests/Bing.MailKit.Tests/EmailExtensionsTest.cs b/framework/tests/Bing.MailKit.Tests/EmailExtensionsTest.cs new file mode 100644 index 00000000..b73275cb --- /dev/null +++ b/framework/tests/Bing.MailKit.Tests/EmailExtensionsTest.cs @@ -0,0 +1,182 @@ +using System.Net.Mail; +using Bing.MailKit.Extensions; +using Shouldly; +using Xunit; + +namespace Bing.MailKit.Tests; + +/// +/// 单元测试。 +/// 验证 System.Net.Mail.MailMessage → MimeKit.MimeMessage 转换的关键行为, +/// 不依赖 SMTP 连接,纯内存转换。 +/// +public class EmailExtensionsTest +{ + /// + /// 测试目的:ToMimeMessage(null) 应抛出 ArgumentNullException,防止空引用。 + /// + [Fact] + public void ToMimeMessage_NullMail_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + ((MailMessage)null).ToMimeMessage()); + } + + /// + /// 测试目的:邮件主题应正确映射到 MimeMessage.Subject。 + /// + [Fact] + public void ToMimeMessage_WithSubject_ShouldMapSubject() + { + // Arrange + var mail = new MailMessage + { + From = new MailAddress("sender@example.com"), + Subject = "Hello World" + }; + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.Subject.ShouldBe("Hello World"); + } + + /// + /// 测试目的:发件人地址应正确映射到 MimeMessage.From。 + /// + [Fact] + public void ToMimeMessage_WithFrom_ShouldMapFromAddress() + { + // Arrange + var mail = new MailMessage + { + From = new MailAddress("sender@example.com", "Sender Name"), + Subject = "Test" + }; + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.From.Mailboxes.ShouldContain(m => m.Address == "sender@example.com"); + } + + /// + /// 测试目的:收件人列表应正确映射到 MimeMessage.To。 + /// + [Fact] + public void ToMimeMessage_WithToRecipients_ShouldMapToList() + { + // Arrange + var mail = new MailMessage + { + From = new MailAddress("sender@example.com"), + Subject = "Test" + }; + mail.To.Add("alice@example.com"); + mail.To.Add("bob@example.com"); + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.To.Mailboxes.Count().ShouldBe(2); + message.To.Mailboxes.ShouldContain(m => m.Address == "alice@example.com"); + message.To.Mailboxes.ShouldContain(m => m.Address == "bob@example.com"); + } + + /// + /// 测试目的:抄送列表应正确映射到 MimeMessage.Cc。 + /// + [Fact] + public void ToMimeMessage_WithCc_ShouldMapCcList() + { + // Arrange + var mail = new MailMessage { From = new MailAddress("sender@example.com"), Subject = "Test" }; + mail.CC.Add("cc@example.com"); + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.Cc.Mailboxes.ShouldContain(m => m.Address == "cc@example.com"); + } + + /// + /// 测试目的:密送列表应正确映射到 MimeMessage.Bcc。 + /// + [Fact] + public void ToMimeMessage_WithBcc_ShouldMapBccList() + { + // Arrange + var mail = new MailMessage { From = new MailAddress("sender@example.com"), Subject = "Test" }; + mail.Bcc.Add("bcc@example.com"); + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.Bcc.Mailboxes.ShouldContain(m => m.Address == "bcc@example.com"); + } + + /// + /// 测试目的:HTML 正文应被映射为 text/html Content-Type。 + /// + [Fact] + public void ToMimeMessage_WithHtmlBody_ShouldHaveHtmlContentType() + { + // Arrange + var mail = new MailMessage + { + From = new MailAddress("sender@example.com"), + Subject = "HTML Test", + Body = "

Hello

", + IsBodyHtml = true + }; + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.Body.ShouldNotBeNull(); + message.HtmlBody.ShouldNotBeNullOrEmpty(); + } + + /// + /// 测试目的:纯文本正文应被映射为 text/plain Content-Type。 + /// + [Fact] + public void ToMimeMessage_WithPlainTextBody_ShouldHaveTextContentType() + { + // Arrange + var mail = new MailMessage + { + From = new MailAddress("sender@example.com"), + Subject = "Plain Text Test", + Body = "Hello, plain text!", + IsBodyHtml = false + }; + + // Act + var message = mail.ToMimeMessage(); + + // Assert + message.Body.ShouldNotBeNull(); + message.TextBody.ShouldNotBeNullOrEmpty(); + } + + /// + /// 测试目的:无 From/无 Subject 的空邮件也应能成功转换,不抛异常。 + /// + [Fact] + public void ToMimeMessage_EmptyMail_ShouldNotThrow() + { + // Arrange + var mail = new MailMessage(); + + // Act & Assert + Should.NotThrow(() => mail.ToMimeMessage()); + } +} diff --git a/framework/tests/Bing.MailKit.Tests/EmailingModelsTest.cs b/framework/tests/Bing.MailKit.Tests/EmailingModelsTest.cs new file mode 100644 index 00000000..50968a52 --- /dev/null +++ b/framework/tests/Bing.MailKit.Tests/EmailingModelsTest.cs @@ -0,0 +1,705 @@ +using System.Net.Mail; +using System.Text; +using Bing.Emailing; +using Bing.Emailing.Attachments; +using Bing.MailKit.Configs; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.MailKit.Tests; + +/// +/// 默认值与属性赋值测试。 +/// 验证集合属性初始化、IsBodyHtml 默认值及字符串属性读写。 +/// +public class EmailBoxTest +{ + /// + /// 测试目的:默认构造后,集合属性应为空列表而非 null,防止空引用异常。 + /// + [Fact] + public void Default_Collections_ShouldBeEmptyNotNull() + { + // Arrange & Act + var box = new EmailBox(); + + // Assert + box.Attachments.ShouldNotBeNull(); + box.Attachments.ShouldBeEmpty(); + box.To.ShouldNotBeNull(); + box.To.ShouldBeEmpty(); + box.Cc.ShouldNotBeNull(); + box.Cc.ShouldBeEmpty(); + box.Bcc.ShouldNotBeNull(); + box.Bcc.ShouldBeEmpty(); + } + + /// + /// 测试目的:IsBodyHtml 默认值应为 true,符合现代邮件发送惯例。 + /// + [Fact] + public void IsBodyHtml_Default_ShouldBeTrue() + { + // Arrange & Act + var box = new EmailBox(); + + // Assert + box.IsBodyHtml.ShouldBeTrue(); + } + + /// + /// 测试目的:Body 和 Subject 默认值应为 null,由调用方按需填充。 + /// + [Fact] + public void StringProperties_Default_ShouldBeNull() + { + // Arrange & Act + var box = new EmailBox(); + + // Assert + box.Body.ShouldBeNull(); + box.Subject.ShouldBeNull(); + } + + /// + /// 测试目的:属性赋值后可正确读取,确保 getter/setter 对称。 + /// + [Fact] + public void Properties_SetAndGet_ShouldRoundtrip() + { + // Arrange & Act + var box = new EmailBox + { + Subject = "Test Subject", + Body = "

Hello

", + IsBodyHtml = false + }; + + // Assert + box.Subject.ShouldBe("Test Subject"); + box.Body.ShouldBe("

Hello

"); + box.IsBodyHtml.ShouldBeFalse(); + } + + /// + /// 测试目的:To/Cc/Bcc 可添加成员,验证集合可写。 + /// + [Fact] + public void Collections_CanAddRecipients() + { + // Arrange + var box = new EmailBox(); + + // Act + box.To.Add("alice@example.com"); + box.Cc.Add("cc@example.com"); + box.Bcc.Add("bcc@example.com"); + + // Assert + box.To.ShouldContain("alice@example.com"); + box.Cc.ShouldContain("cc@example.com"); + box.Bcc.ShouldContain("bcc@example.com"); + } +} + +/// +/// 配置默认值测试。 +/// 验证 Port/SleepInterval 默认值及其余属性的零值/null 初始化。 +/// +public class EmailConfigTest +{ + /// + /// 测试目的:Port 默认值应为标准 SMTP 端口 25。 + /// + [Fact] + public void Port_Default_ShouldBe25() + { + // Arrange & Act + var config = new EmailConfig(); + + // Assert + config.Port.ShouldBe(25); + } + + /// + /// 测试目的:SleepInterval 默认值应为 3000ms,即 3 秒轮询间隔。 + /// + [Fact] + public void SleepInterval_Default_ShouldBe3000() + { + // Arrange & Act + var config = new EmailConfig(); + + // Assert + config.SleepInterval.ShouldBe(3000); + } + + /// + /// 测试目的:EnableSsl 和 UseDefaultCredentials 默认为 false,需显式启用。 + /// + [Fact] + public void BoolProperties_Default_ShouldBeFalse() + { + // Arrange & Act + var config = new EmailConfig(); + + // Assert + config.EnableSsl.ShouldBeFalse(); + config.UseDefaultCredentials.ShouldBeFalse(); + } + + /// + /// 测试目的:所有字符串属性默认为 null,防止依赖方误用空字符串默认值。 + /// + [Fact] + public void StringProperties_Default_ShouldBeNull() + { + // Arrange & Act + var config = new EmailConfig(); + + // Assert + config.Host.ShouldBeNull(); + config.UserName.ShouldBeNull(); + config.Password.ShouldBeNull(); + config.Domain.ShouldBeNull(); + config.DisplayName.ShouldBeNull(); + config.FromAddress.ShouldBeNull(); + } + + /// + /// 测试目的:属性赋值后可正确读取,确保 getter/setter 对称。 + /// + [Fact] + public void Properties_SetAndGet_ShouldRoundtrip() + { + // Arrange & Act + var config = new EmailConfig + { + Host = "smtp.example.com", + Port = 587, + UserName = "user", + Password = "pass", + EnableSsl = true + }; + + // Assert + config.Host.ShouldBe("smtp.example.com"); + config.Port.ShouldBe(587); + config.UserName.ShouldBe("user"); + config.EnableSsl.ShouldBeTrue(); + } +} + +/// +/// 测试。 +/// 验证 GetConfig/GetConfigAsync 返回构造时注入的配置对象(引用相等)。 +/// +public class DefaultEmailConfigProviderTest +{ + /// + /// 测试目的:GetConfig 应返回构造时传入的配置对象引用,无复制行为。 + /// + [Fact] + public void GetConfig_ShouldReturnSameConfig() + { + // Arrange + var config = new EmailConfig { Host = "smtp.example.com", Port = 587 }; + var provider = new DefaultEmailConfigProvider(config); + + // Act & Assert + provider.GetConfig().ShouldBeSameAs(config); + } + + /// + /// 测试目的:GetConfigAsync 应异步返回构造时传入的配置对象引用。 + /// + [Fact] + public async Task GetConfigAsync_ShouldReturnSameConfig() + { + // Arrange + var config = new EmailConfig { Host = "smtp.example.com" }; + var provider = new DefaultEmailConfigProvider(config); + + // Act + var result = await provider.GetConfigAsync(); + + // Assert + result.ShouldBeSameAs(config); + } +} + +/// +/// 附件测试。 +/// 验证 GetFileStream/GetName/Dispose 行为。 +/// +public class MemoryStreamAttachmentTest +{ + /// + /// 测试目的:GetFileStream 应返回构造时传入的 MemoryStream 引用,而不是副本。 + /// + [Fact] + public void GetFileStream_ShouldReturnTheStream() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + using var attachment = new MemoryStreamAttachment(stream, "test.bin"); + + // Act & Assert + attachment.GetFileStream().ShouldBeSameAs(stream); + } + + /// + /// 测试目的:GetName 应返回构造时传入的文件名,原样输出。 + /// + [Fact] + public void GetName_ShouldReturnFileName() + { + // Arrange + using var stream = new MemoryStream(); + using var attachment = new MemoryStreamAttachment(stream, "report.pdf"); + + // Act & Assert + attachment.GetName().ShouldBe("report.pdf"); + } + + /// + /// 测试目的:中文文件名应原样返回,不丢失 Unicode 字符。 + /// + [Fact] + public void GetName_ChineseFileName_ShouldRoundtrip() + { + // Arrange + using var stream = new MemoryStream(); + using var attachment = new MemoryStreamAttachment(stream, "测试报告.xlsx"); + + // Act & Assert + attachment.GetName().ShouldBe("测试报告.xlsx"); + } + + /// + /// 测试目的:Dispose 后 MemoryStream 应被释放,防止内存泄漏。 + /// + [Fact] + public void Dispose_ShouldDisposeUnderlyingStream() + { + // Arrange + var stream = new MemoryStream(); + var attachment = new MemoryStreamAttachment(stream, "file.bin"); + + // Act + attachment.Dispose(); + + // Assert:MemoryStream 被释放后 CanRead=false + stream.CanRead.ShouldBeFalse(); + } +} + +/// +/// 配置测试。 +/// 验证可空属性的默认值及赋值行为。 +/// +public class MailKitConfigTest +{ + /// + /// 测试目的:默认构造后,可空属性应为 null,不影响调用方的 HasValue 判断。 + /// + [Fact] + public void Default_NullableProperties_ShouldBeNull() + { + // Arrange & Act + var config = new MailKitConfig(); + + // Assert + config.SecureSocketOption.ShouldBeNull(); + config.ServerCertificateValidationCallback.ShouldBeNull(); + } + + /// + /// 测试目的:ServerCertificateValidationCallback 赋值 true 后可正确读取。 + /// + [Fact] + public void ServerCertificateValidationCallback_SetTrue_ShouldBeTrue() + { + // Arrange & Act + var config = new MailKitConfig { ServerCertificateValidationCallback = true }; + + // Assert + config.ServerCertificateValidationCallback.ShouldBe(true); + } + + /// + /// 测试目的:ServerCertificateValidationCallback 赋值 false 后可正确读取。 + /// + [Fact] + public void ServerCertificateValidationCallback_SetFalse_ShouldBeFalse() + { + // Arrange & Act + var config = new MailKitConfig { ServerCertificateValidationCallback = false }; + + // Assert + config.ServerCertificateValidationCallback.ShouldBe(false); + } +} + +/// +/// 测试。 +/// 验证 GetConfig/GetConfigAsync 返回注入的配置引用。 +/// +public class DefaultMailKitConfigProviderTest +{ + /// + /// 测试目的:GetConfig 应返回构造时传入的 MailKitConfig 引用,无复制。 + /// + [Fact] + public void GetConfig_ShouldReturnSameConfig() + { + // Arrange + var config = new MailKitConfig { ServerCertificateValidationCallback = true }; + var provider = new DefaultMailKitConfigProvider(config); + + // Act & Assert + provider.GetConfig().ShouldBeSameAs(config); + } + + /// + /// 测试目的:GetConfigAsync 应异步返回构造时传入的 MailKitConfig 引用。 + /// + [Fact] + public async Task GetConfigAsync_ShouldReturnSameConfig() + { + // Arrange + var config = new MailKitConfig(); + var provider = new DefaultMailKitConfigProvider(config); + + // Act + var result = await provider.GetConfigAsync(); + + // Assert + result.ShouldBeSameAs(config); + } +} + +/// +/// 测试(通过 行为验证)。 +/// NullEmailSender.SendEmail 为空实现,因此不依赖 SMTP,适合纯单元测试。 +/// 覆盖:构造校验、ConfigProvider 引用、NormalizeMail 行为、多签名 Send/SendAsync。 +/// +public class NullEmailSenderTest +{ + private static IEmailConfigProvider CreateConfigProvider( + string fromAddress = "noreply@example.com", + string displayName = "Test Sender") + { + var config = new EmailConfig + { + FromAddress = fromAddress, + DisplayName = displayName + }; + return new DefaultEmailConfigProvider(config); + } + + /// + /// 测试目的:传入 null provider 时应抛出 ArgumentNullException,防止空引用延迟崩溃。 + /// + [Fact] + public void Constructor_NullProvider_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => new NullEmailSender(null)); + } + + /// + /// 测试目的:ConfigProvider 属性应引用构造时注入的 provider,保持 DI 透明性。 + /// + [Fact] + public void ConfigProvider_ShouldReferenceInjectedProvider() + { + // Arrange + var provider = CreateConfigProvider(); + var sender = new NullEmailSender(provider); + + // Act & Assert + sender.ConfigProvider.ShouldBeSameAs(provider); + } + + /// + /// 测试目的:Send(to, subject, body) 不应抛异常(NullEmailSender 空实现)。 + /// + [Fact] + public void Send_ThreeStringParams_ShouldNotThrow() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + + // Act & Assert + Should.NotThrow(() => sender.Send("to@example.com", "Subject", "Body")); + } + + /// + /// 测试目的:SendAsync(to, subject, body) 不应抛异常。 + /// + [Fact] + public async Task SendAsync_ThreeStringParams_ShouldNotThrow() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + + // Act & Assert + await Should.NotThrowAsync(() => sender.SendAsync("to@example.com", "Subject", "Body")); + } + + /// + /// 测试目的:Send(from, to, subject, body) 不应抛异常。 + /// + [Fact] + public void Send_FourStringParams_ShouldNotThrow() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + + // Act & Assert + Should.NotThrow(() => sender.Send("from@example.com", "to@example.com", "Subject", "Body")); + } + + /// + /// 测试目的:NormalizeMail 应对未设置编码的 MailMessage 填充 UTF-8。 + /// 验证 HeadersEncoding/SubjectEncoding/BodyEncoding 均被设为 Encoding.UTF8。 + /// + [Fact] + public void Send_NormalizeMail_ShouldSetUtf8Encodings() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + var mail = new MailMessage + { + From = new MailAddress("sender@example.com"), + Subject = "Encoding Test" + }; + mail.To.Add("to@example.com"); + + // Act:normalize=true 触发 NormalizeMail + sender.Send(mail, normalize: true); + + // Assert + mail.HeadersEncoding.ShouldBe(Encoding.UTF8); + mail.SubjectEncoding.ShouldBe(Encoding.UTF8); + mail.BodyEncoding.ShouldBe(Encoding.UTF8); + } + + /// + /// 测试目的:当 mail.From == null 时,NormalizeMail 应从 EmailConfig 中自动填充发件人地址。 + /// + [Fact] + public void Send_NormalizeMail_WhenFromNull_ShouldFillFromAddressFromConfig() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider("noreply@example.com", "Auto Sender")); + var mail = new MailMessage { Subject = "Auto From Test" }; + mail.To.Add("to@example.com"); + + // Act + sender.Send(mail, normalize: true); + + // Assert + mail.From.ShouldNotBeNull(); + mail.From.Address.ShouldBe("noreply@example.com"); + } + + /// + /// 测试目的:当 mail.From 已设置时,NormalizeMail 不应覆盖发件人地址。 + /// + [Fact] + public void Send_NormalizeMail_WhenFromAlreadySet_ShouldPreserveFrom() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider("noreply@example.com")); + var mail = new MailMessage + { + From = new MailAddress("custom@example.com"), + Subject = "Preserve From" + }; + mail.To.Add("to@example.com"); + + // Act + sender.Send(mail, normalize: true); + + // Assert:原发件人地址不应被覆盖 + mail.From.Address.ShouldBe("custom@example.com"); + } + + /// + /// 测试目的:Send(MailMessage, normalize=false) 不应修改 MailMessage 的任何编码字段。 + /// + [Fact] + public void Send_WithNormalizeFalse_ShouldNotModifyMail() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + var mail = new MailMessage { Subject = "No Normalize" }; + mail.To.Add("to@example.com"); + + // Act + sender.Send(mail, normalize: false); + + // Assert:编码未被设置(仍为 null) + mail.HeadersEncoding.ShouldBeNull(); + mail.SubjectEncoding.ShouldBeNull(); + } + + /// + /// 测试目的:Send(EmailBox) 不应抛异常,验证 EmailBox 路径可走通。 + /// + [Fact] + public void Send_EmailBox_ShouldNotThrow() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + var box = new EmailBox + { + Subject = "Box Test", + Body = "

Hello

", + To = new System.Collections.Generic.List { "to@example.com" } + }; + + // Act & Assert + Should.NotThrow(() => sender.Send(box)); + } + + /// + /// 测试目的:SendAsync(EmailBox) 不应抛异常,验证异步 EmailBox 路径可走通。 + /// + [Fact] + public async Task SendAsync_EmailBox_ShouldNotThrow() + { + // Arrange + var sender = new NullEmailSender(CreateConfigProvider()); + var box = new EmailBox + { + Subject = "Async Box Test", + Body = "

Hello Async

", + To = new System.Collections.Generic.List { "to@example.com" } + }; + + // Act & Assert + await Should.NotThrowAsync(() => sender.SendAsync(box)); + } +} + +/// +/// 测试。 +/// 验证 Enqueue 是否正确委托给 。 +/// +public class MailQueueServiceTest +{ + /// + /// 测试目的:Enqueue 应将邮件委托给 IMailQueueProvider.Enqueue,不做额外转换。 + /// + [Fact] + public void Enqueue_ShouldDelegateToProvider() + { + // Arrange + var mockProvider = new Mock(); + var service = new MailQueueService(mockProvider.Object); + var box = new EmailBox { Subject = "Delegate Test" }; + + // Act + service.Enqueue(box); + + // Assert + mockProvider.Verify(p => p.Enqueue(box), Times.Once); + } + + /// + /// 测试目的:Enqueue 使用正确的 EmailBox 实例(引用传递,不复制)。 + /// + [Fact] + public void Enqueue_ShouldPassExactBoxReference() + { + // Arrange + EmailBox capturedBox = null; + var mockProvider = new Mock(); + mockProvider + .Setup(p => p.Enqueue(It.IsAny())) + .Callback(b => capturedBox = b); + var service = new MailQueueService(mockProvider.Object); + var box = new EmailBox { Subject = "Reference Test" }; + + // Act + service.Enqueue(box); + + // Assert + capturedBox.ShouldBeSameAs(box); + } +} + +/// +/// 基本操作测试。 +/// 注意:MailQueueProvider 内部使用静态 ConcurrentQueue,测试间可能共享状态, +/// 因此每个测试在操作后应清理自己入队的元素,以减少测试间干扰。 +/// +public class MailQueueProviderTest +{ + /// + /// 测试目的:Enqueue 应使 Count 增加,IsEmpty 变为 false。 + /// + [Fact] + public void Enqueue_ShouldIncrementCountAndNotBeEmpty() + { + // Arrange + var provider = new MailQueueProvider(); + var countBefore = provider.Count; + var box = new EmailBox { Subject = "Queue Count Test" }; + + // Act + provider.Enqueue(box); + + // Assert + provider.Count.ShouldBe(countBefore + 1); + provider.IsEmpty.ShouldBeFalse(); + + // Cleanup:取出自己入队的项 + provider.TryDequeue(out _); + } + + /// + /// 测试目的:TryDequeue 在队列非空时应返回 true 并输出邮件项。 + /// + [Fact] + public void TryDequeue_WhenNotEmpty_ShouldReturnTrueAndItem() + { + // Arrange + var provider = new MailQueueProvider(); + var box = new EmailBox { Subject = "Dequeue Test" }; + provider.Enqueue(box); + + // Act + var result = provider.TryDequeue(out var dequeued); + + // Assert + result.ShouldBeTrue(); + dequeued.ShouldNotBeNull(); + } + + /// + /// 测试目的:Count 与实际入队数量匹配(相对计数验证)。 + /// + [Fact] + public void Count_ShouldReflectEnqueuedItems() + { + // Arrange + var provider = new MailQueueProvider(); + var baseline = provider.Count; + + // Act + provider.Enqueue(new EmailBox { Subject = "A" }); + provider.Enqueue(new EmailBox { Subject = "B" }); + + // Assert + provider.Count.ShouldBe(baseline + 2); + + // Cleanup + provider.TryDequeue(out _); + provider.TryDequeue(out _); + } +} diff --git a/framework/tests/Bing.MultiTenancy.Tests/AsyncLocalCurrentTenantAccessorTest.cs b/framework/tests/Bing.MultiTenancy.Tests/AsyncLocalCurrentTenantAccessorTest.cs new file mode 100644 index 00000000..d5f5c94a --- /dev/null +++ b/framework/tests/Bing.MultiTenancy.Tests/AsyncLocalCurrentTenantAccessorTest.cs @@ -0,0 +1,132 @@ +using Shouldly; + +namespace Bing.MultiTenancy; + +/// +/// AsyncLocalCurrentTenantAccessor 基于 AsyncLocal 的当前租户访问器测试 +/// +public class AsyncLocalCurrentTenantAccessorTest +{ + // ==================== 默认值 ==================== + + /// + /// 测试目的:新建访问器后,Current 应为 null(无租户)。 + /// + [Fact] + public void Current_DefaultIsNull() + { + // Arrange & Act + var accessor = new AsyncLocalCurrentTenantAccessor(); + + // Assert + accessor.Current.ShouldBeNull(); + } + + // ==================== 读写 ==================== + + /// + /// 测试目的:设置 Current 后,再次读取应返回相同的 BasicTenantInfo。 + /// + [Fact] + public void Current_Set_CanBeReadBack() + { + // Arrange + var accessor = new AsyncLocalCurrentTenantAccessor(); + var info = new BasicTenantInfo("tid-001", "TenantAlpha"); + + // Act + accessor.Current = info; + + // Assert + accessor.Current.ShouldNotBeNull(); + accessor.Current!.TenantId.ShouldBe("tid-001"); + accessor.Current!.Name.ShouldBe("TenantAlpha"); + } + + /// + /// 测试目的:将 Current 设为 null,再读取应返回 null。 + /// + [Fact] + public void Current_SetToNull_ReturnsNull() + { + // Arrange + var accessor = new AsyncLocalCurrentTenantAccessor(); + accessor.Current = new BasicTenantInfo("tid-001"); + + // Act + accessor.Current = null; + + // Assert + accessor.Current.ShouldBeNull(); + } + + // ==================== 静态 Instance ==================== + + /// + /// 测试目的:AsyncLocalCurrentTenantAccessor.Instance 不为 null,且是单例。 + /// + [Fact] + public void Instance_IsNotNull_And_IsSingleton() + { + // Act + var a = AsyncLocalCurrentTenantAccessor.Instance; + var b = AsyncLocalCurrentTenantAccessor.Instance; + + // Assert + a.ShouldNotBeNull(); + a.ShouldBeSameAs(b); + } + + // ==================== AsyncLocal 隔离 ==================== + + /// + /// 测试目的:子 Task 内修改 Current,不影响父上下文(AsyncLocal 隔离语义)。 + /// + [Fact] + public async Task Current_ChildTask_ModificationDoesNotAffectParent() + { + // Arrange:每个测试用独立访问器,避免静态状态污染 + var accessor = new AsyncLocalCurrentTenantAccessor(); + accessor.Current = new BasicTenantInfo("parent-tid"); + + string? childSeenId = null; + + // Act:子任务修改访问器 + await Task.Run(() => + { + accessor.Current = new BasicTenantInfo("child-tid"); + childSeenId = accessor.Current?.TenantId; + }); + + // Assert:子任务能看到自己设置的值 + childSeenId.ShouldBe("child-tid"); + + // Assert:父上下文仍为 parent-tid(AsyncLocal 值不回流) + accessor.Current?.TenantId.ShouldBe("parent-tid"); + } + + /// + /// 测试目的:并发多个 Task,每个 Task 独立设置 Current,相互之间应不干扰。 + /// + [Fact] + public async Task Current_MultipleConcurrentTasks_AreIsolated() + { + // Arrange + var accessor = new AsyncLocalCurrentTenantAccessor(); + var results = new System.Collections.Concurrent.ConcurrentDictionary(); + + // Act + var tasks = Enumerable.Range(0, 5).Select(i => Task.Run(async () => + { + accessor.Current = new BasicTenantInfo($"tenant-{i}"); + await Task.Delay(5); // 给其他任务插入机会 + results[i] = accessor.Current?.TenantId; + })).ToArray(); + + await Task.WhenAll(tasks); + + // Assert:每个任务看到的是自己设置的租户 + for (var i = 0; i < 5; i++) + results[i].ShouldBe($"tenant-{i}"); + } +} diff --git a/framework/tests/Bing.MultiTenancy.Tests/CurrentTenantExtendedTest.cs b/framework/tests/Bing.MultiTenancy.Tests/CurrentTenantExtendedTest.cs new file mode 100644 index 00000000..4ba99179 --- /dev/null +++ b/framework/tests/Bing.MultiTenancy.Tests/CurrentTenantExtendedTest.cs @@ -0,0 +1,203 @@ +using Shouldly; + +namespace Bing.MultiTenancy; + +/// +/// CurrentTenant 扩展行为测试(补充 CurrentTenantTest 中未覆盖的边界) +/// +public class CurrentTenantExtendedTest +{ + /// + /// 创建独立的 ICurrentTenant 实例(不依赖 DI 容器) + /// + private static ICurrentTenant CreateCurrentTenant() => + new CurrentTenant(new AsyncLocalCurrentTenantAccessor()); + + // ==================== IsAvailable ==================== + + /// + /// 测试目的:未设置租户时,IsAvailable 应为 false。 + /// + [Fact] + public void IsAvailable_WhenNoTenantSet_IsFalse() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Assert + tenant.IsAvailable.ShouldBeFalse(); + } + + /// + /// 测试目的:设置非空租户 ID 后,IsAvailable 应为 true。 + /// + [Fact] + public void IsAvailable_WhenTenantIdSet_IsTrue() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Act & Assert + using (tenant.Change("tenant-001")) + { + tenant.IsAvailable.ShouldBeTrue(); + } + + // 离开 scope 后恢复 false + tenant.IsAvailable.ShouldBeFalse(); + } + + /// + /// 测试目的:Change(null) 相当于切换到宿主(IsAvailable 为 false)。 + /// + [Fact] + public void IsAvailable_WhenChangedToNull_IsFalse() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Act:先切换到租户,再切换到宿主 + using (tenant.Change("tenant-001")) + { + tenant.IsAvailable.ShouldBeTrue(); + + using (tenant.Change(null)) + { + // 切换到宿主:Id = null,IsAvailable = false + tenant.Id.ShouldBeNull(); + tenant.IsAvailable.ShouldBeFalse(); + } + + // 恢复到外层 tenant-001 + tenant.IsAvailable.ShouldBeTrue(); + } + } + + // ==================== Name ==================== + + /// + /// 测试目的:未设置租户时,Name 应为 null。 + /// + [Fact] + public void Name_WhenNoTenantSet_IsNull() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Assert + tenant.Name.ShouldBeNull(); + } + + /// + /// 测试目的:Change 传入 name 参数后,Name 属性应返回该名称。 + /// + [Fact] + public void Name_WhenSet_ReturnsCorrectName() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Act + using (tenant.Change("id-001", "TenantAlpha")) + { + // Assert + tenant.Name.ShouldBe("TenantAlpha"); + tenant.Id.ShouldBe("id-001"); + } + } + + /// + /// 测试目的:不传 name 时,Name 应为 null。 + /// + [Fact] + public void Name_WhenNotProvided_IsNull() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Act + using (tenant.Change("id-001")) + { + // Assert + tenant.Name.ShouldBeNull(); + } + } + + // ==================== 嵌套 Change / 恢复 ==================== + + /// + /// 测试目的:三层嵌套 Change,内层 Dispose 后应逐层恢复到外层租户。 + /// + [Fact] + public void NestedChange_RestoresPreviousTenantOnDispose() + { + // Arrange + var tenant = CreateCurrentTenant(); + + // Assert 初始状态 + tenant.Id.ShouldBeNull(); + + using (tenant.Change("T1")) + { + tenant.Id.ShouldBe("T1"); + + using (tenant.Change("T2")) + { + tenant.Id.ShouldBe("T2"); + + using (tenant.Change("T3")) + { + tenant.Id.ShouldBe("T3"); + } + + tenant.Id.ShouldBe("T2"); // 恢复 T2 + } + + tenant.Id.ShouldBe("T1"); // 恢复 T1 + } + + tenant.Id.ShouldBeNull(); // 恢复默认 + } + + // ==================== 异步上下文隔离 ==================== + + /// + /// 测试目的:在并发 Task 中分别 Change 租户,不同任务之间不应互相干扰(AsyncLocal 隔离)。 + /// + [Fact] + public async Task Change_AsyncIsolation_DifferentTasksHaveDifferentTenants() + { + // Arrange + var tenant = CreateCurrentTenant(); + var resultA = string.Empty; + var resultB = string.Empty; + + // Act:两个并发任务分别设置不同的租户 + var taskA = Task.Run(async () => + { + using (tenant.Change("TenantA")) + { + await Task.Delay(10); + resultA = tenant.Id; + } + }); + + var taskB = Task.Run(async () => + { + using (tenant.Change("TenantB")) + { + await Task.Delay(10); + resultB = tenant.Id; + } + }); + + await Task.WhenAll(taskA, taskB); + + // Assert:每个任务看到自己设置的租户 + resultA.ShouldBe("TenantA"); + resultB.ShouldBe("TenantB"); + + // 父上下文不受影响 + tenant.Id.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContextAndStoreTest.cs b/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContextAndStoreTest.cs new file mode 100644 index 00000000..22bdb317 --- /dev/null +++ b/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContextAndStoreTest.cs @@ -0,0 +1,274 @@ +using Bing.MultiTenancy.ConfigurationStore; +using Microsoft.Extensions.Options; +using Moq; +using Shouldly; + +namespace Bing.MultiTenancy; + +/// +/// 测试目的:验证 的四种边界逻辑。 +/// +public class TenantResolveContextTest +{ + private static TenantResolveContext CreateContext() => + new(Mock.Of()); + + // ── HasResolvedTenantOrHost ───────────────────────────────────────────── + + /// + /// 测试目的:Handled=false 且 TenantIdOrName=null 时,HasResolvedTenantOrHost 应返回 false。 + /// + [Fact] + public void HasResolvedTenantOrHost_WhenBothFalseAndNull_ShouldReturnFalse() + { + // Arrange + var context = CreateContext(); + + // Act & Assert + context.Handled.ShouldBeFalse(); + context.TenantIdOrName.ShouldBeNull(); + context.HasResolvedTenantOrHost().ShouldBeFalse(); + } + + /// + /// 测试目的:Handled=true 时,HasResolvedTenantOrHost 应返回 true(即使 TenantIdOrName 为 null)。 + /// + [Fact] + public void HasResolvedTenantOrHost_WhenHandledTrue_ShouldReturnTrue() + { + // Arrange + var context = CreateContext(); + context.Handled = true; + + // Act & Assert + context.HasResolvedTenantOrHost().ShouldBeTrue(); + } + + /// + /// 测试目的:TenantIdOrName 有值时,HasResolvedTenantOrHost 应返回 true(即使 Handled=false)。 + /// + [Fact] + public void HasResolvedTenantOrHost_WhenTenantIdOrNameSet_ShouldReturnTrue() + { + // Arrange + var context = CreateContext(); + context.TenantIdOrName = "tenant-a"; + + // Act & Assert + context.HasResolvedTenantOrHost().ShouldBeTrue(); + } + + /// + /// 测试目的:Handled=true 且 TenantIdOrName 有值时,HasResolvedTenantOrHost 应返回 true。 + /// + [Fact] + public void HasResolvedTenantOrHost_WhenBothSet_ShouldReturnTrue() + { + // Arrange + var context = CreateContext(); + context.Handled = true; + context.TenantIdOrName = "tenant-b"; + + // Act & Assert + context.HasResolvedTenantOrHost().ShouldBeTrue(); + } + + // ── ServiceProvider 属性 ──────────────────────────────────────────────── + + /// + /// 测试目的:构造时传入的 ServiceProvider 应能通过属性读取(引用相同)。 + /// + [Fact] + public void ServiceProvider_ShouldBeTheOnePassedInCtor() + { + // Arrange + var sp = Mock.Of(); + + // Act + var context = new TenantResolveContext(sp); + + // Assert + context.ServiceProvider.ShouldBeSameAs(sp); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 的 Null Object 行为。 +/// +public class NullTenantResolveResultAccessorTest +{ + /// + /// 测试目的:Result getter 应始终返回 null(Null Object 模式,永远不持有状态)。 + /// + [Fact] + public void Result_Getter_ShouldAlwaysReturnNull() + { + // Arrange + var accessor = new NullTenantResolveResultAccessor(); + + // Assert + accessor.Result.ShouldBeNull(); + } + + /// + /// 测试目的:Result setter 赋值后,getter 应仍返回 null(setter 是 no-op)。 + /// + [Fact] + public void Result_Setter_ShouldBeNoOp_GettterStillNull() + { + // Arrange + var accessor = new NullTenantResolveResultAccessor(); + var result = new TenantResolveResult { TenantIdOrName = "tenant-x" }; + + // Act + accessor.Result = result; + + // Assert + accessor.Result.ShouldBeNull(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// 测试目的:验证 在不同查询场景下的行为, +/// 使用 IOptionsMonitor<BingDefaultTenantStoreOptions> Mock 隔离真实配置系统。 +/// +public class DefaultTenantStoreTest +{ + private static DefaultTenantStore CreateStore(params TenantConfiguration[] tenants) + { + var options = new BingDefaultTenantStoreOptions { Tenants = tenants }; + var mockMonitor = new Mock>(); + mockMonitor.Setup(m => m.CurrentValue).Returns(options); + return new DefaultTenantStore(mockMonitor.Object); + } + + // ── FindByNameAsync ──────────────────────────────────────────────────── + + /// + /// 测试目的:FindByNameAsync 按 NormalizedName 匹配,存在时返回对应租户配置。 + /// + [Fact] + public async Task FindByNameAsync_WhenNameExists_ShouldReturnTenant() + { + // Arrange + var tenant = new TenantConfiguration("t1", "tenant-a") { NormalizedName = "TENANT-A" }; + var store = CreateStore(tenant); + + // Act + var result = await store.FindByNameAsync("TENANT-A"); + + // Assert + result.ShouldNotBeNull(); + result.Id.ShouldBe("t1"); + } + + /// + /// 测试目的:FindByNameAsync 查询不存在的名称时,应返回 null。 + /// + [Fact] + public async Task FindByNameAsync_WhenNameNotExists_ShouldReturnNull() + { + // Arrange + var store = CreateStore(new TenantConfiguration("t1", "tenant-a") { NormalizedName = "TENANT-A" }); + + // Act + var result = await store.FindByNameAsync("NONEXISTENT"); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:Tenants 为空数组时 FindByNameAsync 应返回 null,不抛异常。 + /// + [Fact] + public async Task FindByNameAsync_WhenNoTenants_ShouldReturnNull() + { + // Arrange + var store = CreateStore(); + + // Act + var result = await store.FindByNameAsync("any"); + + // Assert + result.ShouldBeNull(); + } + + // ── FindByIdAsync ────────────────────────────────────────────────────── + + /// + /// 测试目的:FindByIdAsync 按 Id 匹配,存在时返回对应租户配置。 + /// + [Fact] + public async Task FindByIdAsync_WhenIdExists_ShouldReturnTenant() + { + // Arrange + var tenant = new TenantConfiguration("id-001", "tenant-b"); + var store = CreateStore(tenant); + + // Act + var result = await store.FindByIdAsync("id-001"); + + // Assert + result.ShouldNotBeNull(); + result.Name.ShouldBe("tenant-b"); + } + + /// + /// 测试目的:FindByIdAsync 查询不存在的 Id 时,应返回 null。 + /// + [Fact] + public async Task FindByIdAsync_WhenIdNotExists_ShouldReturnNull() + { + // Arrange + var store = CreateStore(new TenantConfiguration("id-001", "tenant-b")); + + // Act + var result = await store.FindByIdAsync("no-such-id"); + + // Assert + result.ShouldBeNull(); + } + + // ── GetListAsync ─────────────────────────────────────────────────────── + + /// + /// 测试目的:GetListAsync 应返回所有配置的租户列表,数量与配置一致。 + /// + [Fact] + public async Task GetListAsync_ShouldReturnAllTenants() + { + // Arrange + var t1 = new TenantConfiguration("t1", "a"); + var t2 = new TenantConfiguration("t2", "b"); + var store = CreateStore(t1, t2); + + // Act + var list = await store.GetListAsync(); + + // Assert + list.ShouldNotBeNull(); + list.Count.ShouldBe(2); + } + + /// + /// 测试目的:Tenants 为空数组时 GetListAsync 应返回空列表,不抛异常。 + /// + [Fact] + public async Task GetListAsync_WhenEmpty_ShouldReturnEmptyList() + { + // Arrange + var store = CreateStore(); + + // Act + var list = await store.GetListAsync(); + + // Assert + list.ShouldNotBeNull(); + list.Count.ShouldBe(0); + } +} diff --git a/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContributorTest.cs b/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContributorTest.cs new file mode 100644 index 00000000..671e6f7b --- /dev/null +++ b/framework/tests/Bing.MultiTenancy.Tests/TenantResolveContributorTest.cs @@ -0,0 +1,180 @@ +using Bing.Users; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.MultiTenancy; + +/// +/// 和 +/// 单元测试 +/// +public class TenantResolveContributorTest +{ + // ═══════════════════════════════════════════════════════════ + // ActionTenantResolveContributor — 直接测试 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:传入 null Action 时,构造器应抛出 ArgumentNullException, + /// 防止 ResolveAsync 时出现 NRE。 + /// + [Fact] + public void ActionTenantResolveContributor_NullAction_ShouldThrow() + { + // Act & Assert + Should.Throw(() => + new ActionTenantResolveContributor(null)); + } + + /// + /// 测试目的:Name 属性应返回 "Action"(ContributorName 常量), + /// 确保 TenantResolver 中的 AppliedResolvers 能正确识别此构造器。 + /// + [Fact] + public void ActionTenantResolveContributor_Name_ShouldBeAction() + { + // Arrange + var contributor = new ActionTenantResolveContributor(_ => { }); + + // Assert + contributor.Name.ShouldBe(ActionTenantResolveContributor.ContributorName); + contributor.Name.ShouldBe("Action"); + } + + /// + /// 测试目的:ResolveAsync 应调用注入的 Action 并传入上下文, + /// 确保自定义解析逻辑可以正确设置 TenantIdOrName。 + /// + [Fact] + public async Task ActionTenantResolveContributor_ResolveAsync_ShouldInvokeAction() + { + // Arrange + var mockSp = new Mock(); + var ctx = new TenantResolveContext(mockSp.Object); + var called = false; + var contributor = new ActionTenantResolveContributor(c => + { + called = true; + c.TenantIdOrName = "action-tenant"; + c.Handled = true; + }); + + // Act + await contributor.ResolveAsync(ctx); + + // Assert + called.ShouldBeTrue(); + ctx.TenantIdOrName.ShouldBe("action-tenant"); + ctx.Handled.ShouldBeTrue(); + } + + /// + /// 测试目的:ResolveAsync 应返回已完成的 Task(非 null), + /// 确保调用方可以安全 await。 + /// + [Fact] + public async Task ActionTenantResolveContributor_ResolveAsync_ShouldCompleteSuccessfully() + { + // Arrange + var mockSp = new Mock(); + var ctx = new TenantResolveContext(mockSp.Object); + var contributor = new ActionTenantResolveContributor(_ => { }); + + // Act & Assert(不抛异常即为通过) + await contributor.ResolveAsync(ctx); + } + + // ═══════════════════════════════════════════════════════════ + // CurrentUserTenantResolveContributor — Mock ICurrentUser + // ═══════════════════════════════════════════════════════════ + + /// + /// 构建包含 Mock ICurrentUser 的 IServiceProvider + /// + private static IServiceProvider BuildServiceProvider(bool isAuthenticated, string? tenantId) + { + var mockUser = new Mock(); + mockUser.Setup(u => u.IsAuthenticated).Returns(isAuthenticated); + mockUser.Setup(u => u.TenantId).Returns(tenantId); + var services = new ServiceCollection(); + services.AddSingleton(mockUser.Object); + return services.BuildServiceProvider(); + } + + /// + /// 测试目的:Name 属性应返回 "CurrentUser"(ContributorName 常量)。 + /// + [Fact] + public void CurrentUserTenantResolveContributor_Name_ShouldBeCurrentUser() + { + // Arrange + var contributor = new CurrentUserTenantResolveContributor(); + + // Assert + contributor.Name.ShouldBe(CurrentUserTenantResolveContributor.ContributorName); + contributor.Name.ShouldBe("CurrentUser"); + } + + /// + /// 测试目的:用户已认证且有 TenantId 时,应将 Handled=true 并设置 TenantIdOrName, + /// 确保租户解析链可以短路后续构造器。 + /// + [Fact] + public async Task CurrentUserTenantResolveContributor_AuthenticatedWithTenantId_ShouldSetHandled() + { + // Arrange + var sp = BuildServiceProvider(isAuthenticated: true, tenantId: "tenant-42"); + var ctx = new TenantResolveContext(sp); + var contributor = new CurrentUserTenantResolveContributor(); + + // Act + await contributor.ResolveAsync(ctx); + + // Assert + ctx.Handled.ShouldBeTrue(); + ctx.TenantIdOrName.ShouldBe("tenant-42"); + } + + /// + /// 测试目的:用户未认证时,Handled 应保持 false,不设置 TenantIdOrName, + /// 确保匿名用户不会影响租户解析结果。 + /// + [Fact] + public async Task CurrentUserTenantResolveContributor_NotAuthenticated_ShouldNotSetHandled() + { + // Arrange + var sp = BuildServiceProvider(isAuthenticated: false, tenantId: null); + var ctx = new TenantResolveContext(sp); + var contributor = new CurrentUserTenantResolveContributor(); + + // Act + await contributor.ResolveAsync(ctx); + + // Assert + ctx.Handled.ShouldBeFalse(); + ctx.TenantIdOrName.ShouldBeNull(); + } + + /// + /// 测试目的:用户已认证但 TenantId 为 null(宿主管理员场景), + /// Handled 应为 true,TenantIdOrName 应为 null, + /// 确保宿主身份可以正确终止租户解析链。 + /// + [Fact] + public async Task CurrentUserTenantResolveContributor_AuthenticatedWithNullTenantId_ShouldHandleAsHost() + { + // Arrange + var sp = BuildServiceProvider(isAuthenticated: true, tenantId: null); + var ctx = new TenantResolveContext(sp); + var contributor = new CurrentUserTenantResolveContributor(); + + // Act + await contributor.ResolveAsync(ctx); + + // Assert + ctx.Handled.ShouldBeTrue(); + ctx.TenantIdOrName.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.MultiTenancy.Tests/TenantResolverTest.cs b/framework/tests/Bing.MultiTenancy.Tests/TenantResolverTest.cs new file mode 100644 index 00000000..c30b9af6 --- /dev/null +++ b/framework/tests/Bing.MultiTenancy.Tests/TenantResolverTest.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace Bing.MultiTenancy; + +/// +/// TenantResolver 租户解析器优先级测试 +/// +public class TenantResolverTest +{ + /// + /// 构建包含若干 ActionTenantResolveContributor 的 TenantResolver + /// + private static ITenantResolver BuildResolver( + params Action[] actions) + { + var services = new ServiceCollection(); + services.Configure(opts => + { + // 清空默认贡献者,仅使用测试贡献者 + opts.TenantResolvers.Clear(); + foreach (var action in actions) + opts.TenantResolvers.Add(new ActionTenantResolveContributor(action)); + }); + services.AddSingleton(); + var sp = services.BuildServiceProvider(); + return sp.GetRequiredService(); + } + + // ==================== 基本解析 ==================== + + /// + /// 测试目的:第一个设置 TenantIdOrName 并标记 Handled 的贡献者应获胜,后续贡献者不执行。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_FirstContributorWins() + { + // Arrange + var secondCalled = false; + var resolver = BuildResolver( + ctx => + { + ctx.TenantIdOrName = "first"; + ctx.Handled = true; + }, + ctx => { secondCalled = true; } + ); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert + result.TenantIdOrName.ShouldBe("first"); + secondCalled.ShouldBeFalse(); + } + + /// + /// 测试目的:第一个贡献者未设置 TenantIdOrName,第二个贡献者应被执行并设置值。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_SecondContributorUsed_WhenFirstSkips() + { + // Arrange + var resolver = BuildResolver( + ctx => { /* 第一个什么都不做 */ }, + ctx => + { + ctx.TenantIdOrName = "second"; + ctx.Handled = true; + } + ); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert + result.TenantIdOrName.ShouldBe("second"); + } + + /// + /// 测试目的:所有贡献者均不设置值时,TenantIdOrName 应为 null(宿主租户)。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_AllSkip_ReturnsNullTenantId() + { + // Arrange + var resolver = BuildResolver( + ctx => { /* 不做任何事 */ }, + ctx => { /* 不做任何事 */ } + ); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert + result.TenantIdOrName.ShouldBeNull(); + } + + /// + /// 测试目的:无贡献者时,TenantIdOrName 应为 null,AppliedResolvers 为空。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_NoContributors_ReturnsNullTenantId() + { + // Arrange + var resolver = BuildResolver(/* 无任何贡献者 */); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert + result.TenantIdOrName.ShouldBeNull(); + result.AppliedResolvers.ShouldBeEmpty(); + } + + // ==================== AppliedResolvers 跟踪 ==================== + + /// + /// 测试目的:AppliedResolvers 应记录所有执行过(被调用到)的贡献者名称。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_AppliedResolvers_TracksExecutedContributors() + { + // Arrange:两个贡献者,第二个才解析到租户 + var resolver = BuildResolver( + ctx => { /* 第一个未处理 */ }, + ctx => + { + ctx.TenantIdOrName = "tenant-x"; + ctx.Handled = true; + } + ); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert:两个都被记录 + result.AppliedResolvers.Count.ShouldBe(2); + result.TenantIdOrName.ShouldBe("tenant-x"); + } + + /// + /// 测试目的:第一个贡献者已 Handled,后续贡献者不应出现在 AppliedResolvers 中。 + /// + [Fact] + public async Task ResolveTenantIdOrNameAsync_AppliedResolvers_StopsAtFirstHandled() + { + // Arrange + var resolver = BuildResolver( + ctx => + { + ctx.TenantIdOrName = "tenant-first"; + ctx.Handled = true; + }, + ctx => { /* 不应被执行 */ } + ); + + // Act + var result = await resolver.ResolveTenantIdOrNameAsync(); + + // Assert:只有第一个贡献者被记录 + result.AppliedResolvers.Count.ShouldBe(1); + } +} diff --git a/framework/tests/Bing.Security.Tests/Authorization/BingAuthorizationExceptionTest.cs b/framework/tests/Bing.Security.Tests/Authorization/BingAuthorizationExceptionTest.cs new file mode 100644 index 00000000..d3107705 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Authorization/BingAuthorizationExceptionTest.cs @@ -0,0 +1,157 @@ +using Bing.Authorization; +using Bing.Exceptions; +using Bing.Logging; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Authorization; + +/// +/// 单元测试 +/// +public class BingAuthorizationExceptionTest +{ + // ═══════════════════════════════════════════════════════════ + // 继承结构 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:BingAuthorizationException 应继承自 Warning,可被统一的 Warning 处理链捕获。 + /// + [Fact] + public void BingAuthorizationException_ShouldInheritFromWarning() + { + // Arrange & Act + var ex = new BingAuthorizationException("权限不足"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:BingAuthorizationException 应实现 IHasLogLevel,允许调用者设置日志级别。 + /// + [Fact] + public void BingAuthorizationException_ShouldImplementIHasLogLevel() + { + // Arrange & Act + var ex = new BingAuthorizationException("权限不足"); + + // Assert + ex.ShouldBeAssignableTo(); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用 message 构造时,Message 应等于传入的错误描述文本。 + /// + [Fact] + public void Ctor_WithMessage_ShouldSetMessage() + { + // Arrange & Act + var ex = new BingAuthorizationException("无权访问该资源"); + + // Assert + ex.Message.ShouldBe("无权访问该资源"); + } + + /// + /// 测试目的:使用 Exception 构造时,InnerException 应正确保存内部异常。 + /// + [Fact] + public void Ctor_WithException_ShouldSetInnerException() + { + // Arrange + var inner = new InvalidOperationException("底层错误"); + + // Act + var ex = new BingAuthorizationException(inner); + + // Assert + ex.InnerException.ShouldBe(inner); + } + + /// + /// 测试目的:使用 message + code 构造时,Code 应被正确设置,用于错误分类与客户端解析。 + /// + [Fact] + public void Ctor_WithMessageAndCode_ShouldSetCode() + { + // Arrange & Act + var ex = new BingAuthorizationException("权限不足", "AUTH_403"); + + // Assert + ex.Message.ShouldBe("权限不足"); + ex.Code.ShouldBe("AUTH_403"); + } + + /// + /// 测试目的:使用 message + code + exception 构造时,三个字段均应被正确设置。 + /// + [Fact] + public void Ctor_WithMessageCodeAndException_ShouldSetAllFields() + { + // Arrange + var inner = new UnauthorizedAccessException("底层 401"); + + // Act + var ex = new BingAuthorizationException("授权失败", "AUTH_401", inner); + + // Assert + ex.Message.ShouldBe("授权失败"); + ex.Code.ShouldBe("AUTH_401"); + ex.InnerException.ShouldBe(inner); + } + + // ═══════════════════════════════════════════════════════════ + // LogLevel + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:LogLevel 默认值应为 Warning,表示授权失败是预期的业务拒绝,非系统错误。 + /// + [Fact] + public void LogLevel_Default_ShouldBeWarning() + { + // Arrange & Act + var ex = new BingAuthorizationException("权限不足"); + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Warning); + } + + /// + /// 测试目的:LogLevel 可被调用方覆盖,支持按场景调整日志级别。 + /// + [Fact] + public void LogLevel_WhenChanged_ShouldReflectNewValue() + { + // Arrange + var ex = new BingAuthorizationException("权限不足"); + + // Act + ex.LogLevel = LogLevel.Error; + + // Assert + ex.LogLevel.ShouldBe(LogLevel.Error); + } + + /// + /// 测试目的:通过 IHasLogLevel 接口读取 LogLevel,应与直接属性访问结果一致。 + /// + [Fact] + public void LogLevel_ViaInterface_ShouldMatchDirectAccess() + { + // Arrange + var ex = new BingAuthorizationException("权限不足"); + var hasLogLevel = (IHasLogLevel)ex; + + // Assert + hasLogLevel.LogLevel.ShouldBe(ex.LogLevel); + hasLogLevel.LogLevel.ShouldBe(LogLevel.Warning); + } +} diff --git a/framework/tests/Bing.Security.Tests/Authorization/ModuleInfoAndAuthorizationStatusTest.cs b/framework/tests/Bing.Security.Tests/Authorization/ModuleInfoAndAuthorizationStatusTest.cs new file mode 100644 index 00000000..e50af6c5 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Authorization/ModuleInfoAndAuthorizationStatusTest.cs @@ -0,0 +1,114 @@ +using Bing.Authorization.Modules; +using Bing.Security; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Authorization; + +/// +/// 单元测试 +/// +public class ModuleInfoAndAuthorizationStatusTest +{ + // ═══════════════════════════════════════════════════════════ + // ModuleInfo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后所有属性均为 null / 0,确保无意外默认值。 + /// + [Fact] + public void ModuleInfo_Default_AllPropertiesShouldBeNullOrDefault() + { + // Arrange & Act + var info = new ModuleInfo(); + + // Assert + info.Code.ShouldBeNull(); + info.Name.ShouldBeNull(); + info.SortId.ShouldBe(0); + info.Position.ShouldBeNull(); + } + + /// + /// 测试目的:属性赋值后应能正确读取,确保属性是可读写的。 + /// + [Fact] + public void ModuleInfo_SetProperties_ShouldReturnAssignedValues() + { + // Arrange & Act + var info = new ModuleInfo + { + Code = "user.manage", + Name = "用户管理", + SortId = 10, + Position = "system.admin" + }; + + // Assert + info.Code.ShouldBe("user.manage"); + info.Name.ShouldBe("用户管理"); + info.SortId.ShouldBe(10); + info.Position.ShouldBe("system.admin"); + } + + /// + /// 测试目的:SortId 可以设置为负数(如排在最前),确保 int 类型无下界限制。 + /// + [Fact] + public void ModuleInfo_NegativeSortId_ShouldBeAccepted() + { + // Arrange & Act + var info = new ModuleInfo { SortId = -1 }; + + // Assert + info.SortId.ShouldBe(-1); + } + + // ═══════════════════════════════════════════════════════════ + // AuthorizationStatus + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:验证枚举各成员的数值符合规范(HTTP 状态码对应值)。 + /// + [Fact] + public void AuthorizationStatus_Values_ShouldMatchExpected() + { + // Assert + ((int)AuthorizationStatus.Ok).ShouldBe(200); + ((int)AuthorizationStatus.Unauthorized).ShouldBe(401); + ((int)AuthorizationStatus.LoginTimeout).ShouldBe(402); + ((int)AuthorizationStatus.Forbidden).ShouldBe(403); + ((int)AuthorizationStatus.NoFound).ShouldBe(404); + ((int)AuthorizationStatus.Locked).ShouldBe(423); + ((int)AuthorizationStatus.OtherDeviceLogin).ShouldBe(424); + ((int)AuthorizationStatus.Error).ShouldBe(500); + } + + /// + /// 测试目的:枚举成员个数为 8,防止因新增/删除而导致使用方行为异常(用于变更感知)。 + /// + [Fact] + public void AuthorizationStatus_EnumCount_ShouldBe8() + { + // Assert + Enum.GetValues(typeof(AuthorizationStatus)).Length.ShouldBe(8); + } + + /// + /// 测试目的:AuthorizationStatus.Ok 的值可成功转换为 int,确保用于 HTTP 响应码时的兼容性。 + /// + [Fact] + public void AuthorizationStatus_OkValue_ShouldEqualHttpStatus200() + { + // Arrange + const int httpOk = 200; + + // Act + var status = AuthorizationStatus.Ok; + + // Assert + ((int)status).ShouldBe(httpOk); + } +} diff --git a/framework/tests/Bing.Security.Tests/Bing.Security.Tests.csproj b/framework/tests/Bing.Security.Tests/Bing.Security.Tests.csproj new file mode 100644 index 00000000..2c4737c9 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Bing.Security.Tests.csproj @@ -0,0 +1,13 @@ + + + + + false + + + + + + + + diff --git a/framework/tests/Bing.Security.Tests/Claims/BingClaimTypesTest.cs b/framework/tests/Bing.Security.Tests/Claims/BingClaimTypesTest.cs new file mode 100644 index 00000000..dd05bebd --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Claims/BingClaimTypesTest.cs @@ -0,0 +1,115 @@ +using System.Security.Claims; +using Bing.Security.Claims; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Claims; + +/// +/// 单元测试 +/// +public class BingClaimTypesTest +{ + // ═══════════════════════════════════════════════════════════ + // 默认值验证 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:UserName 默认值应为 ClaimTypes.Name,符合 MS 标准声明类型。 + /// + [Fact] + public void UserName_Default_ShouldBeClaimTypesName() + { + BingClaimTypes.UserName.ShouldBe(ClaimTypes.Name); + } + + /// + /// 测试目的:UserId 默认值应为 ClaimTypes.NameIdentifier。 + /// + [Fact] + public void UserId_Default_ShouldBeNameIdentifier() + { + BingClaimTypes.UserId.ShouldBe(ClaimTypes.NameIdentifier); + } + + /// + /// 测试目的:Email 默认值应为 ClaimTypes.Email。 + /// + [Fact] + public void Email_Default_ShouldBeClaimTypesEmail() + { + BingClaimTypes.Email.ShouldBe(ClaimTypes.Email); + } + + /// + /// 测试目的:Role 默认值应为 ClaimTypes.Role。 + /// + [Fact] + public void Role_Default_ShouldBeClaimTypesRole() + { + BingClaimTypes.Role.ShouldBe(ClaimTypes.Role); + } + + /// + /// 测试目的:PhoneNumber 默认值应为 "phone_number"。 + /// + [Fact] + public void PhoneNumber_Default_ShouldBePhoneNumber() + { + BingClaimTypes.PhoneNumber.ShouldBe("phone_number"); + } + + /// + /// 测试目的:TenantId 默认值应为 "tenant_id"。 + /// + [Fact] + public void TenantId_Default_ShouldBeTenantId() + { + BingClaimTypes.TenantId.ShouldBe("tenant_id"); + } + + /// + /// 测试目的:ClientId 默认值应为 "client_id"。 + /// + [Fact] + public void ClientId_Default_ShouldBeClientId() + { + BingClaimTypes.ClientId.ShouldBe("client_id"); + } + + /// + /// 测试目的:SessionId 默认值应为 "session_id"。 + /// + [Fact] + public void SessionId_Default_ShouldBeSessionId() + { + BingClaimTypes.SessionId.ShouldBe("session_id"); + } + + // ═══════════════════════════════════════════════════════════ + // 可覆盖性(静态属性可运行时替换) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:静态属性可被运行时覆盖,验证替换后能正确读回新值(恢复原值)。 + /// + [Fact] + public void UserName_WhenOverridden_ShouldReturnNewValue() + { + // Arrange + var original = BingClaimTypes.UserName; + try + { + // Act + BingClaimTypes.UserName = "custom_username"; + + // Assert + BingClaimTypes.UserName.ShouldBe("custom_username"); + } + finally + { + // Restore + BingClaimTypes.UserName = original; + } + } +} diff --git a/framework/tests/Bing.Security.Tests/Claims/BingClaimsPrincipalFactoryOptionsAndContextTest.cs b/framework/tests/Bing.Security.Tests/Claims/BingClaimsPrincipalFactoryOptionsAndContextTest.cs new file mode 100644 index 00000000..6e9fc4b1 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Claims/BingClaimsPrincipalFactoryOptionsAndContextTest.cs @@ -0,0 +1,169 @@ +using System.Security.Claims; +using Bing.Security.Claims; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Claims; + +/// +/// 单元测试 +/// +public class BingClaimsPrincipalFactoryOptionsAndContextTest +{ + // ═══════════════════════════════════════════════════════════ + // BingClaimsPrincipalFactoryOptions + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 Contributors 和 DynamicContributors 不为 null,且初始为空, + /// 防止遍历时触发 NullReferenceException。 + /// + [Fact] + public void Options_Default_ContributorListsShouldBeEmptyAndNotNull() + { + // Arrange & Act + var options = new BingClaimsPrincipalFactoryOptions(); + + // Assert + options.Contributors.ShouldNotBeNull(); + options.Contributors.Count.ShouldBe(0); + options.DynamicContributors.ShouldNotBeNull(); + options.DynamicContributors.Count.ShouldBe(0); + } + + /// + /// 测试目的:默认构造后 DynamicClaims 包含 UserName、Role、Email 等预定义字段, + /// 确保动态 Claims 刷新覆盖关键身份字段。 + /// + [Fact] + public void Options_Default_DynamicClaimsShouldContainExpectedFields() + { + // Arrange & Act + var options = new BingClaimsPrincipalFactoryOptions(); + + // Assert + options.DynamicClaims.ShouldNotBeNull(); + options.DynamicClaims.ShouldContain(BingClaimTypes.UserName); + options.DynamicClaims.ShouldContain(BingClaimTypes.Name); + options.DynamicClaims.ShouldContain(BingClaimTypes.SurName); + options.DynamicClaims.ShouldContain(BingClaimTypes.Role); + options.DynamicClaims.ShouldContain(BingClaimTypes.Email); + options.DynamicClaims.ShouldContain(BingClaimTypes.PhoneNumber); + } + + /// + /// 测试目的:默认构造后 IsRemoteRefreshEnabled = true,RemoteRefreshUrl 不为空, + /// 确保微服务身份同步开箱即用。 + /// + [Fact] + public void Options_Default_RemoteRefreshShouldBeEnabledWithDefaultUrl() + { + // Arrange & Act + var options = new BingClaimsPrincipalFactoryOptions(); + + // Assert + options.IsRemoteRefreshEnabled.ShouldBeTrue(); + options.RemoteRefreshUrl.ShouldNotBeNullOrEmpty(); + options.RemoteRefreshUrl.ShouldBe("/api/account/dynamic-claims/refresh"); + } + + /// + /// 测试目的:默认构造后 ClaimsMap 包含 UserName、Role、Email 的映射条目, + /// 确保 OpenID Connect 标准声明能被正确转换。 + /// + [Fact] + public void Options_Default_ClaimsMapShouldContainDefaultMappings() + { + // Arrange & Act + var options = new BingClaimsPrincipalFactoryOptions(); + + // Assert + options.ClaimsMap.ShouldNotBeNull(); + options.ClaimsMap.ContainsKey(BingClaimTypes.UserName).ShouldBeTrue(); + options.ClaimsMap.ContainsKey(BingClaimTypes.Role).ShouldBeTrue(); + options.ClaimsMap.ContainsKey(BingClaimTypes.Email).ShouldBeTrue(); + // UserName 映射来源至少包含 preferred_username + options.ClaimsMap[BingClaimTypes.UserName].ShouldContain("preferred_username"); + } + + /// + /// 测试目的:默认构造后 IsDynamicClaimsEnabled = false, + /// 防止在未显式开启时误启用动态 Claims 计算导致性能损耗。 + /// + [Fact] + public void Options_Default_IsDynamicClaimsEnabledShouldBeFalse() + { + // Arrange & Act + var options = new BingClaimsPrincipalFactoryOptions(); + + // Assert + options.IsDynamicClaimsEnabled.ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // BingClaimsPrincipalContributorContext + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:构造器应正确存储 ClaimsPrincipal 和 ServiceProvider, + /// 确保贡献者能通过 context 访问依赖。 + /// + [Fact] + public void Context_Constructor_ShouldSetClaimsPrincipalAndServiceProvider() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity("test")); + var mockSp = new Mock(); + + // Act + var context = new BingClaimsPrincipalContributorContext(principal, mockSp.Object); + + // Assert + context.ClaimsPrincipal.ShouldBeSameAs(principal); + context.ServiceProvider.ShouldBeSameAs(mockSp.Object); + } + + /// + /// 测试目的:ClaimsPrincipal 属性是可变的,贡献者可将修改后的主体写回 context。 + /// + [Fact] + public void Context_ClaimsPrincipal_ShouldBeMutable() + { + // Arrange + var original = new ClaimsPrincipal(new ClaimsIdentity("test")); + var mockSp = new Mock(); + var context = new BingClaimsPrincipalContributorContext(original, mockSp.Object); + var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity("updated")); + + // Act + context.ClaimsPrincipal = newPrincipal; + + // Assert + context.ClaimsPrincipal.ShouldBeSameAs(newPrincipal); + } + + /// + /// 测试目的:GetRequiredService<T>() 应从 ServiceProvider 正确解析服务, + /// 确保贡献者能按需获取依赖实例。 + /// + [Fact] + public void Context_GetRequiredService_ShouldResolveFromServiceProvider() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity("test")); + var mockSp = new Mock(); + var expectedAccessor = new ThreadCurrentPrincipalAccessor(); + mockSp + .Setup(sp => sp.GetService(typeof(ICurrentPrincipalAccessor))) + .Returns(expectedAccessor); + var context = new BingClaimsPrincipalContributorContext(principal, mockSp.Object); + + // Act + var resolved = context.GetRequiredService(); + + // Assert + resolved.ShouldNotBeNull(); + resolved.ShouldBeSameAs(expectedAccessor); + } +} diff --git a/framework/tests/Bing.Security.Tests/Claims/CurrentPrincipalAccessorExtensionsTest.cs b/framework/tests/Bing.Security.Tests/Claims/CurrentPrincipalAccessorExtensionsTest.cs new file mode 100644 index 00000000..e5c5d9ef --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Claims/CurrentPrincipalAccessorExtensionsTest.cs @@ -0,0 +1,142 @@ +using System.Security.Claims; +using Bing.Security.Claims; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Claims; + +/// +/// 单元测试 +/// +public class CurrentPrincipalAccessorExtensionsTest +{ + private readonly ThreadCurrentPrincipalAccessor _accessor = new(); + + // ═══════════════════════════════════════════════════════════ + // Change(Claim) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Change(Claim) 应将单个 Claim 包装为 ClaimsPrincipal 后更新当前主体, + /// 使 Principal.FindFirst 能找到该 Claim。 + /// + [Fact] + public void Change_WithSingleClaim_ShouldSetPrincipalWithThatClaim() + { + // Arrange + var claim = new Claim(ClaimTypes.Name, "alice"); + + // Act + using (_accessor.Change(claim)) + { + // Assert + _accessor.Principal.ShouldNotBeNull(); + _accessor.Principal.FindFirst(ClaimTypes.Name)?.Value.ShouldBe("alice"); + } + } + + /// + /// 测试目的:Change(Claim) 返回的 IDisposable Dispose 后,当前主体应恢复为变更前的值。 + /// + [Fact] + public void Change_WithSingleClaim_AfterDispose_ShouldRestoreOriginalPrincipal() + { + // Arrange + var originalPrincipal = _accessor.Principal; + var claim = new Claim(ClaimTypes.Name, "alice"); + + // Act + var disposable = _accessor.Change(claim); + disposable.Dispose(); + + // Assert — 恢复原始主体 + _accessor.Principal.ShouldBeSameAs(originalPrincipal); + } + + // ═══════════════════════════════════════════════════════════ + // Change(IEnumerable) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Change(IEnumerable<Claim>) 应将多个 Claim 一起包装为 ClaimsPrincipal, + /// 使 Principal 中能找到所有指定 Claim。 + /// + [Fact] + public void Change_WithMultipleClaims_ShouldSetPrincipalWithAllClaims() + { + // Arrange + var claims = new[] + { + new Claim(ClaimTypes.Name, "bob"), + new Claim(ClaimTypes.Email, "bob@example.com") + }; + + // Act + using (_accessor.Change(claims.AsEnumerable())) + { + // Assert + _accessor.Principal.FindFirst(ClaimTypes.Name)?.Value.ShouldBe("bob"); + _accessor.Principal.FindFirst(ClaimTypes.Email)?.Value.ShouldBe("bob@example.com"); + } + } + + /// + /// 测试目的:Change(IEnumerable<Claim>) Dispose 后主体应恢复为变更前的值。 + /// + [Fact] + public void Change_WithMultipleClaims_AfterDispose_ShouldRestoreOriginalPrincipal() + { + // Arrange + var originalPrincipal = _accessor.Principal; + var claims = new[] { new Claim(ClaimTypes.Name, "bob") }; + + // Act + var disposable = _accessor.Change(claims.AsEnumerable()); + disposable.Dispose(); + + // Assert + _accessor.Principal.ShouldBeSameAs(originalPrincipal); + } + + // ═══════════════════════════════════════════════════════════ + // Change(ClaimsIdentity) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Change(ClaimsIdentity) 应将给定身份包装为 ClaimsPrincipal, + /// 主体的 AuthenticationType 应与传入 Identity 一致。 + /// + [Fact] + public void Change_WithClaimsIdentity_ShouldSetPrincipalWithThatIdentity() + { + // Arrange + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "carol") }, "custom-auth"); + + // Act + using (_accessor.Change(identity)) + { + // Assert + _accessor.Principal.Identity?.AuthenticationType.ShouldBe("custom-auth"); + _accessor.Principal.FindFirst(ClaimTypes.Name)?.Value.ShouldBe("carol"); + } + } + + /// + /// 测试目的:Change(ClaimsIdentity) Dispose 后主体应恢复为变更前的值。 + /// + [Fact] + public void Change_WithClaimsIdentity_AfterDispose_ShouldRestoreOriginalPrincipal() + { + // Arrange + var originalPrincipal = _accessor.Principal; + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "carol") }, "test"); + + // Act + var disposable = _accessor.Change(identity); + disposable.Dispose(); + + // Assert + _accessor.Principal.ShouldBeSameAs(originalPrincipal); + } +} diff --git a/framework/tests/Bing.Security.Tests/Claims/ThreadCurrentPrincipalAccessorTest.cs b/framework/tests/Bing.Security.Tests/Claims/ThreadCurrentPrincipalAccessorTest.cs new file mode 100644 index 00000000..aacccd65 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Claims/ThreadCurrentPrincipalAccessorTest.cs @@ -0,0 +1,175 @@ +using System.Security.Claims; +using Bing.Security.Claims; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Claims; + +/// +/// 单元测试 +/// +public class ThreadCurrentPrincipalAccessorTest +{ + // ═══════════════════════════════════════════════════════════ + // Principal 获取 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 Thread.CurrentPrincipal 为有效 ClaimsPrincipal 时,Principal 应返回该主体。 + /// + [Fact] + public void Principal_WhenThreadCurrentPrincipalSet_ShouldReturnIt() + { + // Arrange + var original = Thread.CurrentPrincipal; + try + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "test-user") }, "test"); + var principal = new ClaimsPrincipal(identity); + Thread.CurrentPrincipal = principal; + + var accessor = new ThreadCurrentPrincipalAccessor(); + + // Act + var result = accessor.Principal; + + // Assert + result.ShouldNotBeNull(); + result.Identity!.Name.ShouldBe("test-user"); + } + finally + { + Thread.CurrentPrincipal = original; + } + } + + /// + /// 测试目的:当 Thread.CurrentPrincipal 为 null 时,Principal 应返回 null,而不抛异常。 + /// + [Fact] + public void Principal_WhenThreadCurrentPrincipalIsNull_ShouldReturnNull() + { + // Arrange + var original = Thread.CurrentPrincipal; + try + { + Thread.CurrentPrincipal = null; + var accessor = new ThreadCurrentPrincipalAccessor(); + + // Act + var result = accessor.Principal; + + // Assert + result.ShouldBeNull(); + } + finally + { + Thread.CurrentPrincipal = original; + } + } + + // ═══════════════════════════════════════════════════════════ + // Change() 作用域切换 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:调用 Change() 后,Principal 应返回新传入的主体(作用域内覆盖)。 + /// + [Fact] + public void Change_WithNewPrincipal_ShouldReturnNewPrincipal() + { + // Arrange + var original = Thread.CurrentPrincipal; + try + { + Thread.CurrentPrincipal = null; + var accessor = new ThreadCurrentPrincipalAccessor(); + + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "new-user") }, "override"); + var newPrincipal = new ClaimsPrincipal(identity); + + // Act + using (accessor.Change(newPrincipal)) + { + var inScope = accessor.Principal; + + // Assert + inScope.ShouldNotBeNull(); + inScope.Identity!.Name.ShouldBe("new-user"); + } + } + finally + { + Thread.CurrentPrincipal = original; + } + } + + /// + /// 测试目的:Change() Dispose 后,Principal 应恢复到变更前的值(作用域退出后回滚)。 + /// + [Fact] + public void Change_AfterDispose_ShouldRestorePreviousPrincipal() + { + // Arrange + var original = Thread.CurrentPrincipal; + try + { + Thread.CurrentPrincipal = null; + var accessor = new ThreadCurrentPrincipalAccessor(); + + var newIdentity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "temp-user") }, "temp"); + var newPrincipal = new ClaimsPrincipal(newIdentity); + + // Act + using (accessor.Change(newPrincipal)) + { + // scope:Principal = newPrincipal + } + + var afterDispose = accessor.Principal; + + // Assert + // Thread.CurrentPrincipal is null, AsyncLocal was reset → null + afterDispose.ShouldBeNull(); + } + finally + { + Thread.CurrentPrincipal = original; + } + } + + /// + /// 测试目的:Change() 可嵌套使用,最内层作用域返回最新设置的主体,退出后逐层恢复。 + /// + [Fact] + public void Change_Nested_ShouldRestoreEachLayer() + { + // Arrange + var original = Thread.CurrentPrincipal; + try + { + Thread.CurrentPrincipal = null; + var accessor = new ThreadCurrentPrincipalAccessor(); + + var outer = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "outer") }, "a")); + var inner = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "inner") }, "b")); + + // Act & Assert + using (accessor.Change(outer)) + { + accessor.Principal!.Identity!.Name.ShouldBe("outer"); + + using (accessor.Change(inner)) + { + accessor.Principal!.Identity!.Name.ShouldBe("inner"); + } + + accessor.Principal!.Identity!.Name.ShouldBe("outer"); + } + } + finally + { + Thread.CurrentPrincipal = original; + } + } +} diff --git a/framework/tests/Bing.Security.Tests/Clients/CurrentClientTest.cs b/framework/tests/Bing.Security.Tests/Clients/CurrentClientTest.cs new file mode 100644 index 00000000..98eebd28 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Clients/CurrentClientTest.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Bing.Clients; +using Bing.Security.Claims; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Clients; + +/// +/// 单元测试 +/// +public class CurrentClientTest +{ + // ═══════════════════════════════════════════════════════════ + // Id / IsAuthenticated + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 Principal 为 null 时,Id 应返回 null,IsAuthenticated 应为 false。 + /// + [Fact] + public void Id_WhenPrincipalIsNull_ShouldReturnNull() + { + // Arrange + var mockAccessor = new Mock(); + mockAccessor.Setup(a => a.Principal).Returns((ClaimsPrincipal)null); + var client = new CurrentClient(mockAccessor.Object); + + // Act & Assert + client.Id.ShouldBeNull(); + client.IsAuthenticated.ShouldBeFalse(); + } + + /// + /// 测试目的:当 Principal 不包含 ClientId claim 时,Id 应返回 null,IsAuthenticated 应为 false。 + /// + [Fact] + public void Id_WhenPrincipalHasNoClientIdClaim_ShouldReturnNull() + { + // Arrange + var mockAccessor = new Mock(); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "user1") }, "test")); + mockAccessor.Setup(a => a.Principal).Returns(principal); + var client = new CurrentClient(mockAccessor.Object); + + // Act & Assert + client.Id.ShouldBeNull(); + client.IsAuthenticated.ShouldBeFalse(); + } + + /// + /// 测试目的:当 Principal 包含有效 ClientId claim 时,Id 应返回该值,IsAuthenticated 应为 true。 + /// + [Fact] + public void Id_WhenPrincipalHasClientIdClaim_ShouldReturnClientId() + { + // Arrange + var mockAccessor = new Mock(); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(BingClaimTypes.ClientId, "my-client") }, "test")); + mockAccessor.Setup(a => a.Principal).Returns(principal); + var client = new CurrentClient(mockAccessor.Object); + + // Act & Assert + client.Id.ShouldBe("my-client"); + client.IsAuthenticated.ShouldBeTrue(); + } + + /// + /// 测试目的:当 ClientId claim 值为空字符串时,Id 应返回 null,IsAuthenticated 应为 false。 + /// + [Fact] + public void Id_WhenClientIdClaimIsEmpty_ShouldReturnNull() + { + // Arrange + var mockAccessor = new Mock(); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(BingClaimTypes.ClientId, string.Empty) }, "test")); + mockAccessor.Setup(a => a.Principal).Returns(principal); + var client = new CurrentClient(mockAccessor.Object); + + // Act & Assert + client.Id.ShouldBeNull(); + client.IsAuthenticated.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Security.Tests/Encryption/NullEncryptorTest.cs b/framework/tests/Bing.Security.Tests/Encryption/NullEncryptorTest.cs new file mode 100644 index 00000000..c59e940d --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Encryption/NullEncryptorTest.cs @@ -0,0 +1,80 @@ +using Bing.Security.Encryption; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Encryption; + +/// +/// 单元测试 +/// +public class NullEncryptorTest +{ + private readonly IEncryptor _encryptor = NullEncryptor.Instance; + + // ═══════════════════════════════════════════════════════════ + // Encrypt + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Encrypt 对任何输入都应返回空字符串(空加密器不做实际加密)。 + /// + [Fact] + public void Encrypt_WithAnyData_ShouldReturnEmpty() + { + _encryptor.Encrypt("secret-password").ShouldBe(string.Empty); + } + + /// + /// 测试目的:Encrypt 对 null 输入应返回空字符串,不抛 NullReferenceException。 + /// + [Fact] + public void Encrypt_WithNull_ShouldReturnEmpty() + { + _encryptor.Encrypt(null!).ShouldBe(string.Empty); + } + + /// + /// 测试目的:Encrypt 对空字符串输入应返回空字符串。 + /// + [Fact] + public void Encrypt_WithEmptyString_ShouldReturnEmpty() + { + _encryptor.Encrypt(string.Empty).ShouldBe(string.Empty); + } + + // ═══════════════════════════════════════════════════════════ + // Decrypt + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Decrypt 对任何输入都应返回空字符串(空加密器不做实际解密)。 + /// + [Fact] + public void Decrypt_WithAnyData_ShouldReturnEmpty() + { + _encryptor.Decrypt("cipher-text").ShouldBe(string.Empty); + } + + /// + /// 测试目的:Decrypt 对 null 输入应返回空字符串,不抛异常。 + /// + [Fact] + public void Decrypt_WithNull_ShouldReturnEmpty() + { + _encryptor.Decrypt(null!).ShouldBe(string.Empty); + } + + // ═══════════════════════════════════════════════════════════ + // Instance 单例 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Instance 字段应为 NullEncryptor 类型,多次访问返回同一引用。 + /// + [Fact] + public void Instance_ShouldBeSingleton() + { + ReferenceEquals(NullEncryptor.Instance, NullEncryptor.Instance).ShouldBeTrue(); + NullEncryptor.Instance.ShouldBeOfType(); + } +} diff --git a/framework/tests/Bing.Security.Tests/Principal/BingClaimsIdentityExtensionsTest.cs b/framework/tests/Bing.Security.Tests/Principal/BingClaimsIdentityExtensionsTest.cs new file mode 100644 index 00000000..2a7cd944 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Principal/BingClaimsIdentityExtensionsTest.cs @@ -0,0 +1,625 @@ +using System.Security.Claims; +using System.Security.Principal; +using Bing.Security.Claims; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Principal; + +/// +/// 单元测试。 +/// 覆盖所有扩展方法(ClaimsPrincipal 版本 + IIdentity 版本 + ClaimsIdentity 操作方法)。 +/// +public class BingClaimsIdentityExtensionsTest +{ + #region 辅助方法 + + /// + /// 使用给定的声明集合构建 ClaimsPrincipal + /// + private static ClaimsPrincipal BuildPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, "TestAuth")); + + /// + /// 使用给定的声明集合构建 ClaimsIdentity + /// + private static ClaimsIdentity BuildIdentity(params Claim[] claims) => + new(claims, "TestAuth"); + + #endregion + + // ═══════════════════════════════════════════════════════════ + // FindUserId — ClaimsPrincipal + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindUserId(ClaimsPrincipal) 在存在有效 Guid 格式的 UserId 声明时,应返回对应 Guid。 + /// + [Fact] + public void FindUserId_Principal_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var userId = Guid.NewGuid(); + var principal = BuildPrincipal(new Claim(BingClaimTypes.UserId, userId.ToString())); + + // Act + var result = principal.FindUserId(); + + // Assert + result.ShouldBe(userId); + } + + /// + /// 测试目的:FindUserId(ClaimsPrincipal) 在无 UserId 声明时,应返回 null 而不抛异常。 + /// + [Fact] + public void FindUserId_Principal_WhenNoUserIdClaim_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindUserId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindUserId(ClaimsPrincipal) 在声明值不是合法 Guid 时,应返回 null。 + /// + [Fact] + public void FindUserId_Principal_WhenNonGuidValue_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(new Claim(BingClaimTypes.UserId, "not-a-guid")); + + // Act + var result = principal.FindUserId(); + + // Assert + result.ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // FindUserId — IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindUserId(IIdentity) 在存在有效声明时,应返回对应 Guid。 + /// + [Fact] + public void FindUserId_Identity_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var userId = Guid.NewGuid(); + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.UserId, userId.ToString())); + + // Act + var result = identity.FindUserId(); + + // Assert + result.ShouldBe(userId); + } + + /// + /// 测试目的:FindUserId(IIdentity) 在无声明时,应返回 null。 + /// + [Fact] + public void FindUserId_Identity_WhenNoUserIdClaim_ShouldReturnNull() + { + // Arrange + IIdentity identity = BuildIdentity(); + + // Act + var result = identity.FindUserId(); + + // Assert + result.ShouldBeNull(); + } + + // ═══════════════════════════════════════════════════════════ + // FindTenantId — ClaimsPrincipal / IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindTenantId(ClaimsPrincipal) 在存在有效 TenantId 声明时,应返回对应 Guid。 + /// + [Fact] + public void FindTenantId_Principal_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var tenantId = Guid.NewGuid(); + var principal = BuildPrincipal(new Claim(BingClaimTypes.TenantId, tenantId.ToString())); + + // Act + var result = principal.FindTenantId(); + + // Assert + result.ShouldBe(tenantId); + } + + /// + /// 测试目的:FindTenantId(ClaimsPrincipal) 在无声明时,应返回 null。 + /// + [Fact] + public void FindTenantId_Principal_WhenNoTenantIdClaim_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindTenantId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindTenantId(IIdentity) 在存在有效 TenantId 声明时,应返回对应 Guid。 + /// + [Fact] + public void FindTenantId_Identity_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var tenantId = Guid.NewGuid(); + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.TenantId, tenantId.ToString())); + + // Act + var result = identity.FindTenantId(); + + // Assert + result.ShouldBe(tenantId); + } + + // ═══════════════════════════════════════════════════════════ + // FindClientId — ClaimsPrincipal / IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindClientId(ClaimsPrincipal) 在存在 ClientId 声明时,应返回对应字符串。 + /// + [Fact] + public void FindClientId_Principal_WhenClientIdClaimExists_ShouldReturnString() + { + // Arrange + var principal = BuildPrincipal(new Claim(BingClaimTypes.ClientId, "client-001")); + + // Act + var result = principal.FindClientId(); + + // Assert + result.ShouldBe("client-001"); + } + + /// + /// 测试目的:FindClientId(ClaimsPrincipal) 在无声明时,应返回 null。 + /// + [Fact] + public void FindClientId_Principal_WhenNoClientIdClaim_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindClientId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindClientId(IIdentity) 在存在 ClientId 声明时,应返回对应字符串。 + /// + [Fact] + public void FindClientId_Identity_WhenClientIdClaimExists_ShouldReturnString() + { + // Arrange + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.ClientId, "client-002")); + + // Act + var result = identity.FindClientId(); + + // Assert + result.ShouldBe("client-002"); + } + + // ═══════════════════════════════════════════════════════════ + // FindEditionId — ClaimsPrincipal / IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindEditionId(ClaimsPrincipal) 在存在有效 EditionId 声明时,应返回对应 Guid。 + /// + [Fact] + public void FindEditionId_Principal_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var editionId = Guid.NewGuid(); + var principal = BuildPrincipal(new Claim(BingClaimTypes.EditionId, editionId.ToString())); + + // Act + var result = principal.FindEditionId(); + + // Assert + result.ShouldBe(editionId); + } + + /// + /// 测试目的:FindEditionId(ClaimsPrincipal) 在无声明时,应返回 null。 + /// + [Fact] + public void FindEditionId_Principal_WhenNoEditionIdClaim_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindEditionId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindEditionId(IIdentity) 在存在有效 EditionId 声明时,应返回对应 Guid。 + /// + [Fact] + public void FindEditionId_Identity_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var editionId = Guid.NewGuid(); + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.EditionId, editionId.ToString())); + + // Act + var result = identity.FindEditionId(); + + // Assert + result.ShouldBe(editionId); + } + + // ═══════════════════════════════════════════════════════════ + // FindImpersonatorTenantId — ClaimsPrincipal / IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindImpersonatorTenantId(ClaimsPrincipal) 在存在有效声明时,应返回对应 Guid。 + /// + [Fact] + public void FindImpersonatorTenantId_Principal_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var impersonatorTenantId = Guid.NewGuid(); + var principal = BuildPrincipal(new Claim(BingClaimTypes.ImpersonatorTenantId, impersonatorTenantId.ToString())); + + // Act + var result = principal.FindImpersonatorTenantId(); + + // Assert + result.ShouldBe(impersonatorTenantId); + } + + /// + /// 测试目的:FindImpersonatorTenantId(ClaimsPrincipal) 在无声明时,应返回 null。 + /// + [Fact] + public void FindImpersonatorTenantId_Principal_WhenNoClaimExists_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindImpersonatorTenantId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindImpersonatorTenantId(IIdentity) 在存在有效声明时,应返回对应 Guid。 + /// + [Fact] + public void FindImpersonatorTenantId_Identity_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var id = Guid.NewGuid(); + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.ImpersonatorTenantId, id.ToString())); + + // Act + var result = identity.FindImpersonatorTenantId(); + + // Assert + result.ShouldBe(id); + } + + // ═══════════════════════════════════════════════════════════ + // FindImpersonatorUserId — ClaimsPrincipal / IIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindImpersonatorUserId(ClaimsPrincipal) 在存在有效声明时,应返回对应 Guid。 + /// + [Fact] + public void FindImpersonatorUserId_Principal_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var impersonatorUserId = Guid.NewGuid(); + var principal = BuildPrincipal(new Claim(BingClaimTypes.ImpersonatorUserId, impersonatorUserId.ToString())); + + // Act + var result = principal.FindImpersonatorUserId(); + + // Assert + result.ShouldBe(impersonatorUserId); + } + + /// + /// 测试目的:FindImpersonatorUserId(ClaimsPrincipal) 在无声明时,应返回 null。 + /// + [Fact] + public void FindImpersonatorUserId_Principal_WhenNoClaimExists_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindImpersonatorUserId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindImpersonatorUserId(IIdentity) 在存在有效声明时,应返回对应 Guid。 + /// + [Fact] + public void FindImpersonatorUserId_Identity_WhenValidGuidClaim_ShouldReturnGuid() + { + // Arrange + var id = Guid.NewGuid(); + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.ImpersonatorUserId, id.ToString())); + + // Act + var result = identity.FindImpersonatorUserId(); + + // Assert + result.ShouldBe(id); + } + + // ═══════════════════════════════════════════════════════════ + // FindSessionId — ClaimsPrincipal / IIdentity + // 注意:实现上复用 ClientId 声明类型(非 SessionId) + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:FindSessionId(ClaimsPrincipal) 在存在 ClientId 声明(实现层复用)时,应返回对应字符串。 + /// + [Fact] + public void FindSessionId_Principal_WhenClientIdClaimExists_ShouldReturnString() + { + // Arrange + var principal = BuildPrincipal(new Claim(BingClaimTypes.ClientId, "session-abc")); + + // Act + var result = principal.FindSessionId(); + + // Assert + result.ShouldBe("session-abc"); + } + + /// + /// 测试目的:FindSessionId(ClaimsPrincipal) 在无任何相关声明时,应返回 null。 + /// + [Fact] + public void FindSessionId_Principal_WhenNoClientIdClaim_ShouldReturnNull() + { + // Arrange + var principal = BuildPrincipal(); + + // Act + var result = principal.FindSessionId(); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindSessionId(IIdentity) 在存在 ClientId 声明(实现层复用)时,应返回对应字符串。 + /// + [Fact] + public void FindSessionId_Identity_WhenClientIdClaimExists_ShouldReturnString() + { + // Arrange + IIdentity identity = BuildIdentity(new Claim(BingClaimTypes.ClientId, "session-xyz")); + + // Act + var result = identity.FindSessionId(); + + // Assert + result.ShouldBe("session-xyz"); + } + + // ═══════════════════════════════════════════════════════════ + // AddIfNotContains — ClaimsIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddIfNotContains 在声明不存在时,应将该声明添加到标识中。 + /// + [Fact] + public void AddIfNotContains_WhenClaimNotExists_ShouldAddClaim() + { + // Arrange + var identity = BuildIdentity(); + var claim = new Claim("custom_type", "custom_value"); + + // Act + identity.AddIfNotContains(claim); + + // Assert + identity.FindFirst("custom_type").ShouldNotBeNull(); + identity.FindFirst("custom_type")!.Value.ShouldBe("custom_value"); + } + + /// + /// 测试目的:AddIfNotContains 在声明已存在时,不应添加重复声明,且返回值为原标识(链式调用)。 + /// + [Fact] + public void AddIfNotContains_WhenClaimAlreadyExists_ShouldNotDuplicate() + { + // Arrange + var identity = BuildIdentity(new Claim("dup_type", "original")); + var duplicate = new Claim("dup_type", "new_value"); + + // Act + var returned = identity.AddIfNotContains(duplicate); + + // Assert + // 仍然只有一条声明 + identity.FindAll("dup_type").Count().ShouldBe(1); + // 保留原始值 + identity.FindFirst("dup_type")!.Value.ShouldBe("original"); + // 返回自身(链式调用) + returned.ShouldBeSameAs(identity); + } + + // ═══════════════════════════════════════════════════════════ + // AddOrReplace — ClaimsIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddOrReplace 在声明不存在时,应直接添加。 + /// + [Fact] + public void AddOrReplace_WhenClaimNotExists_ShouldAddClaim() + { + // Arrange + var identity = BuildIdentity(); + var claim = new Claim("new_type", "new_value"); + + // Act + identity.AddOrReplace(claim); + + // Assert + identity.FindFirst("new_type").ShouldNotBeNull(); + identity.FindFirst("new_type")!.Value.ShouldBe("new_value"); + } + + /// + /// 测试目的:AddOrReplace 在声明已存在时,应替换原有值,并只保留一条声明。 + /// + [Fact] + public void AddOrReplace_WhenClaimAlreadyExists_ShouldReplaceWithNewValue() + { + // Arrange + var identity = BuildIdentity(new Claim("replace_type", "old_value")); + var replacement = new Claim("replace_type", "replaced_value"); + + // Act + var returned = identity.AddOrReplace(replacement); + + // Assert + identity.FindAll("replace_type").Count().ShouldBe(1); + identity.FindFirst("replace_type")!.Value.ShouldBe("replaced_value"); + returned.ShouldBeSameAs(identity); + } + + /// + /// 测试目的:AddOrReplace 在存在多条同类型声明时,应全部移除后添加新声明,只保留一条。 + /// + [Fact] + public void AddOrReplace_WhenMultipleClaimsOfSameType_ShouldReplaceAll() + { + // Arrange + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim("multi_type", "val1")); + identity.AddClaim(new Claim("multi_type", "val2")); + var replacement = new Claim("multi_type", "single_value"); + + // Act + identity.AddOrReplace(replacement); + + // Assert + identity.FindAll("multi_type").Count().ShouldBe(1); + identity.FindFirst("multi_type")!.Value.ShouldBe("single_value"); + } + + // ═══════════════════════════════════════════════════════════ + // AddIdentityIfNotContains — ClaimsPrincipal + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:AddIdentityIfNotContains 在主体中没有同认证类型的标识时,应添加该标识。 + /// + [Fact] + public void AddIdentityIfNotContains_WhenAuthTypeNotExists_ShouldAddIdentity() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity(Array.Empty(), "ExistingAuth")); + var newIdentity = new ClaimsIdentity(new[] { new Claim("x", "1") }, "NewAuth"); + + // Act + var returned = principal.AddIdentityIfNotContains(newIdentity); + + // Assert + principal.Identities.Count().ShouldBe(2); + principal.Identities.Any(i => i.AuthenticationType == "NewAuth").ShouldBeTrue(); + returned.ShouldBeSameAs(principal); + } + + /// + /// 测试目的:AddIdentityIfNotContains 在主体中已有相同认证类型的标识时,不应重复添加。 + /// + [Fact] + public void AddIdentityIfNotContains_WhenAuthTypeAlreadyExists_ShouldNotDuplicate() + { + // Arrange + var principal = new ClaimsPrincipal(new ClaimsIdentity(Array.Empty(), "SameAuth")); + var duplicate = new ClaimsIdentity(new[] { new Claim("y", "2") }, "SameAuth"); + + // Act + principal.AddIdentityIfNotContains(duplicate); + + // Assert + principal.Identities.Count().ShouldBe(1); + } + + // ═══════════════════════════════════════════════════════════ + // RemoveAll — ClaimsIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:RemoveAll 在存在多条同类型声明时,应全部移除,声明数变为 0。 + /// + [Fact] + public void RemoveAll_WhenMultipleClaimsOfType_ShouldRemoveAll() + { + // Arrange + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim("remove_type", "a")); + identity.AddClaim(new Claim("remove_type", "b")); + identity.AddClaim(new Claim("keep_type", "keep")); + + // Act + var returned = identity.RemoveAll("remove_type"); + + // Assert + identity.FindAll("remove_type").ShouldBeEmpty(); + identity.FindFirst("keep_type").ShouldNotBeNull(); // 不影响其他类型 + returned.ShouldBeSameAs(identity); + } + + /// + /// 测试目的:RemoveAll 在声明不存在时,应无副作用,不抛异常。 + /// + [Fact] + public void RemoveAll_WhenClaimTypeNotExist_ShouldNotThrow() + { + // Arrange + var identity = BuildIdentity(new Claim("other_type", "value")); + + // Act & Assert(不应抛出任何异常) + Should.NotThrow(() => identity.RemoveAll("nonexistent_type")); + identity.FindFirst("other_type").ShouldNotBeNull(); // 原有声明不受影响 + } +} diff --git a/framework/tests/Bing.Security.Tests/Principals/UnauthenticatedTest.cs b/framework/tests/Bing.Security.Tests/Principals/UnauthenticatedTest.cs new file mode 100644 index 00000000..fb992303 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Principals/UnauthenticatedTest.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using Bing.Security.Principals; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Principals; + +/// +/// 单元测试 +/// +public class UnauthenticatedTest +{ + // ═══════════════════════════════════════════════════════════ + // UnauthenticatedIdentity + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:UnauthenticatedIdentity.IsAuthenticated 始终为 false,表示未认证身份。 + /// + [Fact] + public void UnauthenticatedIdentity_IsAuthenticated_ShouldAlwaysBeFalse() + { + // Arrange & Act + var identity = new UnauthenticatedIdentity(); + + // Assert + identity.IsAuthenticated.ShouldBeFalse(); + } + + /// + /// 测试目的:UnauthenticatedIdentity.Instance 是单例,避免重复实例化的开销。 + /// + [Fact] + public void UnauthenticatedIdentity_Instance_ShouldBeSingleton() + { + // Arrange & Act + var a = UnauthenticatedIdentity.Instance; + var b = UnauthenticatedIdentity.Instance; + + // Assert + a.ShouldNotBeNull(); + ReferenceEquals(a, b).ShouldBeTrue(); + } + + /// + /// 测试目的:UnauthenticatedIdentity 应继承自 ClaimsIdentity,兼容 ClaimsPrincipal 体系。 + /// + [Fact] + public void UnauthenticatedIdentity_ShouldInheritFromClaimsIdentity() + { + // Arrange & Act + var identity = UnauthenticatedIdentity.Instance; + + // Assert + identity.ShouldBeAssignableTo(); + } + + /// + /// 测试目的:Instance 的 IsAuthenticated 应为 false,与直接构造一致。 + /// + [Fact] + public void UnauthenticatedIdentity_Instance_IsAuthenticated_ShouldBeFalse() + { + // Arrange & Act & Assert + UnauthenticatedIdentity.Instance.IsAuthenticated.ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // UnauthenticatedPrincipal + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:UnauthenticatedPrincipal.Instance 是单例,避免重复实例化的开销。 + /// + [Fact] + public void UnauthenticatedPrincipal_Instance_ShouldBeSingleton() + { + // Arrange & Act + var a = UnauthenticatedPrincipal.Instance; + var b = UnauthenticatedPrincipal.Instance; + + // Assert + a.ShouldNotBeNull(); + ReferenceEquals(a, b).ShouldBeTrue(); + } + + /// + /// 测试目的:UnauthenticatedPrincipal.Identity 应返回 UnauthenticatedIdentity.Instance,实现对象复用。 + /// + [Fact] + public void UnauthenticatedPrincipal_Identity_ShouldBeUnauthenticatedIdentityInstance() + { + // Arrange & Act + var principal = UnauthenticatedPrincipal.Instance; + + // Assert + ReferenceEquals(principal.Identity, UnauthenticatedIdentity.Instance).ShouldBeTrue(); + } + + /// + /// 测试目的:UnauthenticatedPrincipal.Identity.IsAuthenticated 应为 false(通过主体对象访问)。 + /// + [Fact] + public void UnauthenticatedPrincipal_Identity_IsAuthenticated_ShouldBeFalse() + { + // Arrange & Act + var principal = UnauthenticatedPrincipal.Instance; + + // Assert + principal.Identity.IsAuthenticated.ShouldBeFalse(); + } + + /// + /// 测试目的:UnauthenticatedPrincipal 应继承自 ClaimsPrincipal,与 ASP.NET Core 主体体系兼容。 + /// + [Fact] + public void UnauthenticatedPrincipal_ShouldInheritFromClaimsPrincipal() + { + // Arrange & Act + var principal = UnauthenticatedPrincipal.Instance; + + // Assert + principal.ShouldBeAssignableTo(); + } +} diff --git a/framework/tests/Bing.Security.Tests/SecurityLog/DefaultSecurityLogManagerAndSimpleStoreTest.cs b/framework/tests/Bing.Security.Tests/SecurityLog/DefaultSecurityLogManagerAndSimpleStoreTest.cs new file mode 100644 index 00000000..26f8f33f --- /dev/null +++ b/framework/tests/Bing.Security.Tests/SecurityLog/DefaultSecurityLogManagerAndSimpleStoreTest.cs @@ -0,0 +1,193 @@ +using Bing.SecurityLog; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.SecurityLog; + +/// +/// 单元测试 +/// +public class DefaultSecurityLogManagerAndSimpleStoreTest +{ + // ═══════════════════════════════════════════════════════════ + // DefaultSecurityLogManager — 辅助工厂 + // ═══════════════════════════════════════════════════════════ + + private static DefaultSecurityLogManager CreateManager( + bool isEnabled, + string appName, + Mock mockStore) + { + var options = new BingSecurityLogOptions { IsEnabled = isEnabled, ApplicationName = appName }; + var mockOptions = new Mock>(); + mockOptions.Setup(o => o.Value).Returns(options); + return new DefaultSecurityLogManager(mockOptions.Object, mockStore.Object); + } + + // ═══════════════════════════════════════════════════════════ + // DefaultSecurityLogManager — 测试 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 IsEnabled = false 时,SaveAsync 应立即返回,不调用 SecurityLogStore.SaveAsync。 + /// + [Fact] + public async Task SaveAsync_WhenDisabled_ShouldNotCallStore() + { + // Arrange + var mockStore = new Mock(); + var manager = CreateManager(isEnabled: false, appName: "app", mockStore); + + // Act + await manager.SaveAsync(); + + // Assert + mockStore.Verify(s => s.SaveAsync(It.IsAny()), Times.Never); + } + + /// + /// 测试目的:当 IsEnabled = true 时,SaveAsync 应调用 SecurityLogStore.SaveAsync 恰好一次。 + /// + [Fact] + public async Task SaveAsync_WhenEnabled_ShouldCallStoreSaveOnce() + { + // Arrange + var mockStore = new Mock(); + mockStore.Setup(s => s.SaveAsync(It.IsAny())).Returns(Task.CompletedTask); + var manager = CreateManager(isEnabled: true, appName: "MyApp", mockStore); + + // Act + await manager.SaveAsync(); + + // Assert + mockStore.Verify(s => s.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// 测试目的:当 IsEnabled = true 且传入 saveAction 时,saveAction 应被调用, + /// 且传入的 SecurityLogInfo.ApplicationName 应与选项一致。 + /// + [Fact] + public async Task SaveAsync_WhenEnabledWithAction_ShouldInvokeActionAndSetApplicationName() + { + // Arrange + var mockStore = new Mock(); + SecurityLogInfo capturedInfo = null; + mockStore + .Setup(s => s.SaveAsync(It.IsAny())) + .Callback(info => capturedInfo = info) + .Returns(Task.CompletedTask); + var manager = CreateManager(isEnabled: true, appName: "TestApp", mockStore); + + // Act + await manager.SaveAsync(info => info.Action = "Login"); + + // Assert + capturedInfo.ShouldNotBeNull(); + capturedInfo.ApplicationName.ShouldBe("TestApp"); + capturedInfo.Action.ShouldBe("Login"); + } + + /// + /// 测试目的:当 IsEnabled = true 但不传 saveAction 时,应正常调用 Store,不会抛出 NRE。 + /// + [Fact] + public async Task SaveAsync_WhenEnabledWithNullAction_ShouldNotThrow() + { + // Arrange + var mockStore = new Mock(); + mockStore.Setup(s => s.SaveAsync(It.IsAny())).Returns(Task.CompletedTask); + var manager = CreateManager(isEnabled: true, appName: "app", mockStore); + + // Act & Assert — 不抛异常 + await Should.NotThrowAsync(async () => await manager.SaveAsync(null)); + mockStore.Verify(s => s.SaveAsync(It.IsAny()), Times.Once); + } + + // ═══════════════════════════════════════════════════════════ + // SimpleSecurityLogStore — 辅助工厂 + // ═══════════════════════════════════════════════════════════ + + private static SimpleSecurityLogStore CreateStore( + bool isEnabled, + Mock> mockLogger) + { + var options = new BingSecurityLogOptions { IsEnabled = isEnabled }; + var mockOptions = new Mock>(); + mockOptions.Setup(o => o.Value).Returns(options); + return new SimpleSecurityLogStore(mockLogger.Object, mockOptions.Object); + } + + // ═══════════════════════════════════════════════════════════ + // SimpleSecurityLogStore — 测试 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:当 IsEnabled = false 时,SaveAsync 应立即返回,不调用 Logger 任何方法。 + /// + [Fact] + public async Task SimpleStore_SaveAsync_WhenDisabled_ShouldNotLog() + { + // Arrange + var mockLogger = new Mock>(); + var store = CreateStore(isEnabled: false, mockLogger); + + // Act + await store.SaveAsync(new SecurityLogInfo { ApplicationName = "app" }); + + // Assert — Log 未被调用 + mockLogger.Verify( + l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + /// + /// 测试目的:当 IsEnabled = true 时,SaveAsync 应调用 Logger.LogInformation 一次。 + /// + [Fact] + public async Task SimpleStore_SaveAsync_WhenEnabled_ShouldCallLogInformation() + { + // Arrange + var mockLogger = new Mock>(); + var store = CreateStore(isEnabled: true, mockLogger); + + // Act + await store.SaveAsync(new SecurityLogInfo { ApplicationName = "app", Action = "Login" }); + + // Assert + mockLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// 测试目的:SecurityLogOptions.IsEnabled 应在 Store 中正确反映构造时的配置值。 + /// + [Fact] + public void SimpleStore_SecurityLogOptions_ShouldReflectConstructedIsEnabled() + { + // Arrange + var mockLogger = new Mock>(); + + // Act + var storeEnabled = CreateStore(isEnabled: true, mockLogger); + var storeDisabled = CreateStore(isEnabled: false, mockLogger); + + // Assert + storeEnabled.SecurityLogOptions.IsEnabled.ShouldBeTrue(); + storeDisabled.SecurityLogOptions.IsEnabled.ShouldBeFalse(); + } +} diff --git a/framework/tests/Bing.Security.Tests/SecurityLog/SecurityLogInfoAndOptionsTest.cs b/framework/tests/Bing.Security.Tests/SecurityLog/SecurityLogInfoAndOptionsTest.cs new file mode 100644 index 00000000..c6cfa0f2 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/SecurityLog/SecurityLogInfoAndOptionsTest.cs @@ -0,0 +1,231 @@ +using Bing.SecurityLog; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.SecurityLog; + +/// +/// 单元测试 +/// +public class SecurityLogInfoAndOptionsTest +{ + // ═══════════════════════════════════════════════════════════ + // SecurityLogInfo + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 ExtraProperties 不为 null,防止使用时空引用异常。 + /// + [Fact] + public void SecurityLogInfo_Default_ExtraPropertiesShouldNotBeNull() + { + // Arrange & Act + var info = new SecurityLogInfo(); + + // Assert + info.ExtraProperties.ShouldNotBeNull(); + info.ExtraProperties.Count.ShouldBe(0); + } + + /// + /// 测试目的:ApplicationName 属性可读写,用于标识产生日志的服务。 + /// + [Fact] + public void SecurityLogInfo_ApplicationName_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.ApplicationName = "MyApp"; + + // Assert + info.ApplicationName.ShouldBe("MyApp"); + } + + /// + /// 测试目的:Identity 属性可读写,记录当前安全操作的身份标识。 + /// + [Fact] + public void SecurityLogInfo_Identity_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.Identity = "Login"; + + // Assert + info.Identity.ShouldBe("Login"); + } + + /// + /// 测试目的:Action 属性可读写,记录触发安全日志的具体操作。 + /// + [Fact] + public void SecurityLogInfo_Action_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.Action = "UserLogin"; + + // Assert + info.Action.ShouldBe("UserLogin"); + } + + /// + /// 测试目的:UserId 与 UserName 属性可读写,用于记录操作者信息。 + /// + [Fact] + public void SecurityLogInfo_UserFields_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.UserId = "uid-001"; + info.UserName = "zhangsan"; + + // Assert + info.UserId.ShouldBe("uid-001"); + info.UserName.ShouldBe("zhangsan"); + } + + /// + /// 测试目的:TenantId 与 TenantName 属性可读写,用于多租户场景下标识租户信息。 + /// + [Fact] + public void SecurityLogInfo_TenantFields_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.TenantId = "tenant-001"; + info.TenantName = "示例租户"; + + // Assert + info.TenantId.ShouldBe("tenant-001"); + info.TenantName.ShouldBe("示例租户"); + } + + /// + /// 测试目的:ClientId、ClientIpAddress 及 BrowserInfo 属性可读写,用于客户端追踪。 + /// + [Fact] + public void SecurityLogInfo_ClientFields_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.ClientId = "web-client"; + info.ClientIpAddress = "192.168.1.100"; + info.BrowserInfo = "Chrome/120"; + + // Assert + info.ClientId.ShouldBe("web-client"); + info.ClientIpAddress.ShouldBe("192.168.1.100"); + info.BrowserInfo.ShouldBe("Chrome/120"); + } + + /// + /// 测试目的:CorrelationId 属性可读写,用于日志链路追踪。 + /// + [Fact] + public void SecurityLogInfo_CorrelationId_ShouldBeReadWritable() + { + // Arrange + var info = new SecurityLogInfo(); + var traceId = Guid.NewGuid().ToString(); + + // Act + info.CorrelationId = traceId; + + // Assert + info.CorrelationId.ShouldBe(traceId); + } + + /// + /// 测试目的:ExtraProperties 字典支持添加自定义键值对,用于扩展字段存储。 + /// + [Fact] + public void SecurityLogInfo_ExtraProperties_CanAddEntries() + { + // Arrange + var info = new SecurityLogInfo(); + + // Act + info.ExtraProperties["device"] = "mobile"; + info.ExtraProperties["os"] = "iOS"; + + // Assert + info.ExtraProperties["device"].ShouldBe("mobile"); + info.ExtraProperties["os"].ShouldBe("iOS"); + info.ExtraProperties.Count.ShouldBe(2); + } + + // ═══════════════════════════════════════════════════════════ + // BingSecurityLogOptions + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后 IsEnabled 应为 true,安全日志默认开启。 + /// + [Fact] + public void BingSecurityLogOptions_Default_IsEnabledShouldBeTrue() + { + // Arrange & Act + var options = new BingSecurityLogOptions(); + + // Assert + options.IsEnabled.ShouldBeTrue(); + } + + /// + /// 测试目的:IsEnabled 可设置为 false,支持在特定环境关闭安全日志。 + /// + [Fact] + public void BingSecurityLogOptions_IsEnabled_CanBeDisabled() + { + // Arrange + var options = new BingSecurityLogOptions(); + + // Act + options.IsEnabled = false; + + // Assert + options.IsEnabled.ShouldBeFalse(); + } + + /// + /// 测试目的:ApplicationName 属性可读写,用于标识当前应用在安全日志中的名称。 + /// + [Fact] + public void BingSecurityLogOptions_ApplicationName_ShouldBeReadWritable() + { + // Arrange + var options = new BingSecurityLogOptions(); + + // Act + options.ApplicationName = "OrderService"; + + // Assert + options.ApplicationName.ShouldBe("OrderService"); + } + + /// + /// 测试目的:默认构造后 ApplicationName 为 null,允许调用方按需配置。 + /// + [Fact] + public void BingSecurityLogOptions_Default_ApplicationNameShouldBeNull() + { + // Arrange & Act + var options = new BingSecurityLogOptions(); + + // Assert + options.ApplicationName.ShouldBeNull(); + } +} diff --git a/framework/tests/Bing.Security.Tests/Users/CurrentUserExtensionsTest.cs b/framework/tests/Bing.Security.Tests/Users/CurrentUserExtensionsTest.cs new file mode 100644 index 00000000..40d6e0a4 --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Users/CurrentUserExtensionsTest.cs @@ -0,0 +1,310 @@ +using System.Security.Claims; +using Bing.Security.Claims; +using Bing.Test.Shared.Identity; +using Bing.Users; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Users; + +/// +/// 单元测试 +/// 所有测试使用 替代真实 HttpContext/Principal, +/// 确保测试无外部依赖、快速、可重复。 +/// +public class CurrentUserExtensionsTest +{ + // ── GetUserId ──────────────────────────────────────────────── + + /// + /// 测试目的:GetUserId() 应将 UserId 字符串解析为 Guid。 + /// + [Fact] + public void GetUserId_WithValidGuidString_ShouldReturnGuid() + { + // Arrange + var guid = Guid.NewGuid(); + var user = FakeCurrentUser.AsAuthenticated(userId: guid.ToString()); + + // Act + var result = user.GetUserId(); + + // Assert + result.ShouldBe(guid); + } + + /// + /// 测试目的:GetUserId{T}() 应将 UserId 转换为 int。 + /// + [Fact] + public void GetUserId_WithIntType_ShouldReturnInt() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(userId: "99"); + + // Act + var result = user.GetUserId(); + + // Assert + result.ShouldBe(99); + } + + // ── GetUserName (Claim-based) ───────────────────────────────── + + /// + /// 测试目的:GetUserName() 应从 BingClaimTypes.UserName Claim 中读取用户名。 + /// + [Fact] + public void GetUserName_WithUserNameClaim_ShouldReturnClaimValue() + { + // Arrange + var user = new FakeCurrentUser() + .WithClaim(BingClaimTypes.UserName, "testuser"); + + // Act + var result = user.GetUserName(); + + // Assert + result.ShouldBe("testuser"); + } + + /// + /// 测试目的:GetUserName() 在 BingClaimTypes.UserName 缺失时应回退到 "name" Claim。 + /// + [Fact] + public void GetUserName_WhenUserNameClaimMissing_ShouldFallbackToNameClaim() + { + // Arrange + var user = new FakeCurrentUser().WithClaim("name", "fallback-user"); + + // Act + var result = user.GetUserName(); + + // Assert + result.ShouldBe("fallback-user"); + } + + /// + /// 测试目的:GetUserName() 在无任何相关 Claim 时应返回 null,不抛异常。 + /// + [Fact] + public void GetUserName_WhenNoClaim_ShouldReturnNull() + { + // Arrange + var user = FakeCurrentUser.AsAnonymous(); + + // Act + var result = user.GetUserName(); + + // Assert + result.ShouldBeNullOrWhiteSpace(); + } + + // ── GetFullName ─────────────────────────────────────────────── + + /// + /// 测试目的:GetFullName() 应从 BingClaimTypes.FullName Claim 中读取全名。 + /// + [Fact] + public void GetFullName_WithFullNameClaim_ShouldReturnClaimValue() + { + // Arrange + var user = new FakeCurrentUser().WithClaim(BingClaimTypes.FullName, "张 三"); + + // Act + var result = user.GetFullName(); + + // Assert + result.ShouldBe("张 三"); + } + + /// + /// 测试目的:GetFullName() 在缺失主 Claim 时应回退到 "family_name" Claim。 + /// + [Fact] + public void GetFullName_WhenFullNameClaimMissing_ShouldFallbackToFamilyName() + { + // Arrange + var user = new FakeCurrentUser().WithClaim("family_name", "王"); + + // Act + var result = user.GetFullName(); + + // Assert + result.ShouldBe("王"); + } + + // ── FindClaimValue ──────────────────────────────────────────── + + /// + /// 测试目的:FindClaimValue() 对存在的 Claim 应返回其 Value。 + /// + [Fact] + public void FindClaimValue_WhenClaimExists_ShouldReturnValue() + { + // Arrange + var user = new FakeCurrentUser().WithClaim("custom_type", "custom_value"); + + // Act + var result = user.FindClaimValue("custom_type"); + + // Assert + result.ShouldBe("custom_value"); + } + + /// + /// 测试目的:FindClaimValue() 对不存在的 Claim 应返回 null,不抛异常。 + /// + [Fact] + public void FindClaimValue_WhenClaimNotExist_ShouldReturnNull() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + + // Act + var result = user.FindClaimValue("nonexistent_type"); + + // Assert + result.ShouldBeNull(); + } + + /// + /// 测试目的:FindClaimValue{T}() 对存在的 Claim 应将值转换为指定类型。 + /// + [Fact] + public void FindClaimValueGeneric_WhenClaimExists_ShouldConvertToType() + { + // Arrange + var user = new FakeCurrentUser().WithClaim("age", "30"); + + // Act + var result = user.FindClaimValue("age"); + + // Assert + result.ShouldBe(30); + } + + // ── GetTenantId ─────────────────────────────────────────────── + + /// + /// 测试目的:GetTenantId() 应从 BingClaimTypes.TenantId Claim 解析 Guid。 + /// + [Fact] + public void GetTenantId_WithTenantIdClaim_ShouldReturnGuid() + { + // Arrange + var tenantId = Guid.NewGuid(); + var user = new FakeCurrentUser() + .WithClaim(BingClaimTypes.TenantId, tenantId.ToString()); + + // Act + var result = user.GetTenantId(); + + // Assert + result.ShouldBe(tenantId); + } + + /// + /// 测试目的:GetTenantId() 在无租户 Claim 时应返回 Guid.Empty,不抛异常。 + /// + [Fact] + public void GetTenantId_WhenNoTenantClaim_ShouldReturnGuidEmpty() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + + // Act + var result = user.GetTenantId(); + + // Assert + result.ShouldBe(Guid.Empty); + } + + // ── IsInRole ────────────────────────────────────────────────── + + /// + /// 测试目的:IsInRole() 对已分配的角色应返回 true(不区分大小写)。 + /// + [Fact] + public void IsInRole_WithMatchingRole_ShouldReturnTrue() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(roles: new[] { "Admin", "Editor" }); + + // Act & Assert + user.IsInRole("admin").ShouldBeTrue(); + user.IsInRole("EDITOR").ShouldBeTrue(); + } + + /// + /// 测试目的:IsInRole() 对未分配的角色应返回 false。 + /// + [Fact] + public void IsInRole_WithUnassignedRole_ShouldReturnFalse() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(roles: new[] { "User" }); + + // Act & Assert + user.IsInRole("Admin").ShouldBeFalse(); + } + + // ── FindImpersonatorTenantId ─────────────────────────────────── + + /// + /// 测试目的:FindImpersonatorTenantId() 在有模拟租户 Claim 时应正确解析 Guid。 + /// + [Fact] + public void FindImpersonatorTenantId_WithValidClaim_ShouldReturnGuid() + { + // Arrange + var impersonatorId = Guid.NewGuid(); + var user = new FakeCurrentUser() + .WithClaim(BingClaimTypes.ImpersonatorTenantId, impersonatorId.ToString()); + + // Act + var result = user.FindImpersonatorTenantId(); + + // Assert + result.ShouldBe(impersonatorId); + } + + /// + /// 测试目的:FindImpersonatorTenantId() 在无相关 Claim 时应返回 null,不抛异常。 + /// + [Fact] + public void FindImpersonatorTenantId_WhenNoClaim_ShouldReturnNull() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + + // Act + var result = user.FindImpersonatorTenantId(); + + // Assert + result.ShouldBeNull(); + } + + // ── GetRoleIds ──────────────────────────────────────────────── + + /// + /// 测试目的:GetRoleIds() 应从 role_ids Claim 解析出 Guid 列表。 + /// + [Fact] + public void GetRoleIds_WithRoleIdsClaim_ShouldReturnGuidList() + { + // Arrange + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + // BingClaimTypes.RoleIds 存储为逗号分隔的 Guid 字符串 + var user = new FakeCurrentUser() + .WithClaim(BingClaimTypes.RoleIds, $"{id1},{id2}"); + + // Act + var result = user.GetRoleIds(); + + // Assert + result.ShouldContain(id1); + result.ShouldContain(id2); + } +} diff --git a/framework/tests/Bing.Security.Tests/Users/FakeCurrentUserTest.cs b/framework/tests/Bing.Security.Tests/Users/FakeCurrentUserTest.cs new file mode 100644 index 00000000..c8314d2b --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Users/FakeCurrentUserTest.cs @@ -0,0 +1,191 @@ +using System.Security.Claims; +using Bing.Test.Shared.Identity; +using Bing.Users; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Users; + +/// +/// 自身行为测试—— +/// 确认测试基建(test double)的契约符合 预期。 +/// +public class FakeCurrentUserTest +{ + // ── AsAnonymous ─────────────────────────────────────────────── + + /// + /// 测试目的:AsAnonymous() 创建的用户应处于未认证状态,所有标识字段为默认空值。 + /// + [Fact] + public void AsAnonymous_ShouldBeUnauthenticated() + { + // Arrange & Act + var user = FakeCurrentUser.AsAnonymous(); + + // Assert + user.IsAuthenticated.ShouldBeFalse(); + user.UserId.ShouldBe(string.Empty); + user.UserName.ShouldBe(string.Empty); + user.TenantId.ShouldBe(string.Empty); + user.Roles.ShouldBeEmpty(); + } + + // ── AsAuthenticated ─────────────────────────────────────────── + + /// + /// 测试目的:AsAuthenticated() 应正确填充 UserId / UserName / TenantId / Roles。 + /// + [Fact] + public void AsAuthenticated_WithAllParams_ShouldFillAllProperties() + { + // Arrange & Act + var user = FakeCurrentUser.AsAuthenticated( + userId: "u-001", + userName: "alice", + tenantId: "tenant-abc", + roles: new[] { "Admin", "Editor" }); + + // Assert + user.IsAuthenticated.ShouldBeTrue(); + user.UserId.ShouldBe("u-001"); + user.UserName.ShouldBe("alice"); + user.TenantId.ShouldBe("tenant-abc"); + user.Roles.ShouldContain("Admin"); + user.Roles.ShouldContain("Editor"); + } + + /// + /// 测试目的:AsAuthenticated() 在不传 tenantId 时,TenantId 应为空字符串(非 null)。 + /// + [Fact] + public void AsAuthenticated_WithoutTenantId_ShouldHaveEmptyTenantId() + { + // Arrange & Act + var user = FakeCurrentUser.AsAuthenticated(); + + // Assert + user.TenantId.ShouldBe(string.Empty); + } + + // ── WithClaim (链式调用) ─────────────────────────────────────── + + /// + /// 测试目的:WithClaim() 链式添加多个声明后,FindClaim / FindClaims 均应可查询。 + /// + [Fact] + public void WithClaim_Chained_ShouldAddMultipleClaims() + { + // Arrange & Act + var user = FakeCurrentUser.AsAuthenticated() + .WithClaim("custom_a", "value_a") + .WithClaim("custom_b", "value_b"); + + // Assert + user.FindClaim("custom_a")?.Value.ShouldBe("value_a"); + user.FindClaim("custom_b")?.Value.ShouldBe("value_b"); + } + + /// + /// 测试目的:同一 Claim 类型添加多次时,FindClaims() 应返回所有值,FindClaim() 返回第一个。 + /// + [Fact] + public void WithClaim_DuplicateType_ShouldReturnAllViaFindClaims() + { + // Arrange & Act + var user = FakeCurrentUser.AsAuthenticated() + .WithClaim("role", "Admin") + .WithClaim("role", "Editor"); + + // Assert + var all = user.FindClaims("role"); + all.Length.ShouldBe(2); + + var first = user.FindClaim("role"); + first?.Value.ShouldBe("Admin"); + } + + // ── GetAllClaims ────────────────────────────────────────────── + + /// + /// 测试目的:GetAllClaims() 应返回所有通过 WithClaim 添加的声明。 + /// + [Fact] + public void GetAllClaims_ShouldReturnAllAddedClaims() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated() + .WithClaim("c1", "v1") + .WithClaim("c2", "v2") + .WithClaim("c3", "v3"); + + // Act + var all = user.GetAllClaims(); + + // Assert + all.Length.ShouldBe(3); + } + + // ── IsInRole ────────────────────────────────────────────────── + + /// + /// 测试目的:IsInRole() 应不区分大小写地匹配角色名。 + /// + [Fact] + public void IsInRole_ShouldBeCaseInsensitive() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(roles: new[] { "SuperAdmin" }); + + // Assert + user.IsInRole("superadmin").ShouldBeTrue(); + user.IsInRole("SUPERADMIN").ShouldBeTrue(); + user.IsInRole("SuperAdmin").ShouldBeTrue(); + } + + /// + /// 测试目的:Roles 为 null 时,IsInRole() 应返回 false 而不抛 NullReferenceException。 + /// + [Fact] + public void IsInRole_WhenRolesIsNull_ShouldReturnFalse() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + user.Roles = null; + + // Act & Assert + Should.NotThrow(() => user.IsInRole("Admin").ShouldBeFalse()); + } + + // ── 边界 ────────────────────────────────────────────────────── + + /// + /// 测试目的:FindClaim() 对不存在的 Claim 应返回 null,不抛异常。 + /// + [Fact] + public void FindClaim_WhenClaimNotExist_ShouldReturnNull() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + + // Assert + user.FindClaim("nonexistent").ShouldBeNull(); + } + + /// + /// 测试目的:FindClaims() 对不存在的 Claim 类型应返回空数组,不为 null。 + /// + [Fact] + public void FindClaims_WhenClaimNotExist_ShouldReturnEmptyArray() + { + // Arrange + var user = FakeCurrentUser.AsAuthenticated(); + + // Act + var result = user.FindClaims("nonexistent"); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } +} diff --git a/framework/tests/Bing.Security.Tests/Users/NullCurrentUserTest.cs b/framework/tests/Bing.Security.Tests/Users/NullCurrentUserTest.cs new file mode 100644 index 00000000..d035fcec --- /dev/null +++ b/framework/tests/Bing.Security.Tests/Users/NullCurrentUserTest.cs @@ -0,0 +1,111 @@ +using Bing.Users; +using Shouldly; +using Xunit; + +namespace Bing.Security.Tests.Users; + +/// +/// 单元测试 +/// +public class NullCurrentUserTest +{ + private readonly ICurrentUser _user = NullCurrentUser.Instance; + + /// + /// 测试目的:NullCurrentUser 应始终报告未认证状态。 + /// + [Fact] + public void IsAuthenticated_ShouldBeFalse() + { + // Assert + _user.IsAuthenticated.ShouldBeFalse(); + } + + /// + /// 测试目的:NullCurrentUser 的 UserId 应返回空字符串,而不是 null。 + /// + [Fact] + public void UserId_ShouldReturnEmptyString() + { + _user.UserId.ShouldBe(string.Empty); + } + + /// + /// 测试目的:NullCurrentUser 的 UserName 应返回空字符串。 + /// + [Fact] + public void UserName_ShouldReturnEmptyString() + { + _user.UserName.ShouldBe(string.Empty); + } + + /// + /// 测试目的:NullCurrentUser 的 TenantId 应返回空字符串,不为 null(避免调用方 NullReferenceException)。 + /// + [Fact] + public void TenantId_ShouldReturnEmptyString() + { + _user.TenantId.ShouldBe(string.Empty); + } + + /// + /// 测试目的:NullCurrentUser 的 Roles 应返回空数组,不为 null。 + /// + [Fact] + public void Roles_ShouldReturnEmptyArray_NotNull() + { + _user.Roles.ShouldNotBeNull(); + _user.Roles.ShouldBeEmpty(); + } + + /// + /// 测试目的:FindClaim 对任意类型应返回 null,不抛异常。 + /// + [Fact] + public void FindClaim_WithAnyType_ShouldReturnNull() + { + _user.FindClaim("any_claim_type").ShouldBeNull(); + } + + /// + /// 测试目的:FindClaims 对任意类型应返回空数组,不为 null。 + /// + [Fact] + public void FindClaims_WithAnyType_ShouldReturnEmptyArray() + { + var claims = _user.FindClaims("any_claim_type"); + claims.ShouldNotBeNull(); + claims.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetAllClaims 应返回空数组,不为 null。 + /// + [Fact] + public void GetAllClaims_ShouldReturnEmptyArray() + { + var claims = _user.GetAllClaims(); + claims.ShouldNotBeNull(); + claims.ShouldBeEmpty(); + } + + /// + /// 测试目的:IsInRole 对任何角色名都应返回 false。 + /// + [Fact] + public void IsInRole_WithAnyRole_ShouldReturnFalse() + { + _user.IsInRole("admin").ShouldBeFalse(); + _user.IsInRole("user").ShouldBeFalse(); + _user.IsInRole(string.Empty).ShouldBeFalse(); + } + + /// + /// 测试目的:NullCurrentUser.Instance 应为单例,多次访问返回同一实例。 + /// + [Fact] + public void Instance_ShouldBeSingleton() + { + ReferenceEquals(NullCurrentUser.Instance, NullCurrentUser.Instance).ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Test.Shared/Bing.Test.Shared.csproj b/framework/tests/Bing.Test.Shared/Bing.Test.Shared.csproj index ee8f3243..f98696b2 100644 --- a/framework/tests/Bing.Test.Shared/Bing.Test.Shared.csproj +++ b/framework/tests/Bing.Test.Shared/Bing.Test.Shared.csproj @@ -20,4 +20,11 @@ + + + + + + + diff --git a/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Identity/FakeCurrentUser.cs b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Identity/FakeCurrentUser.cs new file mode 100644 index 00000000..cfb92207 --- /dev/null +++ b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Identity/FakeCurrentUser.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using Bing.Users; + +namespace Bing.Test.Shared.Identity; + +/// +/// 测试专用伪用户,可精确控制当前用户上下文,消除对真实 HttpContext / Claims 的依赖。 +/// 用法示例: +/// +/// var user = FakeCurrentUser.AsAuthenticated("user-001", "张三"); +/// Assert.True(user.IsAuthenticated); +/// Assert.Equal("user-001", user.UserId); +/// +/// +public class FakeCurrentUser : ICurrentUser +{ + private static readonly Claim[] EmptyClaimsArray = Array.Empty(); + private static readonly string[] EmptyRolesArray = Array.Empty(); + + private readonly List _claims; + + /// + public bool IsAuthenticated { get; set; } + + /// + public string UserId { get; set; } = string.Empty; + + /// + public string UserName { get; set; } = string.Empty; + + /// + public string PhoneNumber { get; set; } = string.Empty; + + /// + public bool PhoneNumberVerified { get; set; } + + /// + public string Email { get; set; } = string.Empty; + + /// + public bool EmailVerified { get; set; } + + /// + public string TenantId { get; set; } = string.Empty; + + /// + public string[] Roles { get; set; } = EmptyRolesArray; + + /// + /// 初始化一个未认证的空用户实例 + /// + public FakeCurrentUser() + { + _claims = new List(); + IsAuthenticated = false; + } + + /// + /// 初始化一个带有指定属性的用户实例 + /// + private FakeCurrentUser(string userId, string userName, string tenantId, bool isAuthenticated, string[] roles, IEnumerable extraClaims) + { + UserId = userId ?? string.Empty; + UserName = userName ?? string.Empty; + TenantId = tenantId ?? string.Empty; + IsAuthenticated = isAuthenticated; + Roles = roles ?? EmptyRolesArray; + _claims = new List(extraClaims ?? Enumerable.Empty()); + } + + /// + /// 创建一个已认证的用户实例(最常用的工厂方法) + /// + /// 用户标识 + /// 用户名 + /// 租户标识(可选) + /// 角色列表(可选) + public static FakeCurrentUser AsAuthenticated(string userId = "test-user-id", string userName = "test-user", + string tenantId = null, string[] roles = null) + { + return new FakeCurrentUser(userId, userName, tenantId, true, roles, null); + } + + /// + /// 创建一个未认证的匿名用户实例 + /// + public static FakeCurrentUser AsAnonymous() => new FakeCurrentUser(); + + /// + /// 添加额外声明(支持链式调用) + /// + /// 声明类型 + /// 声明值 + public FakeCurrentUser WithClaim(string type, string value) + { + _claims.Add(new Claim(type, value)); + return this; + } + + /// + public Claim FindClaim(string claimType) => + _claims.FirstOrDefault(c => c.Type == claimType); + + /// + public Claim[] FindClaims(string claimType) => + _claims.Where(c => c.Type == claimType).ToArray(); + + /// + public Claim[] GetAllClaims() => _claims.ToArray(); + + /// + public bool IsInRole(string roleName) => + Roles != null && Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase); +} diff --git a/framework/tests/Bing.Test.Shared/Bing/Test/Shared/IntegrationFactAttribute.cs b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/IntegrationFactAttribute.cs new file mode 100644 index 00000000..e6a1a6df --- /dev/null +++ b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/IntegrationFactAttribute.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace Bing.Test.Shared; + +/// +/// 集成测试专用 Fact 特性。 +/// 默认跳过(不执行),必须将环境变量 RUN_INTEGRATION_TESTS=true 时才运行。 +/// 用法: +/// +/// [IntegrationFact] +/// public async Task MyIntegrationTest() { ... } +/// +/// 在 CI 中启用: +/// +/// RUN_INTEGRATION_TESTS=true dotnet test +/// +/// +public sealed class IntegrationFactAttribute : FactAttribute +{ + private const string EnvVar = "RUN_INTEGRATION_TESTS"; + + /// + /// 初始化集成测试特性,若环境变量未设置则自动跳过 + /// + public IntegrationFactAttribute() + { + var runIntegration = Environment.GetEnvironmentVariable(EnvVar); + if (!string.Equals(runIntegration, "true", StringComparison.OrdinalIgnoreCase)) + Skip = $"集成测试已跳过。设置环境变量 {EnvVar}=true 以启用。"; + } +} + +/// +/// 集成测试专用 Theory 特性。 +/// 与 相同的跳过策略。 +/// +public sealed class IntegrationTheoryAttribute : TheoryAttribute +{ + private const string EnvVar = "RUN_INTEGRATION_TESTS"; + + /// + /// 初始化集成测试特性,若环境变量未设置则自动跳过 + /// + public IntegrationTheoryAttribute() + { + var runIntegration = Environment.GetEnvironmentVariable(EnvVar); + if (!string.Equals(runIntegration, "true", StringComparison.OrdinalIgnoreCase)) + Skip = $"集成测试已跳过。设置环境变量 {EnvVar}=true 以启用。"; + } +} diff --git a/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Timing/FakeClock.cs b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Timing/FakeClock.cs new file mode 100644 index 00000000..e6fbb378 --- /dev/null +++ b/framework/tests/Bing.Test.Shared/Bing/Test/Shared/Timing/FakeClock.cs @@ -0,0 +1,70 @@ +using Bing.Timing; + +namespace Bing.Test.Shared.Timing; + +/// +/// 测试专用伪时钟,提供确定性的时间控制。 +/// 通过固定时间或手动推进时间,消除测试中的非确定性。 +/// 用法示例: +/// +/// var clock = new FakeClock(new DateTime(2025, 1, 1, 0, 0, 0)); +/// // 验证固定时间点 +/// Assert.Equal(new DateTime(2025, 1, 1), clock.Now); +/// // 推进时间 +/// clock.Advance(TimeSpan.FromHours(1)); +/// Assert.Equal(new DateTime(2025, 1, 1, 1, 0, 0), clock.Now); +/// +/// +public class FakeClock : IClock +{ + private DateTime _currentTime; + + /// + /// 使用指定的固定本地时间初始化 + /// + /// 固定时间(本地时间) + public FakeClock(DateTime fixedTime) + { + _currentTime = fixedTime; + } + + /// + /// 使用默认时间(2000-01-01 00:00:00)初始化,便于无参测试场景 + /// + public FakeClock() : this(new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Local)) + { + } + + /// + /// 获取当前本地时间(固定值) + /// + public DateTime Now => _currentTime; + + /// + /// 获取当前 UTC 时间(固定值) + /// + public DateTime UtcNow => _currentTime.ToUniversalTime(); + + /// + /// 获取当前时间(带时区信息) + /// + public DateTimeOffset NowOffset => new DateTimeOffset(_currentTime); + + /// + /// 将当前时间向前推进指定时长 + /// + /// 要推进的时长(必须为正值) + /// timeSpan 为负值时抛出 + public void Advance(TimeSpan timeSpan) + { + if (timeSpan < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeSpan), "推进时长不能为负值。"); + _currentTime = _currentTime.Add(timeSpan); + } + + /// + /// 将当前时间直接设置为指定时间 + /// + /// 新的时间点 + public void Set(DateTime newTime) => _currentTime = newTime; +} diff --git a/framework/tests/Bing.TextTemplating.Tests/Bing.TextTemplating.Tests.csproj b/framework/tests/Bing.TextTemplating.Tests/Bing.TextTemplating.Tests.csproj new file mode 100644 index 00000000..c8ef63a9 --- /dev/null +++ b/framework/tests/Bing.TextTemplating.Tests/Bing.TextTemplating.Tests.csproj @@ -0,0 +1,12 @@ + + + + + false + + + + + + + diff --git a/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateDefinitionTest.cs b/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateDefinitionTest.cs new file mode 100644 index 00000000..6425f69d --- /dev/null +++ b/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateDefinitionTest.cs @@ -0,0 +1,227 @@ +using Shouldly; +using Xunit; + +namespace Bing.TextTemplating; + +/// +/// 测试目的:验证 TemplateDefinition 的构造行为、属性读写及流式 API 正确性。 +/// +public class TemplateDefinitionTest +{ + // ===================================================================== + // Constructor & readonly properties + // ===================================================================== + + /// + /// 测试目的:构造时 Name 被正确赋值,IsLayout 默认为 false。 + /// + [Fact] + public void Constructor_WithNameOnly_ShouldSetNameAndDefaultIsLayout() + { + // Arrange & Act + var def = new TemplateDefinition("invoice"); + + // Assert + def.Name.ShouldBe("invoice"); + def.IsLayout.ShouldBeFalse(); + def.Layout.ShouldBeNull(); + def.RenderEngine.ShouldBeNull(); + def.Properties.ShouldNotBeNull(); + def.Properties.ShouldBeEmpty(); + } + + /// + /// 测试目的:构造时指定 isLayout=true,IsLayout 应为 true。 + /// + [Fact] + public void Constructor_WithIsLayoutTrue_ShouldSetIsLayoutTrue() + { + // Arrange & Act + var def = new TemplateDefinition("base-layout", isLayout: true); + + // Assert + def.IsLayout.ShouldBeTrue(); + } + + /// + /// 测试目的:构造时指定 layout 名称,Layout 属性应正确读取。 + /// + [Fact] + public void Constructor_WithLayout_ShouldSetLayout() + { + // Arrange & Act + var def = new TemplateDefinition("report", layout: "base-layout"); + + // Assert + def.Layout.ShouldBe("base-layout"); + } + + // ===================================================================== + // Mutable properties + // ===================================================================== + + /// + /// 测试目的:Layout 属性支持赋值后再读取正确值。 + /// + [Fact] + public void Layout_SetValue_ShouldReturnSameValue() + { + // Arrange + var def = new TemplateDefinition("email"); + + // Act + def.Layout = "base"; + + // Assert + def.Layout.ShouldBe("base"); + } + + /// + /// 测试目的:RenderEngine 属性支持赋值后再读取正确值。 + /// + [Fact] + public void RenderEngine_SetValue_ShouldReturnSameValue() + { + // Arrange + var def = new TemplateDefinition("sms"); + + // Act + def.RenderEngine = "Liquid"; + + // Assert + def.RenderEngine.ShouldBe("Liquid"); + } + + // ===================================================================== + // Indexer & Properties dictionary + // ===================================================================== + + /// + /// 测试目的:通过索引器写入属性后,可用索引器及 Properties 字典读取。 + /// + [Fact] + public void Indexer_SetAndGet_ShouldStoreInDictionary() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + def["subject"] = "Monthly Report"; + + // Assert + def["subject"].ShouldBe("Monthly Report"); + def.Properties["subject"].ShouldBe("Monthly Report"); + } + + /// + /// 测试目的:索引器读取不存在的 key 时应返回 null(GetOrDefault 语义)。 + /// + [Fact] + public void Indexer_GetNonExistentKey_ShouldReturnNull() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + var value = def["nonexistent"]; + + // Assert + value.ShouldBeNull(); + } + + /// + /// 测试目的:多次写入同一 key,应以最新值覆盖。 + /// + [Fact] + public void Indexer_Overwrite_ShouldUseLatestValue() + { + // Arrange + var def = new TemplateDefinition("report"); + def["key"] = "first"; + + // Act + def["key"] = "second"; + + // Assert + def["key"].ShouldBe("second"); + def.Properties.Count.ShouldBe(1); + } + + // ===================================================================== + // WithProperty fluent API + // ===================================================================== + + /// + /// 测试目的:WithProperty 应将属性存入字典并返回同一实例(支持链式调用)。 + /// + [Fact] + public void WithProperty_ShouldStoreValueAndReturnSameInstance() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + var result = def.WithProperty("author", "Bing"); + + // Assert + result.ShouldBeSameAs(def); + def.Properties["author"].ShouldBe("Bing"); + } + + /// + /// 测试目的:WithProperty 支持链式调用写入多个属性。 + /// + [Fact] + public void WithProperty_Chained_ShouldStoreAllProperties() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + def.WithProperty("a", 1).WithProperty("b", 2).WithProperty("c", 3); + + // Assert + def.Properties.Count.ShouldBe(3); + def.Properties["a"].ShouldBe(1); + def.Properties["b"].ShouldBe(2); + def.Properties["c"].ShouldBe(3); + } + + // ===================================================================== + // WithRenderEngine fluent API + // ===================================================================== + + /// + /// 测试目的:WithRenderEngine 应设置 RenderEngine 并返回同一实例。 + /// + [Fact] + public void WithRenderEngine_ShouldSetRenderEngineAndReturnSameInstance() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + var result = def.WithRenderEngine("Scriban"); + + // Assert + result.ShouldBeSameAs(def); + def.RenderEngine.ShouldBe("Scriban"); + } + + /// + /// 测试目的:WithRenderEngine 可与 WithProperty 链式组合使用。 + /// + [Fact] + public void WithRenderEngine_Chained_WithProperty_ShouldSetBoth() + { + // Arrange + var def = new TemplateDefinition("report"); + + // Act + def.WithRenderEngine("Liquid").WithProperty("version", "2.0"); + + // Assert + def.RenderEngine.ShouldBe("Liquid"); + def.Properties["version"].ShouldBe("2.0"); + } +} diff --git a/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateOptionsAndContextTest.cs b/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateOptionsAndContextTest.cs new file mode 100644 index 00000000..efdd2441 --- /dev/null +++ b/framework/tests/Bing.TextTemplating.Tests/Bing/TextTemplating/TemplateOptionsAndContextTest.cs @@ -0,0 +1,298 @@ +using Shouldly; +using Xunit; + +namespace Bing.TextTemplating; + +// ========================================================================= +// TemplateDefinitionContext Tests +// ========================================================================= + +/// +/// 测试目的:验证 TemplateDefinitionContext 的增删查全逻辑。 +/// +public class TemplateDefinitionContextTest +{ + private static TemplateDefinitionContext CreateEmpty() => + new(new Dictionary()); + + // ----------------------------------------------------------------- + // GetOrNull + // ----------------------------------------------------------------- + + /// + /// 测试目的:空上下文 GetOrNull 应返回 null,不抛异常。 + /// + [Fact] + public void GetOrNull_EmptyContext_ShouldReturnNull() + { + var ctx = CreateEmpty(); + ctx.GetOrNull("invoice").ShouldBeNull(); + } + + /// + /// 测试目的:Add 后可通过 GetOrNull 按名称找到对应定义。 + /// + [Fact] + public void GetOrNull_AfterAdd_ShouldReturnDefinition() + { + // Arrange + var ctx = CreateEmpty(); + var def = new TemplateDefinition("invoice"); + + // Act + ctx.Add(def); + + // Assert + ctx.GetOrNull("invoice").ShouldNotBeNull(); + ctx.GetOrNull("invoice").ShouldBeSameAs(def); + } + + /// + /// 测试目的:Add 不存在名称时,GetOrNull 仍返回 null。 + /// + [Fact] + public void GetOrNull_NotExistName_ShouldReturnNull() + { + var ctx = CreateEmpty(); + ctx.Add(new TemplateDefinition("invoice")); + + ctx.GetOrNull("email").ShouldBeNull(); + } + + /// + /// 测试目的:同名模板 Add 两次,第二次应覆盖第一次。 + /// + [Fact] + public void GetOrNull_SameName_ShouldReturnLatest() + { + var ctx = CreateEmpty(); + var first = new TemplateDefinition("report"); + var second = new TemplateDefinition("report"); + ctx.Add(first); + ctx.Add(second); + + ctx.GetOrNull("report").ShouldBeSameAs(second); + } + + // ----------------------------------------------------------------- + // GetAll (no param) + // ----------------------------------------------------------------- + + /// + /// 测试目的:空上下文 GetAll() 应返回空列表,不抛异常。 + /// + [Fact] + public void GetAll_EmptyContext_ShouldReturnEmptyList() + { + var ctx = CreateEmpty(); + ctx.GetAll().ShouldBeEmpty(); + } + + /// + /// 测试目的:Add 多个定义后,GetAll() 应包含所有已添加的定义。 + /// + [Fact] + public void GetAll_AfterAddMultiple_ShouldReturnAll() + { + var ctx = CreateEmpty(); + ctx.Add(new TemplateDefinition("a"), new TemplateDefinition("b"), new TemplateDefinition("c")); + + var all = ctx.GetAll(); + all.Count.ShouldBe(3); + all.Select(x => x.Name).ShouldContain("a"); + all.Select(x => x.Name).ShouldContain("b"); + all.Select(x => x.Name).ShouldContain("c"); + } + + // ----------------------------------------------------------------- + // GetAll (name param) — implementation ignores name, returns all + // ----------------------------------------------------------------- + + /// + /// 测试目的:GetAll(name) 当前实现忽略 name 参数,返回全部定义。 + /// + [Fact] + public void GetAllByName_ShouldReturnAllDefinitions() + { + var ctx = CreateEmpty(); + ctx.Add(new TemplateDefinition("a"), new TemplateDefinition("b")); + + // 实现:返回全部,name 参数被忽略 + ctx.GetAll("whatever").Count.ShouldBe(2); + } + + // ----------------------------------------------------------------- + // Add edge cases + // ----------------------------------------------------------------- + + /// + /// 测试目的:Add 传入空数组时不应抛出异常,上下文保持不变。 + /// + [Fact] + public void Add_EmptyArray_ShouldNotThrowAndContextUnchanged() + { + var ctx = CreateEmpty(); + var ex = Record.Exception(() => ctx.Add()); + ex.ShouldBeNull(); + ctx.GetAll().ShouldBeEmpty(); + } + + /// + /// 测试目的:Add 传入 null 数组时不应抛出异常(源码: null→return)。 + /// + [Fact] + public void Add_NullArray_ShouldNotThrow() + { + var ctx = CreateEmpty(); + var ex = Record.Exception(() => ctx.Add(null!)); + ex.ShouldBeNull(); + } +} + +// ========================================================================= +// BingTextTemplatingOptions Tests +// ========================================================================= + +/// +/// 测试目的:验证 BingTextTemplatingOptions 构造后各集合正确初始化。 +/// +public class BingTextTemplatingOptionsTest +{ + /// + /// 测试目的:构造后 DefinitionProviders 不为 null。 + /// + [Fact] + public void Constructor_DefinitionProviders_ShouldNotBeNull() + { + var options = new BingTextTemplatingOptions(); + options.DefinitionProviders.ShouldNotBeNull(); + } + + /// + /// 测试目的:构造后 ContentContributors 不为 null。 + /// + [Fact] + public void Constructor_ContentContributors_ShouldNotBeNull() + { + var options = new BingTextTemplatingOptions(); + options.ContentContributors.ShouldNotBeNull(); + } + + /// + /// 测试目的:构造后 RenderingEngines 不为 null 且为空字典。 + /// + [Fact] + public void Constructor_RenderingEngines_ShouldBeEmptyDictionary() + { + var options = new BingTextTemplatingOptions(); + options.RenderingEngines.ShouldNotBeNull(); + options.RenderingEngines.ShouldBeEmpty(); + } + + /// + /// 测试目的:构造后 DefaultRenderingEngine 默认为 null。 + /// + [Fact] + public void Constructor_DefaultRenderingEngine_ShouldBeNull() + { + var options = new BingTextTemplatingOptions(); + options.DefaultRenderingEngine.ShouldBeNull(); + } + + /// + /// 测试目的:可以正常设置并读取 DefaultRenderingEngine。 + /// + [Fact] + public void DefaultRenderingEngine_SetValue_ShouldReturnSameValue() + { + var options = new BingTextTemplatingOptions(); + options.DefaultRenderingEngine = "Liquid"; + options.DefaultRenderingEngine.ShouldBe("Liquid"); + } + + /// + /// 测试目的:可向 RenderingEngines 字典添加条目并正常读取。 + /// + [Fact] + public void RenderingEngines_AddEntry_ShouldBeRetrievable() + { + var options = new BingTextTemplatingOptions(); + options.RenderingEngines["Liquid"] = typeof(object); + + options.RenderingEngines["Liquid"].ShouldBe(typeof(object)); + } +} + +// ========================================================================= +// TemplateContentContributorContext Tests +// ========================================================================= + +/// +/// 测试目的:验证 TemplateContentContributorContext 构造参数校验及属性正确性。 +/// +public class TemplateContentContributorContextTest +{ + private static IServiceProvider CreateFakeProvider() => + new FakeServiceProvider(); + + /// + /// 测试目的:所有参数合法时,构造成功并可正确读取各属性。 + /// + [Fact] + public void Constructor_ValidArgs_ShouldSetProperties() + { + // Arrange + var def = new TemplateDefinition("invoice"); + var sp = CreateFakeProvider(); + + // Act + var ctx = new TemplateContentContributorContext(def, sp, "zh-CN"); + + // Assert + ctx.TemplateDefinition.ShouldBeSameAs(def); + ctx.ServiceProvider.ShouldBeSameAs(sp); + ctx.Culture.ShouldBe("zh-CN"); + } + + /// + /// 测试目的:templateDefinition 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_NullTemplateDefinition_ShouldThrowArgumentNullException() + { + var ex = Assert.Throws(() => + new TemplateContentContributorContext(null!, CreateFakeProvider(), "zh-CN")); + ex.ParamName.ShouldBe("templateDefinition"); + } + + /// + /// 测试目的:serviceProvider 为 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_NullServiceProvider_ShouldThrowArgumentNullException() + { + var def = new TemplateDefinition("invoice"); + var ex = Assert.Throws(() => + new TemplateContentContributorContext(def, null!, "zh-CN")); + ex.ParamName.ShouldBe("serviceProvider"); + } + + /// + /// 测试目的:Culture 允许为 null,不应抛出异常。 + /// + [Fact] + public void Constructor_NullCulture_ShouldBeAllowed() + { + var ctx = new TemplateContentContributorContext( + new TemplateDefinition("invoice"), CreateFakeProvider(), null); + ctx.Culture.ShouldBeNull(); + } + + /// + /// 简单 IServiceProvider 桩,仅用于测试。 + /// + private class FakeServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } +} diff --git a/framework/tests/Bing.Uow.Tests/Bing.Uow.Tests.csproj b/framework/tests/Bing.Uow.Tests/Bing.Uow.Tests.csproj new file mode 100644 index 00000000..59b8575a --- /dev/null +++ b/framework/tests/Bing.Uow.Tests/Bing.Uow.Tests.csproj @@ -0,0 +1,12 @@ + + + + + false + + + + + + + diff --git a/framework/tests/Bing.Uow.Tests/UnitOfWorkManagerTest.cs b/framework/tests/Bing.Uow.Tests/UnitOfWorkManagerTest.cs new file mode 100644 index 00000000..7127133f --- /dev/null +++ b/framework/tests/Bing.Uow.Tests/UnitOfWorkManagerTest.cs @@ -0,0 +1,199 @@ +using Bing.Uow; +using Moq; +using Shouldly; +using Xunit; + +namespace Bing.Uow.Tests; + +/// +/// 单元测试。 +/// Mock IUnitOfWork,验证管理器协调行为,不依赖真实 DB。 +/// +public class UnitOfWorkManagerTest +{ + private static Mock CreateMockUow(int commitResult = 1) + { + var mock = new Mock(); + mock.Setup(u => u.Commit()).Returns(commitResult); + mock.Setup(u => u.CommitAsync(It.IsAny())) + .ReturnsAsync(commitResult); + return mock; + } + + // ── Register ────────────────────────────────────────────────── + + /// + /// 测试目的:Register 后 GetUnitOfWorks 应包含已注册的工作单元。 + /// + [Fact] + public void Register_ShouldAddUnitOfWorkToCollection() + { + // Arrange + var manager = new UnitOfWorkManager(); + var uow = CreateMockUow().Object; + + // Act + manager.Register(uow); + + // Assert + manager.GetUnitOfWorks().ShouldContain(uow); + } + + /// + /// 测试目的:同一个工作单元重复注册,集合中只应存在一次(HashSet 去重)。 + /// + [Fact] + public void Register_SameUowTwice_ShouldNotDuplicate() + { + // Arrange + var manager = new UnitOfWorkManager(); + var uow = CreateMockUow().Object; + + // Act + manager.Register(uow); + manager.Register(uow); + + // Assert + manager.GetUnitOfWorks().Count.ShouldBe(1); + } + + /// + /// 测试目的:注册 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void Register_WithNull_ShouldThrowArgumentNullException() + { + // Arrange + var manager = new UnitOfWorkManager(); + + // Act & Assert + Should.Throw(() => manager.Register(null)); + } + + // ── Commit ──────────────────────────────────────────────────── + + /// + /// 测试目的:Commit 应调用所有已注册工作单元的 Commit 方法,每个仅调用一次。 + /// + [Fact] + public void Commit_ShouldCallCommitOnAllRegisteredUows() + { + // Arrange + var manager = new UnitOfWorkManager(); + var mock1 = CreateMockUow(); + var mock2 = CreateMockUow(); + manager.Register(mock1.Object); + manager.Register(mock2.Object); + + // Act + manager.Commit(); + + // Assert + mock1.Verify(u => u.Commit(), Times.Once); + mock2.Verify(u => u.Commit(), Times.Once); + } + + /// + /// 测试目的:无注册工作单元时,Commit 不抛异常(零元素场景)。 + /// + [Fact] + public void Commit_WithNoUows_ShouldNotThrow() + { + // Arrange + var manager = new UnitOfWorkManager(); + + // Act & Assert + Should.NotThrow(() => manager.Commit()); + } + + // ── CommitAsync ─────────────────────────────────────────────── + + /// + /// 测试目的:CommitAsync 应异步调用所有已注册工作单元的 CommitAsync,每个仅调用一次。 + /// + [Fact] + public async Task CommitAsync_ShouldCallCommitAsyncOnAllRegisteredUows() + { + // Arrange + var manager = new UnitOfWorkManager(); + var mock1 = CreateMockUow(); + var mock2 = CreateMockUow(); + manager.Register(mock1.Object); + manager.Register(mock2.Object); + + // Act + await manager.CommitAsync(); + + // Assert + mock1.Verify(u => u.CommitAsync(It.IsAny()), Times.Once); + mock2.Verify(u => u.CommitAsync(It.IsAny()), Times.Once); + } + + /// + /// 测试目的:CommitAsync 在无注册工作单元时应正常完成,不抛异常。 + /// + [Fact] + public async Task CommitAsync_WithNoUows_ShouldCompleteSuccessfully() + { + // Arrange + var manager = new UnitOfWorkManager(); + + // Act & Assert + await Should.NotThrowAsync(async () => await manager.CommitAsync()); + } + + /// + /// 测试目的:传入 CancellationToken 时应将 Token 正确传递给各工作单元。 + /// + [Fact] + public async Task CommitAsync_WithCancellationToken_ShouldPassTokenToUows() + { + // Arrange + var manager = new UnitOfWorkManager(); + var mock = CreateMockUow(); + manager.Register(mock.Object); + var cts = new CancellationTokenSource(); + + // Act + await manager.CommitAsync(cts.Token); + + // Assert + mock.Verify(u => u.CommitAsync(cts.Token), Times.Once); + } + + // ── GetUnitOfWorks ──────────────────────────────────────────── + + /// + /// 测试目的:初始状态下 GetUnitOfWorks 应返回空集合,不为 null。 + /// + [Fact] + public void GetUnitOfWorks_InitialState_ShouldBeEmptyNotNull() + { + // Arrange + var manager = new UnitOfWorkManager(); + + // Act + var uows = manager.GetUnitOfWorks(); + + // Assert + uows.ShouldNotBeNull(); + uows.ShouldBeEmpty(); + } + + /// + /// 测试目的:GetUnitOfWorks 返回的集合应为只读,防止调用方直接修改内部状态。 + /// + [Fact] + public void GetUnitOfWorks_ShouldReturnReadOnlyCollection() + { + // Arrange + var manager = new UnitOfWorkManager(); + manager.Register(CreateMockUow().Object); + + // Act + var uows = manager.GetUnitOfWorks(); + + // Assert — IReadOnlyCollection 不能 Cast 为 IList + (uows is IReadOnlyCollection).ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing.Validation.Tests.csproj b/framework/tests/Bing.Validation.Tests/Bing.Validation.Tests.csproj new file mode 100644 index 00000000..54d2143d --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing.Validation.Tests.csproj @@ -0,0 +1,12 @@ + + + + + false + + + + + + + diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ExtendedValidationAttributesTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ExtendedValidationAttributesTest.cs new file mode 100644 index 00000000..888b867a --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ExtendedValidationAttributesTest.cs @@ -0,0 +1,278 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +// ── 测试用模型定义 ───────────────────────────────────────────────────────── + +internal class HttpUrlModel +{ + [HttpUrlAddress] + public string Url { get; set; } +} + +internal class TelNoModel +{ + [TelNoOfChina] + public string TelNo { get; set; } +} + +internal class PlateModel +{ + [PlateNumberOfChina] + public string PlateNo { get; set; } +} + +internal class WechatModel +{ + [WechatNo] + public string WechatId { get; set; } +} + +/// +/// 扩展验证 Attribute 单元测试: +/// 、 +/// +/// +public class ExtendedValidationAttributesTest +{ + // ── 辅助方法 ───────────────────────────────────────────────────────────── + + private static bool IsValid(object model) + { + var ctx = new System.ComponentModel.DataAnnotations.ValidationContext(model); + var results = new List(); + return Validator.TryValidateObject(model, ctx, results, validateAllProperties: true); + } + + // ════════════════════════════════════════════════════════════════ + // [HttpUrlAddress] + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:合法的 http/https URL 应通过 [HttpUrlAddress] 验证。 + /// + [Theory] + [InlineData("http://www.example.com")] + [InlineData("https://example.com/path/to/page")] + [InlineData("http://sub.domain.org/resource?key=value&other=123")] + public void HttpUrlAddress_ValidUrl_ShouldPass(string url) + { + // Arrange + var model = new HttpUrlModel { Url = url }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + /// + /// 测试目的:非 URL 格式字符串应使 [HttpUrlAddress] 验证失败。 + /// + [Theory] + [InlineData("not-a-url")] + [InlineData("ftp://example.com")] + [InlineData("www.example.com")] + [InlineData("just some text")] + public void HttpUrlAddress_InvalidUrl_ShouldFail(string url) + { + // Arrange + var model = new HttpUrlModel { Url = url }; + + // Act & Assert + IsValid(model).ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [HttpUrlAddress] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void HttpUrlAddress_NullOrEmpty_ShouldPass(string url) + { + // Arrange + var model = new HttpUrlModel { Url = url }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // [TelNoOfChina] + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:符合中国固定电话号码格式(区号-号码)的字符串应通过验证。 + /// + [Theory] + [InlineData("010-12345678")] + [InlineData("021-1234567")] + [InlineData("0755-1234567")] + [InlineData("01012345678")] + public void TelNoOfChina_ValidTelNo_ShouldPass(string telNo) + { + // Arrange + var model = new TelNoModel { TelNo = telNo }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + /// + /// 测试目的:不符合固话格式的字符串应使 [TelNoOfChina] 验证失败。 + /// + [Theory] + [InlineData("13800138000")] // 手机号,非固话 + [InlineData("abcd-1234567")] + [InlineData("12")] + public void TelNoOfChina_InvalidTelNo_ShouldFail(string telNo) + { + // Arrange + var model = new TelNoModel { TelNo = telNo }; + + // Act & Assert + IsValid(model).ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [TelNoOfChina] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void TelNoOfChina_NullOrEmpty_ShouldPass(string telNo) + { + // Arrange + var model = new TelNoModel { TelNo = telNo }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // [PlateNumberOfChina] + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:符合中国车牌号格式的字符串应通过 [PlateNumberOfChina] 验证。 + /// + [Theory] + [InlineData("京A12345")] + [InlineData("粤B88888")] + [InlineData("沪C12AB6")] + public void PlateNumberOfChina_ValidPlate_ShouldPass(string plateNo) + { + // Arrange + var model = new PlateModel { PlateNo = plateNo }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + /// + /// 测试目的:不符合车牌格式(太短、纯数字等)应使验证失败。 + /// + [Theory] + [InlineData("123456")] + [InlineData("AB12345")] + [InlineData("1234")] + public void PlateNumberOfChina_InvalidPlate_ShouldFail(string plateNo) + { + // Arrange + var model = new PlateModel { PlateNo = plateNo }; + + // Act & Assert + IsValid(model).ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [PlateNumberOfChina] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void PlateNumberOfChina_NullOrEmpty_ShouldPass(string plateNo) + { + // Arrange + var model = new PlateModel { PlateNo = plateNo }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // [WechatNo] + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:满足微信号规则(字母开头,6~20位字母/数字/下划线/横线)的字符串应通过验证。 + /// + [Theory] + [InlineData("aBcDe12345")] + [InlineData("wx_user-001")] + [InlineData("hello_world_2024")] + public void WechatNo_ValidWechat_ShouldPass(string wechat) + { + // Arrange + var model = new WechatModel { WechatId = wechat }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + /// + /// 测试目的:不符合微信号格式(数字开头、太短)的字符串应验证失败。 + /// + [Theory] + [InlineData("1abc")] // 数字开头 + [InlineData("ab")] // 太短(不足6位) + [InlineData("@invalid!")] // 非法字符 + public void WechatNo_InvalidWechat_ShouldFail(string wechat) + { + // Arrange + var model = new WechatModel { WechatId = wechat }; + + // Act & Assert + IsValid(model).ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [WechatNo] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void WechatNo_NullOrEmpty_ShouldPass(string wechat) + { + // Arrange + var model = new WechatModel { WechatId = wechat }; + + // Act & Assert + IsValid(model).ShouldBeTrue(); + } + + // ════════════════════════════════════════════════════════════════ + // ValidatePattern 常量验证 + // ════════════════════════════════════════════════════════════════ + + /// + /// 测试目的:ValidatePattern 中各正则常量不为 null/空,确保静态初始化正常。 + /// + [Fact] + public void ValidatePattern_AllPatterns_ShouldNotBeNullOrEmpty() + { + // Assert + Bing.Validations.Validators.ValidatePattern.MobilePhonePattern.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.IdCardPattern.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.ChinesePattern.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.UrlPattern.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.LetterPattern.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.PlateNumberOfChinaPatter.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.TelNoOfChinaPatter.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.WechatNoPatter.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.QQPatter.ShouldNotBeNullOrWhiteSpace(); + Bing.Validations.Validators.ValidatePattern.PostalCodeOfChinaPatter.ShouldNotBeNullOrWhiteSpace(); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationAttributesTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationAttributesTest.cs new file mode 100644 index 00000000..9480b6a5 --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationAttributesTest.cs @@ -0,0 +1,420 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +// ── 测试用模型定义(仅 string 参数属性,确保属性语法合法)────────────────── + +internal class LetterModel +{ + [Letter] + public string Code { get; set; } +} + +internal class ChineseModel +{ + [Chinese] + public string Name { get; set; } +} + +internal class IdCardModel +{ + [IdCard] + public string CardNo { get; set; } +} + +internal class PostalModel +{ + [PostalCodeOfChina] + public string Postal { get; set; } +} + +internal class QQModel +{ + [QQ] + public string QQNo { get; set; } +} + +internal class MultiAttributeModel +{ + [Letter] + public string Code { get; set; } + + [Chinese] + public string Name { get; set; } +} + +/// +/// 自定义 DataAnnotation 验证属性的单元测试 +/// +public class ValidationAttributesTest +{ + // ── [Letter] ────────────────────────────────────────────────── + + /// + /// 测试目的:纯英文字母值应通过 [Letter] 验证。 + /// + [Theory] + [InlineData("abc")] + [InlineData("ABC")] + [InlineData("Hello")] + public void LetterAttribute_ValidLetter_ShouldPass(string value) + { + // Arrange + var model = new LetterModel { Code = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:包含非字母字符(数字、空格、中文)的值应使 [Letter] 验证失败。 + /// + [Theory] + [InlineData("abc123")] + [InlineData("Hello World")] + [InlineData("测试")] + public void LetterAttribute_InvalidLetter_ShouldFail(string value) + { + // Arrange + var model = new LetterModel { Code = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串值应通过 [Letter] 验证(允许空值,非必填)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void LetterAttribute_NullOrEmpty_ShouldPass(string value) + { + // Arrange + var model = new LetterModel { Code = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + // ── [Chinese] ───────────────────────────────────────────────── + + /// + /// 测试目的:纯中文字符串应通过 [Chinese] 验证。 + /// + [Theory] + [InlineData("张三")] + [InlineData("你好世界")] + public void ChineseAttribute_ValidChinese_ShouldPass(string value) + { + // Arrange + var model = new ChineseModel { Name = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:含英文字母或数字的字符串应使 [Chinese] 验证失败。 + /// + [Theory] + [InlineData("abc")] + [InlineData("张三abc")] + [InlineData("123")] + public void ChineseAttribute_InvalidChinese_ShouldFail(string value) + { + // Arrange + var model = new ChineseModel { Name = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [Chinese] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void ChineseAttribute_NullOrEmpty_ShouldPass(string value) + { + // Arrange + var model = new ChineseModel { Name = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + // ── [Money] (直接实例化测试,decimal 不支持属性语法中的编译时常量) ───── + + /// + /// 测试目的:在 (Min, Max] 范围内的金额值应通过 MoneyAttribute 验证。 + /// + [Theory] + [InlineData("1")] + [InlineData("500")] + [InlineData("1000")] + public void MoneyAttribute_ValidAmount_ShouldPass(string rawValue) + { + // Arrange + var attr = new MoneyAttribute(0m, 1000m); + var value = decimal.Parse(rawValue); + var ctx = new ValidationContext(new object()) { MemberName = "Amount" }; + + // Act + var result = attr.GetValidationResult(value, ctx); + + // Assert — null 表示验证通过 + result.ShouldBeNull(); + } + + /// + /// 测试目的:等于 Min(不满足 > Min)或超出 Max 的值应验证失败。 + /// + [Theory] + [InlineData("0")] // 等于 Min,不满足 > 0 + [InlineData("1001")] // 超过 Max + [InlineData("-10")] // 负数,低于 Min + public void MoneyAttribute_InvalidAmount_ShouldFail(string rawValue) + { + // Arrange + var attr = new MoneyAttribute(0m, 1000m); + var value = decimal.Parse(rawValue); + var ctx = new ValidationContext(new object()) { MemberName = "Amount" }; + + // Act + var result = attr.GetValidationResult(value, ctx); + + // Assert — 非 null 表示验证失败 + result.ShouldNotBeNull(); + } + + /// + /// 测试目的:null 值(空值场景)应通过 MoneyAttribute 验证,不强制要求填写。 + /// + [Fact] + public void MoneyAttribute_Null_ShouldPass() + { + // Arrange + var attr = new MoneyAttribute(0m, 1000m); + var ctx = new ValidationContext(new object()) { MemberName = "Amount" }; + + // Act + var result = attr.GetValidationResult(null, ctx); + + // Assert + result.ShouldBeNull(); + } + + // ── [IdCard] ────────────────────────────────────────────────── + + /// + /// 测试目的:符合身份证格式(15 位或 18 位含 X/x)的字符串应通过 [IdCard] 验证。 + /// + [Theory] + [InlineData("11010519491231002X")] // 18 位含 X + [InlineData("110105194912310020")] // 18 位纯数字 + [InlineData("110105491231002")] // 15 位 + public void IdCardAttribute_ValidIdCard_ShouldPass(string value) + { + // Arrange + var model = new IdCardModel { CardNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:不符合身份证格式的字符串应验证失败。 + /// + [Theory] + [InlineData("1234567")] // 位数太少 + [InlineData("abcdefghijklmnopqr")] // 全为字母 + [InlineData("1101051949123100XY")] // 结尾两个字符无效 + public void IdCardAttribute_InvalidIdCard_ShouldFail(string value) + { + // Arrange + var model = new IdCardModel { CardNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [IdCard] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void IdCardAttribute_NullOrEmpty_ShouldPass(string value) + { + // Arrange + var model = new IdCardModel { CardNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + // ── [PostalCodeOfChina] ──────────────────────────────────────── + + /// + /// 测试目的:6 位纯数字邮政编码应通过 [PostalCodeOfChina] 验证。 + /// + [Theory] + [InlineData("100000")] + [InlineData("200001")] + [InlineData("310000")] + public void PostalCodeAttribute_Valid_ShouldPass(string value) + { + // Arrange + var model = new PostalModel { Postal = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:位数不足、含字母或超出 6 位的编码应验证失败。 + /// + [Theory] + [InlineData("12345")] // 只有 5 位 + [InlineData("1234567")] // 7 位超出 + [InlineData("abcdef")] // 含字母 + public void PostalCodeAttribute_Invalid_ShouldFail(string value) + { + // Arrange + var model = new PostalModel { Postal = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + } + + // ── [QQ] ────────────────────────────────────────────────────── + + /// + /// 测试目的:满足 QQ 号规则(非 0 开头、5~11 位数字)应通过 [QQ] 验证。 + /// + [Theory] + [InlineData("10001")] + [InlineData("123456789")] + [InlineData("99999999999")] // 11 位(最大长度边界) + public void QQAttribute_Valid_ShouldPass(string value) + { + // Arrange + var model = new QQModel { QQNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:以 0 开头、位数不足或含非数字字符的值应验证失败。 + /// + [Theory] + [InlineData("0001")] // 以 0 开头 + [InlineData("1234")] // 只有 4 位,不足 5 位 + [InlineData("abc123")] // 含字母 + public void QQAttribute_Invalid_ShouldFail(string value) + { + // Arrange + var model = new QQModel { QQNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:null 或空字符串应通过 [QQ] 验证(允许空值)。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + public void QQAttribute_NullOrEmpty_ShouldPass(string value) + { + // Arrange + var model = new QQModel { QQNo = value }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + // ── 多属性模型 ──────────────────────────────────────────────── + + /// + /// 测试目的:同一模型上多个属性均违规时,所有错误应被汇总收集(非 fail-fast)。 + /// + [Fact] + public void MultipleAttributes_WhenBothInvalid_ShouldCollectAllErrors() + { + // Arrange — Code 含数字违反 [Letter],Name 含英文违反 [Chinese] + var model = new MultiAttributeModel { Code = "abc123", Name = "English" }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Count.ShouldBe(2); + } + + /// + /// 测试目的:同一模型上多个属性均合法时,验证应通过。 + /// + [Fact] + public void MultipleAttributes_WhenBothValid_ShouldPass() + { + // Arrange + var model = new MultiAttributeModel { Code = "HelloWorld", Name = "张三" }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationExceptionTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationExceptionTest.cs new file mode 100644 index 00000000..fad6b325 --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationExceptionTest.cs @@ -0,0 +1,162 @@ +using Bing.Exceptions; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +/// +/// 单元测试 +/// +public class ValidationExceptionTest +{ + // ── Default constructor ──────────────────────────────────────── + + /// + /// 测试目的:默认构造后,Flag 应为 "__VALID_FLG",标识其为验证类异常。 + /// + [Fact] + public void DefaultConstructor_ShouldHaveValidFlag() + { + // Act + var ex = new ValidationException(); + + // Assert + ex.Flag.ShouldBe("__VALID_FLG"); + } + + // ── Constructor(string message) ──────────────────────────────── + + /// + /// 测试目的:通过消息字符串构造时,Message 应正确赋值,Flag 仍为 "__VALID_FLG"。 + /// + [Fact] + public void Constructor_WithMessage_ShouldSetMessageAndFlag() + { + // Act + var ex = new ValidationException("用户名不能为空"); + + // Assert + ex.Message.ShouldBe("用户名不能为空"); + ex.Flag.ShouldBe("__VALID_FLG"); + } + + // ── Constructor(string message, string flag) ─────────────────── + + /// + /// 测试目的:通过消息与自定义 flag 构造时,应正确覆盖默认 Flag 值。 + /// + [Fact] + public void Constructor_WithMessageAndFlag_ShouldSetCustomFlag() + { + // Act + var ex = new ValidationException("验证失败", "MY_CUSTOM_FLG"); + + // Assert + ex.Message.ShouldBe("验证失败"); + ex.Flag.ShouldBe("MY_CUSTOM_FLG"); + } + + // ── Constructor(string message, Exception innerException) ────── + + /// + /// 测试目的:带内部异常构造时,InnerException 应被正确链接,Flag 仍为默认值。 + /// + [Fact] + public void Constructor_WithInnerException_ShouldLinkInnerExceptionAndFlag() + { + // Arrange + var inner = new InvalidOperationException("原始错误"); + + // Act + var ex = new ValidationException("包装错误", inner); + + // Assert + ex.Message.ShouldBe("包装错误"); + ex.InnerException.ShouldBe(inner); + ex.Flag.ShouldBe("__VALID_FLG"); + } + + // ── Constructor(IEnumerable) ────────────────────────── + + /// + /// 测试目的:传入 null 的验证消息集合应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WithNullMessages_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new ValidationException((IEnumerable)null)); + } + + /// + /// 测试目的:通过有效验证消息集合构造时不应抛出异常, + /// 且 ValidationMessage 属性应包含所有传入消息。 + /// + [Fact] + public void Constructor_WithValidMessages_ShouldSetValidationMessage() + { + // Arrange + var messages = new[] { "字段A错误", "字段B错误" }; + + // Act + var ex = new ValidationException(messages); + + // Assert + ex.ValidationMessage.ShouldNotBeNull(); + ex.ValidationMessage.ShouldContain("字段A错误"); + ex.ValidationMessage.ShouldContain("字段B错误"); + } + + // ── Constructor(long errorCode, string message) ──────────────── + + /// + /// 测试目的:通过错误码和消息构造时,Code 和 Message 均应被正确设置。 + /// + [Fact] + public void Constructor_WithErrorCodeAndMessage_ShouldSetCodeAndMessage() + { + // Act + var ex = new ValidationException(4001L, "自定义验证错误码"); + + // Assert + ex.Code.ShouldBe("4001"); + ex.Message.ShouldBe("自定义验证错误码"); + ex.Flag.ShouldBe("__VALID_FLG"); + } + + // ── ToString / GetFullMessage ────────────────────────────────── + + /// + /// 测试目的:带验证消息集合时,ToString 应包含各条消息内容(非空输出)。 + /// + [Fact] + public void ToString_WithValidationMessages_ShouldContainMessages() + { + // Arrange + var ex = new ValidationException(new[] { "字段X不合法", "字段Y超出范围" }); + + // Act + var str = ex.ToString(); + + // Assert + str.ShouldContain("字段X不合法"); + str.ShouldContain("字段Y超出范围"); + } + + // ── Is-a hierarchy ──────────────────────────────────────────── + + /// + /// 测试目的:ValidationException 应继承自 BingException,满足框架异常类型约定。 + /// + [Fact] + public void ValidationException_ShouldInheritFromBingException() + { + // Arrange + var ex = new ValidationException("test"); + + // Assert + ex.ShouldBeAssignableTo(); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationMeAndContextTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationMeAndContextTest.cs new file mode 100644 index 00000000..b4c4f7e1 --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationMeAndContextTest.cs @@ -0,0 +1,648 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Exceptions; +using Bing.Validation; +using Bing.Validation.Strategies; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +// ─── 测试辅助:实现 IVerifyModel 的样本模型 ──────────────────── + +/// +/// 合法验证模型样本(Name 有默认值,DataAnnotation 可通过) +/// +internal class ValidatableValidModel : IVerifyModel +{ + [Required(ErrorMessage = "名称不能为空")] + public string Name { get; set; } = "valid-name"; + + public IValidationResult Validate() => + new ValidationResultCollection(); + + public void SetValidationCallback(IValidationCallbackHandler handler) { } + public void UseValidationRules() { } + public void UseStrategy(IValidationStrategy strategy) { } + public void UseStrategyList(IEnumerable> strategies) { } +} + +/// +/// 非法验证模型样本(Name 为 null,[Required] 约束失败) +/// +internal class ValidatableInvalidModel : IVerifyModel +{ + [Required(ErrorMessage = "名称不能为空")] + public string Name { get; set; } // null → DataAnnotation 失败 + + public IValidationResult Validate() => + new ValidationResultCollection("名称不能为空"); + + public void SetValidationCallback(IValidationCallbackHandler handler) { } + public void UseValidationRules() { } + public void UseStrategy(IValidationStrategy strategy) { } + public void UseStrategyList(IEnumerable> strategies) { } +} + +/// +/// 验证策略样本:可指定策略名称和返回的验证结果 +/// +internal class NamedStrategy : IValidationStrategy +{ + private readonly ValidationResult _result; + public string StrategyName { get; } + + public NamedStrategy(string name, ValidationResult result = null) + { + StrategyName = name; + _result = result; + } + + public ValidationResult Validate(ValidatableValidModel obj) => _result; +} + +// ─── ValidationMe 测试 ──────────────────────────────────────────── + +/// +/// 单元测试 +/// +public class ValidationMeTest : IDisposable +{ + /// + /// 测试后恢复默认 ThrowHandler,避免影响其他测试 + /// + public void Dispose() => ValidationMe.RegisterCallbackHandler(new ThrowHandler()); + + /// + /// 测试目的:默认处理器为 ThrowHandler;当验证失败且未自定义 Handler 时, + /// 通过 ValidationContext.Validate() 会抛出 Warning 异常。 + /// + [Fact] + public void DefaultHandler_IsThrowHandler_ValidateShouldThrowWarning() + { + // Arrange — 确保恢复默认处理器 + ValidationMe.RegisterCallbackHandler(new ThrowHandler()); + var ctx = new ValidationContext(new ValidatableInvalidModel()); + // 不设置自定义 Handle,使用全局默认处理器 + + // Act & Assert + Should.Throw(() => ctx.Validate()); + } + + /// + /// 测试目的:注册 NothingHandler 后,ValidationContext 验证失败不再抛出异常。 + /// + [Fact] + public void RegisterCallbackHandler_WithNothingHandler_ValidateShouldNotThrow() + { + // Arrange + ValidationMe.RegisterCallbackHandler(new NothingHandler()); + var ctx = new ValidationContext(new ValidatableInvalidModel()); + // 不设置自定义 Handle,使用已注册的 NothingHandler + + // Act & Assert + Should.NotThrow(() => ctx.Validate()); + } + + /// + /// 测试目的:多次 RegisterCallbackHandler 应使用最后一次注册的处理器。 + /// + [Fact] + public void RegisterCallbackHandler_MultipleRegistrations_UsesLast() + { + // Arrange — 先注册 NothingHandler + ValidationMe.RegisterCallbackHandler(new NothingHandler()); + // 再注册 ThrowHandler + ValidationMe.RegisterCallbackHandler(new ThrowHandler()); + var ctx = new ValidationContext(new ValidatableInvalidModel()); + + // Act & Assert — 最后注册的是 ThrowHandler,应抛出 Warning + Should.Throw(() => ctx.Validate()); + } +} + +// ─── ValidationContext 测试 ──────────────────────────────────── + +/// +/// 单元测试 +/// +public class ValidationContextTest +{ + // ── AddStrategy ───────────────────────────────────────────── + + /// + /// 测试目的:AddStrategy 传入 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void AddStrategy_WhenNull_ShouldThrowArgumentNullException() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act & Assert + Should.Throw(() => ctx.AddStrategy(null)); + } + + /// + /// 测试目的:相同 StrategyName 的策略只添加一次(重复跳过)。 + /// + [Fact] + public void AddStrategy_DuplicateName_ShouldBeIgnored() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + var s1 = new NamedStrategy("DupStrategy"); + var s2 = new NamedStrategy("DupStrategy"); // 同名 + + // Act + ctx.AddStrategy(s1); + ctx.AddStrategy(s2); // 应被跳过 + + // 验证 Validate 只调用一次策略(通过结果数量间接验证) + bool handlerInvoked = false; + ctx.SetHandler(_ => handlerInvoked = true); + ctx.Validate(); // 两个策略都返回 null(valid),处理器不应被调用 + + // Assert + handlerInvoked.ShouldBeFalse(); + } + + // ── AddStrategyList ───────────────────────────────────────── + + /// + /// 测试目的:AddStrategyList 传入 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void AddStrategyList_WhenNull_ShouldThrowArgumentNullException() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act & Assert + Should.Throw(() => ctx.AddStrategyList(null)); + } + + /// + /// 测试目的:AddStrategyList 接受空集合时不应抛出异常。 + /// + [Fact] + public void AddStrategyList_WithEmptyList_ShouldNotThrow() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act & Assert + Should.NotThrow(() => ctx.AddStrategyList(Array.Empty())); + } + + /// + /// 测试目的:AddStrategyList 批量添加多个策略,重复名称只保留第一个。 + /// + [Fact] + public void AddStrategyList_WithDuplicates_ShouldSkipDuplicate() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + var strategies = new[] + { + new NamedStrategy("A"), + new NamedStrategy("B"), + new NamedStrategy("A"), // 重复 + }; + + // Act & Assert — 不抛异常,重复跳过 + Should.NotThrow(() => ctx.AddStrategyList(strategies)); + } + + // ── SetHandler ────────────────────────────────────────────── + + /// + /// 测试目的:SetHandler 传入 null 应抛出 ArgumentNullException。 + /// + [Fact] + public void SetHandler_WhenNull_ShouldThrowArgumentNullException() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act & Assert + Should.Throw(() => ctx.SetHandler(null)); + } + + /// + /// 测试目的:第一次调用 SetHandler 应设置处理器(不叠加)。 + /// + [Fact] + public void SetHandler_FirstCall_ShouldSetHandler() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + int callCount = 0; + ctx.SetHandler(_ => callCount++); + + // 为触发 Handle,向 appendAction 添加一个错误 + ctx.Validate(c => c.Add(new ValidationResult("触发处理器"))); + + // Assert — 处理器被调用了 1 次 + callCount.ShouldBe(1); + } + + /// + /// 测试目的:连续调用 SetHandler 应叠加处理器(+=),两个都被调用。 + /// + [Fact] + public void SetHandler_MultipleCalls_ShouldAccumulate() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + int callCount = 0; + ctx.SetHandler(_ => callCount++); + ctx.SetHandler(_ => callCount++); // 第二次叠加 + + // Act — appendAction 注入一个错误触发 Handle + ctx.Validate(c => c.Add(new ValidationResult("触发处理器"))); + + // Assert — 两个处理器都被调用 + callCount.ShouldBe(2); + } + + // ── IsValid ───────────────────────────────────────────────── + + /// + /// 测试目的:Validate 调用前 IsValid 应默认返回 true(尚未验证)。 + /// + [Fact] + public void IsValid_BeforeValidate_ShouldBeTrue() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Assert + ctx.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:对合法模型调用 Validate 后 IsValid 应仍为 true。 + /// + [Fact] + public void IsValid_AfterValidateValidModel_ShouldBeTrue() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act + ctx.Validate(); + + // Assert + ctx.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:对非法模型调用 Validate 后(使用自定义 Handler), + /// 处理器被调用且 IsValid 应为 false。 + /// + [Fact] + public void IsValid_AfterValidateInvalidModel_ShouldBeFalse() + { + // Arrange + var ctx = new ValidationContext(new ValidatableInvalidModel()); + ctx.SetHandler(_ => { }); // 空处理器,防止抛出异常 + + // Act + ctx.Validate(); + + // Assert + ctx.IsValid.ShouldBeFalse(); + } + + // ── Validate(带 appendAction)───────────────────────────── + + /// + /// 测试目的:appendAction 可向验证结果集合追加额外错误,影响最终 IsValid。 + /// + [Fact] + public void Validate_WithAppendAction_ShouldIncludeAppendedErrors() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + ctx.SetHandler(_ => { }); // 不抛出 + + // Act — 向 appendAction 注入错误使有效模型变为无效 + ctx.Validate(c => c.Add(new ValidationResult("人工追加错误"))); + + // Assert + ctx.IsValid.ShouldBeFalse(); + ctx.GetValidationResultCollection().ShouldNotBeNull(); + } + + // ── GetValidationResultCollection ────────────────────────── + + /// + /// 测试目的:Validate 之前 GetValidationResultCollection 应返回 null。 + /// + [Fact] + public void GetValidationResultCollection_BeforeValidate_ShouldBeNull() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Assert + ctx.GetValidationResultCollection().ShouldBeNull(); + } + + /// + /// 测试目的:Validate 之后 GetValidationResultCollection 应返回非 null 集合。 + /// + [Fact] + public void GetValidationResultCollection_AfterValidate_ShouldNotBeNull() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act + ctx.Validate(); + + // Assert + ctx.GetValidationResultCollection().ShouldNotBeNull(); + } + + // ── RaiseException ───────────────────────────────────────── + + /// + /// 测试目的:未调用 Validate 时 RaiseException 不应抛出异常(ResultCollection 为 null)。 + /// + [Fact] + public void RaiseException_BeforeValidate_ShouldNotThrow() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + + // Act & Assert + Should.NotThrow(() => ctx.RaiseException()); + } + + /// + /// 测试目的:合法模型 Validate 后 RaiseException 不应抛出(IsValid=true)。 + /// + [Fact] + public void RaiseException_AfterValidValidModel_ShouldNotThrow() + { + // Arrange + var ctx = new ValidationContext(new ValidatableValidModel()); + ctx.Validate(); + + // Act & Assert + Should.NotThrow(() => ctx.RaiseException()); + } + + /// + /// 测试目的:非法模型 Validate 后(自定义 Handler 不抛出), + /// RaiseException 应抛出 ValidationException。 + /// + [Fact] + public void RaiseException_AfterValidateInvalidModel_ShouldThrowValidationException() + { + // Arrange + var ctx = new ValidationContext(new ValidatableInvalidModel()); + ctx.SetHandler(_ => { }); // 空处理器,防止 Validate 内部抛出 + + ctx.Validate(); + + // Act & Assert + Should.Throw(() => ctx.RaiseException()); + } +} + +// ─── ValidationHandleOperation 测试 ────────────────────────────── + +/// +/// 单元测试 +/// +public class ValidationHandleOperationTest +{ + /// + /// 测试目的:构造函数传入 null 集合时应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WhenCollectionIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => new ValidationHandleOperation(null)); + } + + /// + /// 测试目的:RaiseException 对有效集合不应抛出任何异常。 + /// + [Fact] + public void RaiseException_WhenValid_ShouldNotThrow() + { + // Arrange + var op = new ValidationHandleOperation(new ValidationResultCollection()); + + // Act & Assert + Should.NotThrow(() => op.RaiseException()); + } + + /// + /// 测试目的:RaiseException 对无效集合应抛出指定类型的异常。 + /// + [Fact] + public void RaiseException_WhenInvalid_ShouldThrowValidationException() + { + // Arrange + var op = new ValidationHandleOperation(new ValidationResultCollection("字段X不合法")); + + // Act & Assert + Should.Throw(() => op.RaiseException()); + } +} + +// ─── ValidationHandleExceptionExtensions 测试 ──────────────────── + +/// +/// 单元测试 +/// +public class ValidationHandleExceptionExtensionsTest +{ + /// + /// 测试目的:Handle() 扩展方法应为集合创建并返回 ValidationHandleOperation 实例。 + /// + [Fact] + public void Handle_ShouldReturnValidationHandleOperation() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var op = col.Handle(); + + // Assert + op.ShouldNotBeNull(); + op.ShouldBeOfType(); + } + + /// + /// 测试目的:HandleAll 传入 null 的 handler 时应抛出 ArgumentNullException。 + /// + [Fact] + public void HandleAll_WhenHandlerIsNull_ShouldThrowArgumentNullException() + { + // Arrange + var op = new ValidationHandleOperation(new ValidationResultCollection()); + + // Act & Assert + Should.Throw(() => op.HandleAll(null)); + } + + /// + /// 测试目的:HandleAll 传入 null 的 op 时应抛出 ArgumentNullException。 + /// + [Fact] + public void HandleAll_WhenOpIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + ValidationHandleExceptionExtensions.HandleAll(null, new ThrowHandler())); + } + + /// + /// 测试目的:HandleAll 传入 NothingHandler 对无效集合不应抛出异常,并返回 op 本身(链式调用)。 + /// + [Fact] + public void HandleAll_WithNothingHandler_ShouldNotThrowAndReturnOp() + { + // Arrange + var col = new ValidationResultCollection("字段Y错误"); + var op = col.Handle(); + + // Act + var result = Should.NotThrow(() => op.HandleAll(new NothingHandler())); + + // Assert + result.ShouldBeSameAs(op); + } + + /// + /// 测试目的:HandleAll 传入 ThrowHandler 对无效集合应抛出 Warning。 + /// + [Fact] + public void HandleAll_WithThrowHandler_WhenInvalid_ShouldThrowWarning() + { + // Arrange + var col = new ValidationResultCollection("字段Z错误"); + var op = col.Handle(); + + // Act & Assert + Should.Throw(() => op.HandleAll(new ThrowHandler())); + } + + /// + /// 测试目的:HandleAll 对有效集合使用 ThrowHandler 不应抛出异常。 + /// + [Fact] + public void HandleAll_WithThrowHandler_WhenValid_ShouldNotThrow() + { + // Arrange + var col = new ValidationResultCollection(); // 空 = 有效 + var op = col.Handle(); + + // Act & Assert + Should.NotThrow(() => op.HandleAll(new ThrowHandler())); + } +} + +// ─── ValidationExceptionExtensions 测试 ────────────────────────── + +/// +/// 单元测试 +/// +public class ValidationExceptionExtensionsTest +{ + /// + /// 测试目的:null 集合调用 ToException 应抛出 ArgumentNullException。 + /// + [Fact] + public void ToException_WhenCollectionIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + ((ValidationResultCollection)null).ToException()); + } + + /// + /// 测试目的:有效集合(无错误)调用 ToException 应返回带空消息的 ValidationException。 + /// + [Fact] + public void ToException_WhenValid_ShouldReturnException() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var ex = col.ToException(); + + // Assert — 应正确返回异常实例 + ex.ShouldNotBeNull(); + } + + /// + /// 测试目的:无效集合调用 ToException 应返回包含错误信息的 ValidationException。 + /// + [Fact] + public void ToException_WhenInvalid_ShouldContainErrorMessages() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("字段W不能为空")); + + // Act + var ex = col.ToException(); + + // Assert + ex.ShouldNotBeNull(); + ex.Message.ShouldContain("字段W不能为空"); + } + + /// + /// 测试目的:appendAction 参数应在 ToException 时被执行,可追加额外信息。 + /// + [Fact] + public void ToException_WithAppendAction_ShouldInvokeAction() + { + // Arrange + var col = new ValidationResultCollection("字段V错误"); + bool actionInvoked = false; + + // Act + var ex = col.ToException((e, c) => actionInvoked = true); + + // Assert + ex.ShouldNotBeNull(); + actionInvoked.ShouldBeTrue(); + } + + /// + /// 测试目的:RaiseException 扩展方法对有效集合不应抛出任何异常。 + /// + [Fact] + public void RaiseException_WhenValid_ShouldNotThrow() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act & Assert + Should.NotThrow(() => col.RaiseException()); + } + + /// + /// 测试目的:RaiseException 扩展方法对无效集合应抛出 ValidationException。 + /// + [Fact] + public void RaiseException_WhenInvalid_ShouldThrow() + { + // Arrange + var col = new ValidationResultCollection("字段U不合法"); + + // Act & Assert + Should.Throw(() => col.RaiseException()); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionAdvancedTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionAdvancedTest.cs new file mode 100644 index 00000000..d95cb27e --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionAdvancedTest.cs @@ -0,0 +1,325 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +/// +/// 高级构造函数与方法的单元测试 +/// +public class ValidationResultCollectionAdvancedTest +{ + // ── Static Success ───────────────────────────────────────────── + + /// + /// 测试目的:静态 Success 属性应为有效的空集合(IsValid=true,Count=0)。 + /// + [Fact] + public void Success_ShouldBeValidAndEmpty() + { + // Arrange & Act + var success = ValidationResultCollection.Success; + + // Assert + success.IsValid.ShouldBeTrue(); + success.Count.ShouldBe(0); + } + + // ── Constructor(ValidationResult) ────────────────────────────── + + /// + /// 测试目的:通过 ValidationResult 构造时,集合应包含该条目且 IsValid=false。 + /// + [Fact] + public void Constructor_WithValidationResult_ShouldContainOneError() + { + // Arrange + var result = new ValidationResult("字段错误", new[] { "Field1" }); + + // Act + var col = new ValidationResultCollection(result); + + // Assert + col.IsValid.ShouldBeFalse(); + col.Count.ShouldBe(1); + col.First().ErrorMessage.ShouldBe("字段错误"); + } + + /// + /// 测试目的:传入 null 的 ValidationResult 应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WithNullValidationResult_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => new ValidationResultCollection((ValidationResult)null)); + } + + // ── Constructor(ValidationResult, strategyName) ──────────────── + + /// + /// 测试目的:带策略名构造时,结果仍应被正确存入集合,IsValid=false。 + /// + [Fact] + public void Constructor_WithValidationResultAndStrategyName_ShouldContainOneError() + { + // Arrange + var result = new ValidationResult("策略错误"); + + // Act + var col = new ValidationResultCollection(result, "myStrategy"); + + // Assert + col.IsValid.ShouldBeFalse(); + col.Count.ShouldBe(1); + } + + // ── Constructor(IEnumerable) ───────────────── + + /// + /// 测试目的:通过多条 ValidationResult 集合构造时,Count 应正确反映。 + /// + [Fact] + public void Constructor_WithEnumerableResults_ShouldContainAllErrors() + { + // Arrange + var results = new[] + { + new ValidationResult("错误A"), + new ValidationResult("错误B"), + }; + + // Act + var col = new ValidationResultCollection(results); + + // Assert + col.Count.ShouldBe(2); + col.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:传入 null 的枚举集合应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_WithNullEnumerable_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new ValidationResultCollection((IEnumerable)null)); + } + + // ── Copy Constructor(ValidationResultCollection) ─────────────── + + /// + /// 测试目的:拷贝构造时,应复制 ErrorCode、Flag 与所有错误条目。 + /// + [Fact] + public void Constructor_CopyFromOther_ShouldCopyErrorCodeFlagAndResults() + { + // Arrange + var source = new ValidationResultCollection("原始错误"); + source.ErrorCode = 9999; + source.Flag = "MY_FLAG"; + + // Act + var copy = new ValidationResultCollection(source); + + // Assert + copy.Count.ShouldBe(1); + copy.ErrorCode.ShouldBe(9999L); + copy.Flag.ShouldBe("MY_FLAG"); + } + + /// + /// 测试目的:传入 null 的源集合应抛出 ArgumentNullException。 + /// + [Fact] + public void Constructor_CopyFromNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => + new ValidationResultCollection((ValidationResultCollection)null)); + } + + // ── ErrorCode / Flag defaults ────────────────────────────────── + + /// + /// 测试目的:默认构造后,ErrorCode 应为 1001,Flag 应为 "__EMPTY_FLG"。 + /// + [Fact] + public void DefaultConstructor_ShouldHaveDefaultErrorCodeAndFlag() + { + // Arrange & Act + var col = new ValidationResultCollection(); + + // Assert + col.ErrorCode.ShouldBe(1001L); + col.Flag.ShouldBe("__EMPTY_FLG"); + } + + /// + /// 测试目的:ErrorCode 和 Flag 属性可被外部设置并正确存储。 + /// + [Fact] + public void ErrorCodeAndFlag_CanBeSetExternally() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + col.ErrorCode = 5000L; + col.Flag = "CUSTOM_FLAG"; + + // Assert + col.ErrorCode.ShouldBe(5000L); + col.Flag.ShouldBe("CUSTOM_FLAG"); + } + + // ── ToMessage ───────────────────────────────────────────────── + + /// + /// 测试目的:有效(空)集合的 ToMessage 应包含"未发现验证错误"。 + /// + [Fact] + public void ToMessage_WhenValid_ShouldContainNoErrorText() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var msg = col.ToMessage(); + + // Assert + msg.ShouldContain("未发现验证错误"); + } + + /// + /// 测试目的:1 条错误时 ToMessage 应包含"发现1个验证错误"。 + /// + [Fact] + public void ToMessage_WhenOneError_ShouldContainOneErrorText() + { + // Arrange + var col = new ValidationResultCollection("单条错误"); + + // Act + var msg = col.ToMessage(); + + // Assert + msg.ShouldContain("发现1个验证错误"); + } + + /// + /// 测试目的:多条错误时 ToMessage 应包含正确的错误数量。 + /// + [Fact] + public void ToMessage_WhenMultipleErrors_ShouldContainCorrectCount() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("错误1")); + col.Add(new ValidationResult("错误2")); + + // Act + var msg = col.ToMessage(); + + // Assert + msg.ShouldContain("发现2个验证错误"); + } + + // ── ToValidationMessages ─────────────────────────────────────── + + /// + /// 测试目的:有效集合的 ToValidationMessages 应返回空序列(非 null)。 + /// + [Fact] + public void ToValidationMessages_WhenValid_ShouldReturnEmpty() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var msgs = col.ToValidationMessages().ToList(); + + // Assert + msgs.ShouldNotBeNull(); + msgs.ShouldBeEmpty(); + } + + /// + /// 测试目的:无效集合的 ToValidationMessages 每条消息应包含成员名与错误描述。 + /// + [Fact] + public void ToValidationMessages_WhenInvalid_ShouldContainMemberNameAndMessage() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("姓名格式有误", new[] { "Name" })); + + // Act + var msgs = col.ToValidationMessages().ToList(); + + // Assert + msgs.Count.ShouldBe(1); + msgs[0].ShouldContain("Name"); + msgs[0].ShouldContain("姓名格式有误"); + } + + // ── Add(null) / AddRange(null) no-op ────────────────────────── + + /// + /// 测试目的:Add(null) 应为空操作,Count 保持不变,不抛异常。 + /// + [Fact] + public void Add_Null_ShouldBeNoOp() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + Should.NotThrow(() => col.Add(null)); + + // Assert + col.Count.ShouldBe(0); + col.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:AddRange(null) 应为空操作,不抛异常,Count 保持不变。 + /// + [Fact] + public void AddRange_Null_ShouldBeNoOp() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + Should.NotThrow(() => col.AddRange(null)); + + // Assert + col.Count.ShouldBe(0); + } + + // ── IEnumerable ──────────────────────────────────────────────── + + /// + /// 测试目的:集合应支持 foreach 遍历并返回所有 ValidationResult。 + /// + [Fact] + public void GetEnumerator_ShouldIterateAllResults() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("E1")); + col.Add(new ValidationResult("E2")); + + // Act + var list = col.ToList(); + + // Assert + list.Count.ShouldBe(2); + list[0].ErrorMessage.ShouldBe("E1"); + list[1].ErrorMessage.ShouldBe("E2"); + } +} diff --git a/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionTest.cs b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionTest.cs new file mode 100644 index 00000000..cd64bc9e --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/Bing/Validation/ValidationResultCollectionTest.cs @@ -0,0 +1,395 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +/// +/// 单元测试 +/// +public class ValidationResultCollectionTest +{ + // ═══════════════════════════════════════════════════════════ + // 构造函数 - 默认 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:默认构造后集合为空,IsValid 应为 true,Count = 0。 + /// + [Fact] + public void Default_ShouldBeEmptyAndValid() + { + // Arrange & Act + var collection = new ValidationResultCollection(); + + // Assert + collection.Count.ShouldBe(0); + collection.IsValid.ShouldBeTrue(); + } + + /// + /// 测试目的:静态 Success 实例应为空集合,IsValid = true。 + /// + [Fact] + public void Success_ShouldBeValidAndEmpty() + { + // Assert + ValidationResultCollection.Success.IsValid.ShouldBeTrue(); + ValidationResultCollection.Success.Count.ShouldBe(0); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数 - string + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用非空错误字符串构造时,Count = 1,IsValid = false。 + /// + [Fact] + public void Ctor_WithErrorString_ShouldContainOneResult() + { + // Arrange & Act + var collection = new ValidationResultCollection("姓名不能为空"); + + // Assert + collection.Count.ShouldBe(1); + collection.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:使用 null 或空白字符串构造时,集合应为空,IsValid = true。 + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Ctor_WithNullOrWhiteSpace_ShouldBeEmpty(string message) + { + // Arrange & Act + var collection = new ValidationResultCollection(message); + + // Assert + collection.Count.ShouldBe(0); + collection.IsValid.ShouldBeTrue(); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数 - ValidationResult + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用 ValidationResult 构造时,Count = 1,IsValid = false。 + /// + [Fact] + public void Ctor_WithValidationResult_ShouldContainOneResult() + { + // Arrange + var result = new ValidationResult("年龄超出范围", new[] { "Age" }); + + // Act + var collection = new ValidationResultCollection(result); + + // Assert + collection.Count.ShouldBe(1); + collection.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:使用 null ValidationResult 构造时应抛出 ArgumentNullException。 + /// + [Fact] + public void Ctor_WithNullValidationResult_ShouldThrow() + { + // Arrange & Act & Assert + Should.Throw(() => new ValidationResultCollection((ValidationResult)null)); + } + + // ═══════════════════════════════════════════════════════════ + // 构造函数 - IEnumerable + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用多条 ValidationResult 集合构造时,Count 应等于集合长度。 + /// + [Fact] + public void Ctor_WithMultipleResults_ShouldSetCountCorrectly() + { + // Arrange + var results = new[] + { + new ValidationResult("错误1", new[] { "Field1" }), + new ValidationResult("错误2", new[] { "Field2" }), + new ValidationResult("错误3", new[] { "Field3" }) + }; + + // Act + var collection = new ValidationResultCollection(results); + + // Assert + collection.Count.ShouldBe(3); + collection.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:使用 null 集合构造时应抛出 ArgumentNullException。 + /// + [Fact] + public void Ctor_WithNullEnumerable_ShouldThrow() + { + // Arrange & Act & Assert + Should.Throw(() => + new ValidationResultCollection((IEnumerable)null)); + } + + // ═══════════════════════════════════════════════════════════ + // Add / AddRange + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:Add(ValidationResult) 应使 Count 增加 1,IsValid 变为 false。 + /// + [Fact] + public void Add_ValidResult_ShouldIncreaseCount() + { + // Arrange + var collection = new ValidationResultCollection(); + + // Act + collection.Add(new ValidationResult("错误消息")); + + // Assert + collection.Count.ShouldBe(1); + collection.IsValid.ShouldBeFalse(); + } + + /// + /// 测试目的:Add(null) 应被忽略,不改变 Count。 + /// + [Fact] + public void Add_Null_ShouldBeIgnored() + { + // Arrange + var collection = new ValidationResultCollection(); + + // Act + collection.Add(null); + + // Assert + collection.Count.ShouldBe(0); + } + + /// + /// 测试目的:AddRange(null) 应被忽略,不抛异常,Count 保持不变。 + /// + [Fact] + public void AddRange_Null_ShouldBeIgnoredWithoutThrowing() + { + // Arrange + var collection = new ValidationResultCollection(); + + // Act & Assert + Should.NotThrow(() => collection.AddRange(null)); + collection.Count.ShouldBe(0); + } + + /// + /// 测试目的:AddRange 多条结果后,Count 应等于累计添加数量。 + /// + [Fact] + public void AddRange_MultipleResults_ShouldAccumulateCount() + { + // Arrange + var collection = new ValidationResultCollection(); + var results = new[] { new ValidationResult("E1"), new ValidationResult("E2") }; + + // Act + collection.AddRange(results); + + // Assert + collection.Count.ShouldBe(2); + } + + // ═══════════════════════════════════════════════════════════ + // IsValid / Count + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:初始为空时 IsValid = true;添加一条错误后变为 false(边界转换)。 + /// + [Fact] + public void IsValid_TransitionFromTrueToFalse_WhenFirstErrorAdded() + { + // Arrange + var collection = new ValidationResultCollection(); + collection.IsValid.ShouldBeTrue(); + + // Act + collection.Add(new ValidationResult("首个错误")); + + // Assert + collection.IsValid.ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // ToMessage + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:空集合 ToMessage 应包含"未发现验证错误"提示。 + /// + [Fact] + public void ToMessage_WhenValid_ShouldContainNoErrorText() + { + // Arrange + var collection = new ValidationResultCollection(); + + // Act + var msg = collection.ToMessage(); + + // Assert + msg.ShouldContain("未发现验证错误"); + } + + /// + /// 测试目的:有 1 条错误时 ToMessage 应包含"发现1个验证错误"。 + /// + [Fact] + public void ToMessage_WithOneError_ShouldContainSingleErrorText() + { + // Arrange + var collection = new ValidationResultCollection("错误"); + + // Act + var msg = collection.ToMessage(); + + // Assert + msg.ShouldContain("发现1个验证错误"); + } + + /// + /// 测试目的:有多条错误时 ToMessage 应包含错误总数。 + /// + [Fact] + public void ToMessage_WithMultipleErrors_ShouldContainErrorCount() + { + // Arrange + var collection = new ValidationResultCollection(new[] + { + new ValidationResult("E1"), + new ValidationResult("E2"), + new ValidationResult("E3") + }); + + // Act + var msg = collection.ToMessage(); + + // Assert + msg.ShouldContain("发现3个验证错误"); + } + + // ═══════════════════════════════════════════════════════════ + // ToValidationMessages + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:有效集合(空)调用 ToValidationMessages 应返回空枚举。 + /// + [Fact] + public void ToValidationMessages_WhenValid_ShouldReturnEmpty() + { + // Arrange + var collection = new ValidationResultCollection(); + + // Act + var messages = collection.ToValidationMessages().ToList(); + + // Assert + messages.Count.ShouldBe(0); + } + + /// + /// 测试目的:有错误时 ToValidationMessages 应返回对应数量的消息字符串。 + /// + [Fact] + public void ToValidationMessages_WithErrors_ShouldReturnAllMessages() + { + // Arrange + var collection = new ValidationResultCollection(new[] + { + new ValidationResult("姓名不能为空", new[] { "Name" }), + new ValidationResult("年龄超出范围", new[] { "Age" }) + }); + + // Act + var messages = collection.ToValidationMessages().ToList(); + + // Assert + messages.Count.ShouldBe(2); + messages.ShouldContain(m => m.Contains("姓名不能为空")); + messages.ShouldContain(m => m.Contains("年龄超出范围")); + } + + // ═══════════════════════════════════════════════════════════ + // 枚举 / 迭代 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:ValidationResultCollection 应支持 foreach 迭代,遍历出所有错误结果。 + /// + [Fact] + public void Enumeration_ShouldYieldAllResults() + { + // Arrange + var collection = new ValidationResultCollection(new[] + { + new ValidationResult("E1"), + new ValidationResult("E2") + }); + + // Act + var items = collection.ToList(); + + // Assert + items.Count.ShouldBe(2); + items[0].ErrorMessage.ShouldBe("E1"); + items[1].ErrorMessage.ShouldBe("E2"); + } + + // ═══════════════════════════════════════════════════════════ + // 复制构造 + // ═══════════════════════════════════════════════════════════ + + /// + /// 测试目的:使用 ValidationResultCollection 复制构造时,Count 和 ErrorCode 应与原始一致。 + /// + [Fact] + public void CopyCtor_ShouldPreserveCountAndErrorCode() + { + // Arrange + var original = new ValidationResultCollection(new[] + { + new ValidationResult("错误A"), + new ValidationResult("错误B") + }); + original.ErrorCode = 2001; + + // Act + var copy = new ValidationResultCollection(original); + + // Assert + copy.Count.ShouldBe(2); + copy.ErrorCode.ShouldBe(2001); + } + + /// + /// 测试目的:复制构造传 null 时应抛出 ArgumentNullException。 + /// + [Fact] + public void CopyCtor_WithNull_ShouldThrow() + { + // Arrange & Act & Assert + Should.Throw(() => + new ValidationResultCollection((ValidationResultCollection)null)); + } +} diff --git a/framework/tests/Bing.Validation.Tests/ValidationTests.cs b/framework/tests/Bing.Validation.Tests/ValidationTests.cs new file mode 100644 index 00000000..c0e52fa7 --- /dev/null +++ b/framework/tests/Bing.Validation.Tests/ValidationTests.cs @@ -0,0 +1,340 @@ +using System.ComponentModel.DataAnnotations; +using Bing.Validation; +using Shouldly; +using Xunit; + +namespace Bing.Validation.Tests; + +// ─── 测试用模型 ──────────────────────────────────────────────────── + +internal class ValidModel +{ + [Required(ErrorMessage = "姓名不能为空")] + [StringLength(20, ErrorMessage = "姓名不超过20个字符")] + public string Name { get; set; } = "测试"; + + [Range(0, 150, ErrorMessage = "年龄必须在 0~150 之间")] + public int Age { get; set; } = 25; +} + +internal class InvalidModel +{ + [Required(ErrorMessage = "姓名不能为空")] + public string Name { get; set; } // null → 验证失败 + + [Range(1, 10, ErrorMessage = "数值必须在 1~10 之间")] + public int Value { get; set; } = 100; // 超出范围 +} + +internal class MultiErrorModel +{ + [Required(ErrorMessage = "字段A不能为空")] + public string FieldA { get; set; } + + [Required(ErrorMessage = "字段B不能为空")] + public string FieldB { get; set; } +} + +/// +/// 单元测试 +/// +public class ValidationResultCollectionTest +{ + // ── IsValid ──────────────────────────────────────────────────── + + /// + /// 测试目的:空集合应报告有效(无错误)。 + /// + [Fact] + public void IsValid_WhenEmpty_ShouldBeTrue() + { + // Arrange & Act + var col = new ValidationResultCollection(); + + // Assert + col.IsValid.ShouldBeTrue(); + col.Count.ShouldBe(0); + } + + /// + /// 测试目的:添加 ValidationResult 后应报告无效。 + /// + [Fact] + public void IsValid_AfterAddError_ShouldBeFalse() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + col.Add(new ValidationResult("字段错误")); + + // Assert + col.IsValid.ShouldBeFalse(); + col.Count.ShouldBe(1); + } + + // ── 构造函数 ────────────────────────────────────────────────── + + /// + /// 测试目的:从字符串构造时,集合应包含对应的单条错误消息。 + /// + [Fact] + public void Constructor_WithString_ShouldContainSingleError() + { + // Arrange & Act + var col = new ValidationResultCollection("初始错误消息"); + + // Assert + col.IsValid.ShouldBeFalse(); + col.Count.ShouldBe(1); + col.First().ErrorMessage.ShouldBe("初始错误消息"); + } + + /// + /// 测试目的:从空字符串构造时,集合应为空(有效)。 + /// + [Fact] + public void Constructor_WithEmptyString_ShouldBeValid() + { + // Arrange & Act + var col = new ValidationResultCollection(string.Empty); + + // Assert + col.IsValid.ShouldBeTrue(); + } + + // ── AddRange ────────────────────────────────────────────────── + + /// + /// 测试目的:AddRange 应一次性加入多条错误,Count 正确反映总数。 + /// + [Fact] + public void AddRange_ShouldAddAllErrors() + { + // Arrange + var col = new ValidationResultCollection(); + var errors = new[] + { + new ValidationResult("错误1"), + new ValidationResult("错误2"), + new ValidationResult("错误3"), + }; + + // Act + col.AddRange(errors); + + // Assert + col.Count.ShouldBe(3); + col.IsValid.ShouldBeFalse(); + } + + // ── GetErrors ───────────────────────────────────────────────── + + /// + /// 测试目的:有效时 GetErrors 应返回空序列,不为 null。 + /// + [Fact] + public void GetErrors_WhenValid_ShouldReturnEmpty() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var errors = col.GetErrors(); + + // Assert + errors.ShouldNotBeNull(); + errors.ShouldBeEmpty(); + } + + /// + /// 测试目的:无效时 GetErrors 应返回所有错误的字符串描述。 + /// + [Fact] + public void GetErrors_WhenInvalid_ShouldReturnErrorDescriptions() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("字段A错误", new[] { "FieldA" })); + + // Act + var errors = col.GetErrors().ToList(); + + // Assert + errors.ShouldNotBeEmpty(); + errors[0].ShouldContain("FieldA"); + } + + // ── ToString ────────────────────────────────────────────────── + + /// + /// 测试目的:有效时 ToString 应返回空字符串(或者不含错误描述)。 + /// + [Fact] + public void ToString_WhenValid_ShouldReturnEmptyOrSpecialString() + { + // Arrange + var col = new ValidationResultCollection(); + + // Act + var str = col.ToString(); + + // Assert — 有效时不应包含错误描述内容 + str.ShouldNotContain("验证错误"); + } + + /// + /// 测试目的:含多个错误时 ToString 应包含"验证错误"关键字(中文提示文本)。 + /// + [Fact] + public void ToString_WhenMultipleErrors_ShouldContainErrorCountKeyword() + { + // Arrange + var col = new ValidationResultCollection(); + col.Add(new ValidationResult("错误A")); + col.Add(new ValidationResult("错误B")); + + // Act + var str = col.ToString(); + + // Assert + str.ShouldContain("验证错误"); + } +} + +/// +/// 单元测试 +/// +public class DataAnnotationValidationTest +{ + // ── 有效模型 ─────────────────────────────────────────────────── + + /// + /// 测试目的:满足所有 DataAnnotation 约束的模型应验证通过。 + /// + [Fact] + public void Validate_WithValidModel_ShouldReturnValidResult() + { + // Arrange + var model = new ValidModel { Name = "张三", Age = 30 }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeTrue(); + } + + // ── 无效模型 ─────────────────────────────────────────────────── + + /// + /// 测试目的:违反 [Required] 和 [Range] 约束的模型应返回相应错误信息。 + /// + [Fact] + public void Validate_WithInvalidModel_ShouldReturnErrors() + { + // Arrange + var model = new InvalidModel { Name = null, Value = 100 }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.IsValid.ShouldBeFalse(); + result.Count.ShouldBeGreaterThanOrEqualTo(1); + } + + /// + /// 测试目的:多字段违规时,所有错误都应被收集(非 fail-fast)。 + /// + [Fact] + public void Validate_WithMultipleViolations_ShouldCollectAllErrors() + { + // Arrange + var model = new MultiErrorModel { FieldA = null, FieldB = null }; + + // Act + var result = DataAnnotationValidation.Validate(model); + + // Assert + result.Count.ShouldBe(2); + } + + // ── null 边界 ───────────────────────────────────────────────── + + /// + /// 测试目的:传入 null 时应抛出 ArgumentNullException,而非 NullReferenceException。 + /// + [Fact] + public void Validate_WhenTargetIsNull_ShouldThrowArgumentNullException() + { + // Act & Assert + Should.Throw(() => DataAnnotationValidation.Validate(null)); + } +} + +/// +/// 单元测试 +/// +public class ValidationHandlerTest +{ + // ── ThrowHandler ─────────────────────────────────────────────── + + /// + /// 测试目的:ThrowHandler 在验证失败时应抛出 Warning 异常。 + /// + [Fact] + public void ThrowHandler_WhenInvalid_ShouldThrowWarning() + { + // Arrange + var handler = new ThrowHandler(); + var result = new ValidationResultCollection("验证失败:字段X不合法"); + + // Act & Assert + Should.Throw(() => handler.Handle(result)); + } + + /// + /// 测试目的:ThrowHandler 在验证通过时不应抛出任何异常。 + /// + [Fact] + public void ThrowHandler_WhenValid_ShouldNotThrow() + { + // Arrange + var handler = new ThrowHandler(); + var result = new ValidationResultCollection(); + + // Act & Assert + Should.NotThrow(() => handler.Handle(result)); + } + + // ── NothingHandler ───────────────────────────────────────────── + + /// + /// 测试目的:NothingHandler 无论验证结果如何都不应抛出任何异常(空操作)。 + /// + [Fact] + public void NothingHandler_WhenInvalid_ShouldNotThrow() + { + // Arrange + var handler = new NothingHandler(); + var result = new ValidationResultCollection("验证失败"); + + // Act & Assert + Should.NotThrow(() => handler.Handle(result)); + } + + /// + /// 测试目的:NothingHandler 对有效结果也不抛异常(确认接口完整性)。 + /// + [Fact] + public void NothingHandler_WhenValid_ShouldNotThrow() + { + // Arrange + var handler = new NothingHandler(); + var result = new ValidationResultCollection(); + + // Act & Assert + Should.NotThrow(() => handler.Handle(result)); + } +}