From 40d7b23f557ddeebd95d492ad179225e2599bef5 Mon Sep 17 00:00:00 2001 From: marsonshine Date: Sat, 27 Jun 2026 10:41:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84NetworkShareA?= =?UTF-8?q?ccessor=E4=BB=A5=E6=8F=90=E5=8D=87=E5=8F=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 Platform 抽象,解耦平台依赖,所有平台相关操作(目录检测、连接、断开、Sleep)均通过委托注入,便于单元测试 mock。新增 Execute/ExecuteAsync 静态方法,支持同步/异步委托自动管理连接。构造函数与连接流程支持注入 Platform,重试与断开流程优化。测试代码全面重构,移除真实网络依赖,覆盖所有关键分支和资源释放场景。代码风格与注释进一步规范,符合 Clean Architecture 分层要求。 --- .../FileSystem/NetworkShareAccessor.cs | 247 +++++---- .../FileSystem/NetworkShareAccessorTests.cs | 468 ++++++++---------- 2 files changed, 363 insertions(+), 352 deletions(-) diff --git a/src/MS.Microservice.Infrastructure/FileSystem/NetworkShareAccessor.cs b/src/MS.Microservice.Infrastructure/FileSystem/NetworkShareAccessor.cs index 356113e..06abd52 100644 --- a/src/MS.Microservice.Infrastructure/FileSystem/NetworkShareAccessor.cs +++ b/src/MS.Microservice.Infrastructure/FileSystem/NetworkShareAccessor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -9,28 +9,33 @@ namespace MS.Microservice.Infrastructure.FileSystem; - /// /// 网络共享连接访问器。 -/// +/// /// 策略: /// 1. 先检查共享是否已经可访问(利用当前用户会话的已有连接),如果可访问则跳过连接。 /// 2. 如果不可访问,先尽力断开已有连接,再用 WNetUseConnection 建立临时连接。 /// 3. 处理 1219 错误:重试断开 + 等待后重连。 -/// +/// /// 使用方式: /// -/// using var accessor = new NetworkShareAccessor(@"\\server\share", "user", "pass"); -/// // 在此作用域内可以访问共享文件夹中的文件 -/// var exists = File.Exists(@"\\server\share\somefile.txt"); +/// var exists = NetworkShareAccessor.Execute( +/// @"\\server\share", +/// "user", +/// "pass", +/// () => File.Exists(@"\\server\share\somefile.txt")); /// +/// +/// 需要跨多步访问时,仍可使用构造函数配合 using 控制作用域。 /// public sealed partial class NetworkShareAccessor : IDisposable { private static readonly Lock ConnectLock = new(); + private static readonly Platform DefaultPlatform = new(); private readonly string _networkName; - private readonly bool _didConnect; // 是否实际建立了新连接(需要 Dispose 断开) + private readonly Platform _platform; + private readonly bool _didConnect; private bool _disposed; /// @@ -40,6 +45,62 @@ public sealed partial class NetworkShareAccessor : IDisposable /// internal bool DidEstablishConnection => _didConnect; + /// + /// 在访问共享路径前临时建立连接,并在委托返回后自动断开。 + /// + public static void Execute(string networkName, string userName, string password, Action action) + => Execute(networkName, userName, password, action, platform: null); + + internal static void Execute(string networkName, string userName, string password, Action action, Platform? platform) + { + ArgumentNullException.ThrowIfNull(action); + + using var accessor = new NetworkShareAccessor(networkName, userName, password, forceConnect: false, platform); + action(); + } + + /// + /// 在访问共享路径前临时建立连接,并返回委托结果。 + /// + public static TResult Execute(string networkName, string userName, string password, Func action) + => Execute(networkName, userName, password, action, platform: null); + + internal static TResult Execute(string networkName, string userName, string password, Func action, Platform? platform) + { + ArgumentNullException.ThrowIfNull(action); + + using var accessor = new NetworkShareAccessor(networkName, userName, password, forceConnect: false, platform); + return action(); + } + + /// + /// 在访问共享路径前临时建立连接,并等待异步委托完成后自动断开。 + /// + public static Task ExecuteAsync(string networkName, string userName, string password, Func action) + => ExecuteAsync(networkName, userName, password, action, platform: null); + + internal static async Task ExecuteAsync(string networkName, string userName, string password, Func action, Platform? platform) + { + ArgumentNullException.ThrowIfNull(action); + + using var accessor = new NetworkShareAccessor(networkName, userName, password, forceConnect: false, platform); + await action().ConfigureAwait(false); + } + + /// + /// 在访问共享路径前临时建立连接,并返回异步委托结果。 + /// + public static Task ExecuteAsync(string networkName, string userName, string password, Func> action) + => ExecuteAsync(networkName, userName, password, action, platform: null); + + internal static async Task ExecuteAsync(string networkName, string userName, string password, Func> action, Platform? platform) + { + ArgumentNullException.ThrowIfNull(action); + + using var accessor = new NetworkShareAccessor(networkName, userName, password, forceConnect: false, platform); + return await action().ConfigureAwait(false); + } + /// /// 连接到指定的网络共享。 /// @@ -48,7 +109,7 @@ public sealed partial class NetworkShareAccessor : IDisposable /// 密码 /// 连接失败时抛出,NativeErrorCode 为 Win32 错误码 public NetworkShareAccessor(string networkName, string userName, string password) - : this(networkName, userName, password, forceConnect: false) + : this(networkName, userName, password, forceConnect: false, platform: null) { } @@ -56,17 +117,23 @@ public NetworkShareAccessor(string networkName, string userName, string password /// 强制走真实连接路径,跳过"共享是否已可访问"的快速检查。 /// internal NetworkShareAccessor(string networkName, string userName, string password, bool forceConnect) + : this(networkName, userName, password, forceConnect, platform: null) + { + } + + internal NetworkShareAccessor(string networkName, string userName, string password, bool forceConnect, Platform? platform) { ArgumentException.ThrowIfNullOrWhiteSpace(networkName); ArgumentException.ThrowIfNullOrWhiteSpace(userName); _networkName = networkName; + _platform = platform ?? DefaultPlatform; lock (ConnectLock) { // 策略 1:检查共享是否已经可访问(利用当前用户会话的已有连接) // forceConnect=true 时跳过此检查,强制走 WNetUseConnection 路径 - if (!forceConnect && IsShareAlreadyAccessible(networkName)) + if (!forceConnect && IsShareAlreadyAccessible(networkName, _platform.DirectoryExists)) { _didConnect = false; return; @@ -97,8 +164,7 @@ public void Dispose() lock (ConnectLock) { - const int connectUpdateProfile = 0x00000001; - NativeMethods.WNetCancelConnection2(_networkName, connectUpdateProfile, force: true); + _platform.CancelConnection(_networkName, ConnectUpdateProfile, force: true); } } @@ -107,10 +173,13 @@ public void Dispose() /// 如果可以访问,说明当前用户会话已经有有效连接,无需重复建立。 /// internal static bool IsShareAlreadyAccessible(string networkName) + => IsShareAlreadyAccessible(networkName, static path => Directory.Exists(path)); + + private static bool IsShareAlreadyAccessible(string networkName, Func directoryExists) { try { - return Directory.Exists(networkName); + return directoryExists(networkName); } catch { @@ -122,7 +191,7 @@ internal static bool IsShareAlreadyAccessible(string networkName) /// 尝试使用 WNetUseConnection 建立连接。 /// 如果遇到 1219 错误,会进行重试(再次断开 + 等待 + 重连)。 /// - private static void TryConnect(string networkName, string userName, string password) + private void TryConnect(string networkName, string userName, string password) { const int maxRetries = 2; int lastError = 0; @@ -133,60 +202,21 @@ private static void TryConnect(string networkName, string userName, string passw { // 重试前:更激进地断开,并延长等待时间 ForceDisconnectServerConnections(networkName); - Thread.Sleep(500 * attempt); // 递增等待:500ms, 1000ms + _platform.Sleep(TimeSpan.FromMilliseconds(500 * attempt)); } - IntPtr remoteNamePtr = IntPtr.Zero; - try - { - remoteNamePtr = Marshal.StringToHGlobalUni(networkName); - - var netResource = new NETRESOURCE - { - dwType = RESOURCETYPE_DISK, - lpRemoteName = remoteNamePtr - }; - - int bufferSize = 256; - var accessName = new StringBuilder(bufferSize); - - // CONNECT_TEMPORARY (0x00000004):不持久化 - // CONNECT_REDIRECT (0x00000080):允许重定向处理凭据冲突 - const int flags = 0x00000004 | 0x00000080; - - int result = NativeMethods.WNetUseConnection( - IntPtr.Zero, - ref netResource, - password, - userName, - flags, - accessName, - ref bufferSize, - out int connectResult); - - // ⚠️ 关键:WNetUseConnection 成功时 result == 0, - // connectResult 是附加信息(可能是 0、CONNECT_LOCALDRIVE=256、CONNECT_REDIRECT=128), - // 这些全都表示成功,不是错误码! - if (result == 0) - return; // 成功 - - // result != 0 → 连接失败 - // 如果 result == ERROR_EXTENDED_ERROR (1208),connectResult 包含真正的错误码 - int errorCode = result == ERROR_EXTENDED_ERROR ? connectResult : result; - lastError = errorCode; - - // 只有 1219 才重试,其他错误直接抛出 - if (errorCode != 1219) - { - throw new Win32Exception( - errorCode, - $"连接共享目录失败:{networkName},Win32Error={errorCode},用户名={userName}"); - } - } - finally + int errorCode = _platform.UseConnection(networkName, userName, password); + if (errorCode == 0) + return; + + lastError = errorCode; + + // 只有 1219 才重试,其他错误直接抛出 + if (errorCode != 1219) { - if (remoteNamePtr != IntPtr.Zero) - Marshal.FreeHGlobal(remoteNamePtr); + throw new Win32Exception( + errorCode, + $"连接共享目录失败:{networkName},Win32Error={errorCode},用户名={userName}"); } } @@ -202,46 +232,32 @@ private static void TryConnect(string networkName, string userName, string passw /// 会先断开具体共享路径,再断开服务器级别的连接, /// 并进行多次重试。 /// - private static void ForceDisconnectServerConnections(string networkName) + private void ForceDisconnectServerConnections(string networkName) { // 提取服务器路径(\\server\share → \\server) string? serverPath = ExtractServerPath(networkName); for (int attempt = 0; attempt < 3; attempt++) { - bool allDisconnected = true; - - // 尝试多种方式断开 - // 方式1:带 CONNECT_UPDATE_PROFILE 标志 - if (!NativeMethods.WNetCancelConnection2(networkName, 0x00000001, force: true)) - { - // 方式2:不带标志,纯强制断开 - if (!NativeMethods.WNetCancelConnection2(networkName, 0, force: true)) - { - allDisconnected = false; - } - } + bool allDisconnected = TryDisconnect(networkName); - // 断开服务器级别连接 if (serverPath != null && !string.Equals(serverPath, networkName, StringComparison.OrdinalIgnoreCase)) { - if (!NativeMethods.WNetCancelConnection2(serverPath, 0x00000001, force: true)) - { - if (!NativeMethods.WNetCancelConnection2(serverPath, 0, force: true)) - { - allDisconnected = false; - } - } + allDisconnected &= TryDisconnect(serverPath); } if (allDisconnected) break; - Thread.Sleep(200); + _platform.Sleep(TimeSpan.FromMilliseconds(200)); } } + private bool TryDisconnect(string path) + => _platform.CancelConnection(path, ConnectUpdateProfile, force: true) + || _platform.CancelConnection(path, 0, force: true); + /// /// 从网络路径中提取服务器路径。 /// \\192.168.1.2\share folder → \\192.168.1.2 @@ -252,6 +268,58 @@ private static void ForceDisconnectServerConnections(string networkName) return match.Success ? match.Value : null; } + // ponytail: keep the test seam as delegates inside Infrastructure; add a wider abstraction only if another caller needs to swap the transport. + internal sealed class Platform + { + public Func DirectoryExists { get; init; } = static networkName => Directory.Exists(networkName); + public UseConnectionCallback UseConnection { get; init; } = UseConnectionCore; + public CancelConnectionCallback CancelConnection { get; init; } = NativeMethods.WNetCancelConnection2; + public Action Sleep { get; init; } = static delay => Thread.Sleep(delay); + } + + internal delegate int UseConnectionCallback(string networkName, string userName, string password); + + internal delegate bool CancelConnectionCallback(string path, int flags, bool force); + + private static int UseConnectionCore(string networkName, string userName, string password) + { + IntPtr remoteNamePtr = IntPtr.Zero; + + try + { + remoteNamePtr = Marshal.StringToHGlobalUni(networkName); + + var netResource = new NETRESOURCE + { + dwType = RESOURCETYPE_DISK, + lpRemoteName = remoteNamePtr + }; + + int bufferSize = 256; + var accessName = new StringBuilder(bufferSize); + + int result = NativeMethods.WNetUseConnection( + IntPtr.Zero, + ref netResource, + password, + userName, + ConnectTemporary | ConnectRedirect, + accessName, + ref bufferSize, + out int connectResult); + + if (result == 0) + return 0; + + return result == ERROR_EXTENDED_ERROR ? connectResult : result; + } + finally + { + if (remoteNamePtr != IntPtr.Zero) + Marshal.FreeHGlobal(remoteNamePtr); + } + } + // ---- P/Invoke 定义 ---- [StructLayout(LayoutKind.Sequential)] @@ -294,5 +362,8 @@ public static partial bool WNetCancelConnection2( } private const int RESOURCETYPE_DISK = 1; + private const int ConnectUpdateProfile = 0x00000001; + private const int ConnectTemporary = 0x00000004; + private const int ConnectRedirect = 0x00000080; private const int ERROR_EXTENDED_ERROR = 1208; // WNet 扩展错误,connectResult 包含实际错误码 -} \ No newline at end of file +} diff --git a/test/MS.Microservice.Infrastructure.Tests/FileSystem/NetworkShareAccessorTests.cs b/test/MS.Microservice.Infrastructure.Tests/FileSystem/NetworkShareAccessorTests.cs index 64f6f39..52a1b79 100644 --- a/test/MS.Microservice.Infrastructure.Tests/FileSystem/NetworkShareAccessorTests.cs +++ b/test/MS.Microservice.Infrastructure.Tests/FileSystem/NetworkShareAccessorTests.cs @@ -4,356 +4,296 @@ namespace MS.Microservice.Infrastructure.Tests.FileSystem; -public sealed class NetworkShareAccessorTests : IDisposable +public sealed class NetworkShareAccessorTests { - // 测试用的共享路径和凭据 - private const string TestSharePath = @"\\192.168.1.2\工作目录"; - private const string TestUserName = @"shuai.mao@kingsunsoft.com"; - private const string TestPassword = "Marsonshine123"; - - // 用于测试 WNetUseConnection 路径的路径(不存在的共享,确保不会走 fast-path) - private const string NonExistentSharePath = @"\\192.168.1.2\nonexistentshare_xyz"; - - private readonly List _activeAccessors = new(); - - public void Dispose() - { - foreach (var accessor in _activeAccessors) - { - try { accessor.Dispose(); } - catch { /* 忽略 dispose 异常 */ } - } - _activeAccessors.Clear(); - } - - // ================================================================ - // 参数校验 - // ================================================================ + private const string NetworkPath = @"\\server\share"; + private const string ServerPath = @"\\server"; + private const string UserName = @"DOMAIN\user"; + private const string Password = "pass"; [Fact] public void Constructor_NullNetworkName_ThrowsArgumentNullException() { Assert.Throws(() => - new NetworkShareAccessor(null!, TestUserName, TestPassword)); + new NetworkShareAccessor(null!, UserName, Password)); } [Fact] public void Constructor_EmptyNetworkName_ThrowsArgumentException() { Assert.Throws(() => - new NetworkShareAccessor("", TestUserName, TestPassword)); + new NetworkShareAccessor("", UserName, Password)); } [Fact] public void Constructor_NullUserName_ThrowsArgumentNullException() { Assert.Throws(() => - new NetworkShareAccessor(TestSharePath, null!, TestPassword)); + new NetworkShareAccessor(NetworkPath, null!, Password)); } [Fact] public void Constructor_EmptyUserName_ThrowsArgumentException() { Assert.Throws(() => - new NetworkShareAccessor(TestSharePath, "", TestPassword)); + new NetworkShareAccessor(NetworkPath, "", Password)); } - // ================================================================ - // ExtractServerPath - // ================================================================ - - [Theory] - [InlineData(@"\\192.168.1.2\工作目录", @"\\192.168.1.2")] - [InlineData(@"\\server\share\subdir", @"\\server")] - [InlineData(@"\\localhost\c$", @"\\localhost")] - [InlineData(@"\\10.0.0.1\data", @"\\10.0.0.1")] - [InlineData(@"\\SERVER", @"\\SERVER")] - [InlineData(@"C:\local\path", null)] - [InlineData(@"not-a-network-path", null)] - public void ExtractServerPath_VariousPaths_ReturnsExpected(string input, string? expected) + [Fact] + public void Constructor_AccessibleShare_UsesFastPath() { - var result = NetworkShareAccessor.ExtractServerPath(input); - Assert.Equal(expected, result); - } + var recorder = new RecordingPlatform + { + DirectoryExists = _ => true + }; - // ================================================================ - // IsShareAlreadyAccessible 检查 - // ================================================================ + using var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build()); + + Assert.False(accessor.DidEstablishConnection); + Assert.Empty(recorder.ConnectCalls); + Assert.Empty(recorder.CancelCalls); + } [Fact] - public void IsShareAlreadyAccessible_ExistingShare_ReturnsTrue() + public void Constructor_ForceConnect_SkipsAccessibilityCheck() { - // 开发机上已有连接,应返回 true - bool accessible = NetworkShareAccessor.IsShareAlreadyAccessible(TestSharePath); - // 不强制断言(取决于环境),但记录行为供调试 - // 如果环境可访问,则为 true + int directoryChecks = 0; + var recorder = new RecordingPlatform + { + DirectoryExists = _ => + { + directoryChecks++; + return true; + } + }; + + using var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: true, recorder.Build()); + + Assert.True(accessor.DidEstablishConnection); + Assert.Equal(0, directoryChecks); + Assert.Single(recorder.ConnectCalls); } [Fact] - public void IsShareAlreadyAccessible_NonExistentShare_ReturnsFalse() + public void Constructor_DirectoryCheckThrows_FallsBackToConnection() { - bool accessible = NetworkShareAccessor.IsShareAlreadyAccessible(NonExistentSharePath); - Assert.False(accessible, "不存在的共享应返回 false"); + var recorder = new RecordingPlatform + { + DirectoryExists = _ => throw new IOException("boom") + }; + + using var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build()); + + Assert.True(accessor.DidEstablishConnection); + Assert.Single(recorder.ConnectCalls); } [Fact] - public void IsShareAlreadyAccessible_LocalPath_ReturnsFalse() + public void Constructor_InaccessibleShare_DisconnectsShareAndServerBeforeConnecting() { - bool accessible = NetworkShareAccessor.IsShareAlreadyAccessible(@"C:\Windows"); - // 本地路径虽然存在,但不是网络共享 — 不过 Directory.Exists 返回 true - // 这里只验证方法不会抛异常 + var recorder = new RecordingPlatform(); + var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build()); + + accessor.Dispose(); + + Assert.True(accessor.DidEstablishConnection); + Assert.Single(recorder.ConnectCalls); + Assert.Collection( + recorder.CancelCalls, + call => Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), call), + call => Assert.Equal(new CancelCall(ServerPath, 0x00000001, true), call), + call => Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), call)); } - // ================================================================ - // 🔑 核心测试:Fast-path vs 真实连接路径 (WNetUseConnection) - // - // 背景:Win32 Error 1219 是"同一用户会话不能用不同凭据连接同一服务器"。 - // 如果当前 Windows 用户会话(如资源管理器)已有到目标服务器的连接, - // 则任何 WNet API(包括 WNetUseConnection)都无法用不同凭据覆盖它。 - // - // 以下测试区分两种场景: - // A) 当前用户对目标服务器已有连接 → fast-path(无需新连接) - // B) 当前用户对目标服务器无连接 → WNetUseConnection 路径 - // - // 生产环境(IIS AppPool 身份)通常属于场景 B,WNetUseConnection 正常工作。 - // ================================================================ - - /// - /// 验证:public 构造函数在共享已可访问时走 fast-path,不建立新连接。 - /// [Fact] - public void PublicConstructor_AlreadyAccessibleShare_UsesFastPath() + public void Constructor_DisconnectFallback_UsesNonProfileFlagWhenNeeded() { - try + bool allowProfileDisconnect = false; + var recorder = new RecordingPlatform { - using var accessor = new NetworkShareAccessor(TestSharePath, TestUserName, TestPassword); - _activeAccessors.Add(accessor); + CancelConnection = (_, flags, _) => flags == 0 || allowProfileDisconnect + }; - Assert.False(accessor.DidEstablishConnection, - "共享已可访问时应走 fast-path,不应建立新连接"); - } - catch (Win32Exception) { /* 环境不可用,忽略 */ } + var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build()); + + Assert.Collection( + recorder.CancelCalls.Take(4), + call => Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), call), + call => Assert.Equal(new CancelCall(NetworkPath, 0, true), call), + call => Assert.Equal(new CancelCall(ServerPath, 0x00000001, true), call), + call => Assert.Equal(new CancelCall(ServerPath, 0, true), call)); + + allowProfileDisconnect = true; + accessor.Dispose(); } - /// - /// 验证:forceConnect=true 强制走 WNetUseConnection 路径。 - /// - /// 在当前开发机环境中,因为已有到 \\192.168.1.2 的持久连接, - /// WNetUseConnection 会返回 1219(服务器级别凭据冲突)。 - /// 这恰证明 WNetUseConnection 确实被调用了 — Windows 在检查共享名 - /// 之前就执行了服务器级凭据验证。 - /// - /// 在生产环境(无已有连接)中,此路径会成功建立连接。 - /// [Fact] - public void ForceConnect_SameServer_CallsWNetUseConnection_ProvenBy1219() + public void Constructor_Win32Error1219_RetriesThreeTimesBeforeThrowing() { - try - { - using var accessor = new NetworkShareAccessor( - NonExistentSharePath, TestUserName, TestPassword, forceConnect: true); - - _activeAccessors.Add(accessor); - Assert.True(accessor.DidEstablishConnection); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1219) - { - // ✅ 预期行为: - // 1219 说明 WNetUseConnection 确实被调用了。 - // Windows 在服务器级别检测到凭据冲突,在检查共享是否存在之前就返回了 1219。 - // 这证明代码正确走入了 WNetUseConnection 路径。 - // - // 生产环境中(无已有连接时),此代码路径将成功建立连接。 - } - catch (Win32Exception ex) when (ex.NativeErrorCode is 67 or 53 or 1326) + var recorder = new RecordingPlatform { - // ✅ 也是预期行为:如果服务器可达但共享不存在 / 凭据错误 - } + UseConnection = (_, _, _) => 1219 + }; + + var exception = Assert.Throws(() => + new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build())); + + Assert.Equal(1219, exception.NativeErrorCode); + Assert.Equal(3, recorder.ConnectCalls.Count); + Assert.Collection( + recorder.SleepCalls, + delay => Assert.Equal(TimeSpan.FromMilliseconds(500), delay), + delay => Assert.Equal(TimeSpan.FromMilliseconds(1000), delay)); } - /// - /// 验证:WNetUseConnection 在无凭据冲突时能正常返回合理的错误码。 - /// 使用完全不同的服务器 IP 来避开已有连接。 - /// [Fact] - public void ForceConnect_DifferentServer_No1219Conflict() + public void Constructor_Non1219Error_ThrowsImmediately() { - const string differentServerShare = @"\\192.168.255.255\share"; - - var ex = Assert.Throws(() => + var recorder = new RecordingPlatform { - using var accessor = new NetworkShareAccessor( - differentServerShare, "fakeuser", "fakepass", forceConnect: true); - }); + UseConnection = (_, _, _) => 1326 + }; - // 不同服务器 → 无凭据冲突 → 不应返回 1219 - Assert.NotEqual(1219, ex.NativeErrorCode); + var exception = Assert.Throws(() => + new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build())); - // 预期:53 (ERROR_BAD_NETPATH) — 网络不可达 - // 或 1326 (ERROR_LOGON_FAILURE) + Assert.Equal(1326, exception.NativeErrorCode); + Assert.Single(recorder.ConnectCalls); + Assert.Empty(recorder.SleepCalls); } - /// - /// 验证:forceConnect 路径下连续两次连接(中间 Dispose)的行为。 - /// - /// 在当前开发机环境中,因为已有持久连接,两次都会得到 1219。 - /// 这验证了: - /// 1. Dispose 正确清理了本次建立的临时连接 - /// 2. 但无法清除其他进程/系统建立的持久连接 - /// - /// 在生产环境中,Dispose 清理后第二次连接应成功。 - /// [Fact] - public void ForceConnect_RepeatedConnections_ConsistentBehavior() + public void Dispose_MultipleCalls_CancelsOnlyOnce() { - int errorCode1 = 0; - int errorCode2 = 0; + var recorder = new RecordingPlatform(); + var accessor = new NetworkShareAccessor(NetworkPath, UserName, Password, forceConnect: false, recorder.Build()); + int cancelCallsBeforeDispose = recorder.CancelCalls.Count; - // 第一次 forceConnect - try - { - using var a1 = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword, forceConnect: true); - _activeAccessors.Add(a1); - } - catch (Win32Exception ex) { errorCode1 = ex.NativeErrorCode; } - - // 第二次 forceConnect - try - { - using var a2 = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword, forceConnect: true); - _activeAccessors.Add(a2); - } - catch (Win32Exception ex) { errorCode2 = ex.NativeErrorCode; } - - // 两次行为应一致(要么都成功,要么同一错误) - if (errorCode1 == 1219 || errorCode2 == 1219) - { - // 环境中有持久连接 → 1219 是预期的(非代码缺陷) - // 验证两次行为一致 - Assert.Equal(errorCode1, errorCode2); - } - } + accessor.Dispose(); + accessor.Dispose(); - // ================================================================ - // 集成测试:真实共享可访问性 - // ================================================================ + Assert.Equal(cancelCallsBeforeDispose + 1, recorder.CancelCalls.Count); + Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), recorder.CancelCalls[^1]); + } - [Fact] - public void Connect_ToRealShare_CanAccessFiles() + [Theory] + [InlineData(@"\\192.168.1.2\工作目录", @"\\192.168.1.2")] + [InlineData(@"\\server\share\subdir", @"\\server")] + [InlineData(@"\\localhost\c$", @"\\localhost")] + [InlineData(@"\\10.0.0.1\data", @"\\10.0.0.1")] + [InlineData(@"\\SERVER", @"\\SERVER")] + [InlineData(@"C:\local\path", null)] + [InlineData(@"not-a-network-path", null)] + public void ExtractServerPath_VariousPaths_ReturnsExpected(string input, string? expected) { - try - { - using var accessor = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword); - - _activeAccessors.Add(accessor); + string? result = NetworkShareAccessor.ExtractServerPath(input); - var dirInfo = new DirectoryInfo(TestSharePath); - Assert.True(dirInfo.Exists, $"共享 {TestSharePath} 应该可访问"); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1326) { } - catch (Win32Exception ex) when (ex.NativeErrorCode == 53 || ex.NativeErrorCode == 67) { } + Assert.Equal(expected, result); } - // ================================================================ - // 重复连接 & 不同凭据 - // ================================================================ - [Fact] - public void Connect_RepeatedConnections_DoesNotThrow1219() + public void Execute_ReturnsDelegateResult() { - try - { - using (var a1 = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword)) - { - _activeAccessors.Add(a1); - } - - using (var a2 = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword)) - { - _activeAccessors.Add(a2); - } - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1219) - { - Assert.Fail($"仍然出现 1219 错误:{ex.Message}"); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1326) { } - catch (Win32Exception ex) when (ex.NativeErrorCode == 53 || ex.NativeErrorCode == 67) { } + var recorder = new RecordingPlatform(); + + int result = NetworkShareAccessor.Execute( + NetworkPath, + UserName, + Password, + () => 42, + platform: recorder.Build()); + + Assert.Equal(42, result); + Assert.Single(recorder.ConnectCalls); + Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), recorder.CancelCalls[^1]); } [Fact] - public void Connect_DifferentCredentialsSameServer_Handles1219Gracefully() + public void Execute_WhenActionThrows_StillDisposesAccessor() { - try - { - using (var a1 = new NetworkShareAccessor(TestSharePath, TestUserName, TestPassword)) - { - _activeAccessors.Add(a1); - } - } - catch (Win32Exception) { } + var recorder = new RecordingPlatform(); - try - { - using var a2 = new NetworkShareAccessor(TestSharePath, "otheruser@domain.com", "otherpass"); - _activeAccessors.Add(a2); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1219) - { - Assert.Fail($"第二次连接仍然出现 1219 错误:{ex.Message}"); - } - catch (Win32Exception) { } - } + Assert.Throws(() => + NetworkShareAccessor.Execute( + NetworkPath, + UserName, + Password, + () => throw new InvalidOperationException("boom"), + platform: recorder.Build())); - // ================================================================ - // Dispose 安全性 - // ================================================================ + Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), recorder.CancelCalls[^1]); + } [Fact] - public void Dispose_MultipleCalls_DoesNotThrow() + public async Task ExecuteAsync_ReturnsDelegateResult() { - NetworkShareAccessor? accessor = null; - try - { - accessor = new NetworkShareAccessor( - NonExistentSharePath, TestUserName, TestPassword); - } - catch (Win32Exception) { } + var recorder = new RecordingPlatform(); - if (accessor != null) - { - accessor.Dispose(); - accessor.Dispose(); - } + int result = await NetworkShareAccessor.ExecuteAsync( + NetworkPath, + UserName, + Password, + async () => + { + await Task.Yield(); + return 24; + }, + platform: recorder.Build()); + + Assert.Equal(24, result); + Assert.Single(recorder.ConnectCalls); + Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), recorder.CancelCalls[^1]); } - // ================================================================ - // 文件可访问性 - // ================================================================ - [Fact] - public void Connect_ThenFileExists_ReturnsCorrectResult() + public async Task ExecuteAsync_WhenActionThrows_StillDisposesAccessor() { - try - { - using var accessor = new NetworkShareAccessor( - TestSharePath, TestUserName, TestPassword); + var recorder = new RecordingPlatform(); + + await Assert.ThrowsAsync(() => + NetworkShareAccessor.ExecuteAsync( + NetworkPath, + UserName, + Password, + async () => + { + await Task.Yield(); + throw new InvalidOperationException("boom"); + }, + platform: recorder.Build())); + + Assert.Equal(new CancelCall(NetworkPath, 0x00000001, true), recorder.CancelCalls[^1]); + } - _activeAccessors.Add(accessor); + private sealed class RecordingPlatform + { + public List CancelCalls { get; } = []; + public List ConnectCalls { get; } = []; + public List SleepCalls { get; } = []; - var dirInfo = new DirectoryInfo(TestSharePath); - if (!dirInfo.Exists) - return; + public Func DirectoryExists { get; set; } = _ => false; + public Func UseConnection { get; set; } = (_, _, _) => 0; + public Func CancelConnection { get; set; } = (_, _, _) => true; - Assert.True(dirInfo.Exists); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 1326) { } - catch (Win32Exception ex) when (ex.NativeErrorCode is 53 or 67) { } + public NetworkShareAccessor.Platform Build() + => new() + { + DirectoryExists = path => DirectoryExists(path), + UseConnection = (networkName, userName, password) => + { + ConnectCalls.Add(new ConnectCall(networkName, userName, password)); + return UseConnection(networkName, userName, password); + }, + CancelConnection = (path, flags, force) => + { + CancelCalls.Add(new CancelCall(path, flags, force)); + return CancelConnection(path, flags, force); + }, + Sleep = delay => SleepCalls.Add(delay) + }; } -} + private readonly record struct ConnectCall(string NetworkName, string UserName, string Password); + + private readonly record struct CancelCall(string Path, int Flags, bool Force); +} From 202a0f5b30b9e69776d2c6c535647ca5cb5ce722 Mon Sep 17 00:00:00 2001 From: marsonshine Date: Sat, 27 Jun 2026 16:21:10 +0800 Subject: [PATCH 2/4] refactor: add testresult folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6b5921e..be81a15 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ [Oo]bj/ logs/ logs +/TestResults From c5ec1be5aa52e1e8fe851d22970b113d6e8abc67 Mon Sep 17 00:00:00 2001 From: marsonshine Date: Sat, 27 Jun 2026 17:04:54 +0800 Subject: [PATCH 3/4] Add unit tests for HTTP client logging, serialization, text normalization, audio format detection, event sourcing, and Excel utilities - Implement LogHttpClientTests to verify GET and POST requests with logging. - Create LoggingHttpClientHandlerTests to ensure request and response logging. - Add SecretFieldBranchTests for sensitive information handling. - Introduce SerializationConverterTests for custom JSON converters. - Develop TextNormalizerTests for various text normalization scenarios. - Implement AudioFileFormatDetectorTests for audio format detection from streams. - Add OrderAggregateDecisionTests to validate order processing logic. - Create PerformanceProbeTests for performance measurement utility. - Implement DynamicExcelBuilderTests and ExcelHelperEdgeTests for Excel file handling. - Add coverlet.runsettings for code coverage configuration. --- .gitignore | 3 +- Directory.Packages.props | 2 +- .../AIRequestValidatorTests.cs | 189 +++++ .../LoggerExtensionsTests.cs | 53 +- .../Ceching/WeakEvictionCache.cs | 129 ++-- .../Concurrent/SingleflightManager.cs | 71 +- .../System/Collection/DisposableStack.cs | 12 + .../Ceching/WeakEvictionCacheTests.cs | 96 +++ .../Common/TextNormalizerTests.cs | 34 + .../Coverage/BinaryTreeTests.cs | 34 + .../Coverage/CacheAndRetryTests.cs | 47 ++ .../Coverage/CoreEdgeGapTests.cs | 40 ++ .../Coverage/CoreExtensionsGapTests.cs | 121 ++++ .../Coverage/CoreFinalGap2Tests.cs | 68 ++ .../Coverage/CoreFinalGapTests.cs | 62 ++ .../Coverage/DtoAndSpecificationTests.cs | 101 +++ .../Coverage/ExtensionCoverageTests.cs | 58 ++ .../Coverage/LinkedListTests.cs | 57 ++ .../Coverage/ResultExtensionsAsyncTests.cs | 69 ++ .../Cryptology/EncryptTest.cs | 113 ++- .../Domain/EntityHelperEdgeTests.cs | 63 ++ .../EventSourcingAbstractionsTests.cs | 48 ++ .../IdentityModel/PasswordSaltHelperTests.cs | 17 + .../Domain/IdentityModel/RoleTests.cs | 50 ++ .../Domain/IdentityModel/UserTests.cs | 67 ++ .../Repository/IUnitOfWorkExtensionsTests.cs | 116 ++++ .../Dto/ResultTests.cs | 134 ++++ .../Extensions/CollectionExtensionsTest.cs | 48 +- .../Extensions/ListHelperTests.cs | 64 ++ .../Functional/CountryCodeTests.cs | 104 +++ .../Functional/EitherExtensionsMoreTests.cs | 276 ++++++++ .../Functional/EitherValueTests.cs | 53 ++ .../Functional/FAsyncTests.cs | 127 ++++ .../Functional/ValidationExceptionalTests.cs | 279 +++++++- .../Functional/ValidationTests.cs | 653 ++++++++++++++++++ .../Linq/ExpressionStarterTests.cs | 64 ++ .../System/Collection/DisposableStackTests.cs | 47 ++ .../Net/Http/HttpClientExtensionsTests.cs | 109 +++ .../Net/Http/LogHttpClientTests.cs | 133 ++++ .../Net/Http/LoggingHttpClientHandlerTests.cs | 110 +++ .../Security/SecretFieldBranchTests.cs | 101 +++ .../SerializationConverterTests.cs | 102 +++ .../Text/TextNormalizerTests.cs | 81 +++ .../NAudio/AudioFileFormatDetectorTests.cs | 81 +++ .../OrderAggregateDecisionTests.cs | 98 +++ .../Diagnostics/PerformanceProbeTests.cs | 37 + .../Utils/Excel/DynamicExcelBuilderTests.cs | 125 ++++ .../Utils/Excel/ExcelHelperEdgeTests.cs | 237 +++++++ .../Utils/Excel/ExcelHelperTests.cs | 177 ++++- test/coverlet.runsettings | 21 + 50 files changed, 4806 insertions(+), 175 deletions(-) create mode 100644 MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIRequestValidatorTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Ceching/WeakEvictionCacheTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Common/TextNormalizerTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/BinaryTreeTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/CacheAndRetryTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/CoreEdgeGapTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/CoreExtensionsGapTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/CoreFinalGap2Tests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/CoreFinalGapTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/DtoAndSpecificationTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/ExtensionCoverageTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/LinkedListTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Coverage/ResultExtensionsAsyncTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/EntityHelperEdgeTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/EventSourcing/EventSourcingAbstractionsTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/IdentityModel/PasswordSaltHelperTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/IdentityModel/RoleTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/IdentityModel/UserTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Domain/Repository/IUnitOfWorkExtensionsTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Dto/ResultTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Extensions/ListHelperTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Functional/CountryCodeTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Functional/EitherExtensionsMoreTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Functional/EitherValueTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Functional/FAsyncTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Functional/ValidationTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Linq/ExpressionStarterTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Microsoft/System/Collection/DisposableStackTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Net/Http/HttpClientExtensionsTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Net/Http/LogHttpClientTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Net/Http/LoggingHttpClientHandlerTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Security/SecretFieldBranchTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Serialization/SerializationConverterTests.cs create mode 100644 test/MS.Microservice.Core.Tests/Text/TextNormalizerTests.cs create mode 100644 test/MS.Microservice.Infrastructure.Tests/Common/NAudio/AudioFileFormatDetectorTests.cs create mode 100644 test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs create mode 100644 test/MS.Microservice.Infrastructure.Tests/Utils/Diagnostics/PerformanceProbeTests.cs create mode 100644 test/MS.Microservice.Infrastructure.Tests/Utils/Excel/DynamicExcelBuilderTests.cs create mode 100644 test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperEdgeTests.cs create mode 100644 test/coverlet.runsettings diff --git a/.gitignore b/.gitignore index be81a15..59643e9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ [Oo]bj/ logs/ logs -/TestResults +/.tools +TestResults/ diff --git a/Directory.Packages.props b/Directory.Packages.props index e624236..fbb1d3c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIRequestValidatorTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIRequestValidatorTests.cs new file mode 100644 index 0000000..a5df129 --- /dev/null +++ b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIRequestValidatorTests.cs @@ -0,0 +1,189 @@ +using System; +using FluentAssertions; +using MS.Microservice.AI.Abstractions; +using MS.Microservice.AI.Core; +using Xunit; + +namespace MS.Microservice.AI.Core.Tests; + +public sealed class AIRequestValidatorTests +{ + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenMessagesAreEmpty() + { + var request = new AIChatRequest { Messages = [] }; + + Action action = () => AIRequestValidator.ValidateChatRequest(request); + + action.Should().Throw() + .WithMessage("*at least one message*"); + } + + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenMessageRoleIsEmpty() + { + var request = new AIChatRequest { Messages = [new AIChatMessage("", "hello")] }; + + Action action = () => AIRequestValidator.ValidateChatRequest(request); + + action.Should().Throw() + .WithMessage("*index 0*role*"); + } + + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenMessageContentIsEmpty() + { + var request = new AIChatRequest { Messages = [new AIChatMessage("user", "")] }; + + Action action = () => AIRequestValidator.ValidateChatRequest(request); + + action.Should().Throw() + .WithMessage("*index 0*content*"); + } + + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenCharacterLimitIsExceeded() + { + var request = new AIChatRequest + { + Messages = [new AIChatMessage("user", "abcd")] + }; + var limits = new AIPayloadLimitOptions { MaxChatCharacters = 3, MaxStreamingChatCharacters = 2 }; + + Action normal = () => AIRequestValidator.ValidateChatRequest(request, limits); + Action streaming = () => AIRequestValidator.ValidateChatRequest(request, limits, isStreaming: true); + + normal.Should().Throw().WithMessage("*configured limit*"); + streaming.Should().Throw().WithMessage("*configured limit*"); + } + + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenNumericFieldsAreInvalid() + { + var request = new AIChatRequest + { + Messages = [new AIChatMessage("user", "hello")], + Temperature = -0.1 + }; + + Action temperature = () => AIRequestValidator.ValidateChatRequest(request); + temperature.Should().Throw().WithMessage("*temperature*"); + + request = request with { Temperature = 0.5, TopP = 1.1 }; + Action topP = () => AIRequestValidator.ValidateChatRequest(request); + topP.Should().Throw().WithMessage("*top_p*"); + + request = request with { TopP = 0.5, MaxOutputTokens = 0 }; + Action maxOutput = () => AIRequestValidator.ValidateChatRequest(request); + maxOutput.Should().Throw().WithMessage("*max output tokens*"); + + request = request with { MaxOutputTokens = 16, Timeout = TimeSpan.Zero }; + Action timeout = () => AIRequestValidator.ValidateChatRequest(request); + timeout.Should().Throw().WithMessage("*timeout*"); + } + + [Fact] + public void ValidateChatRequest_ShouldThrow_WhenOptionalStringsAreWhitespace() + { + var request = new AIChatRequest + { + Messages = [new AIChatMessage("user", "hello")], + Provider = " " + }; + + Action provider = () => AIRequestValidator.ValidateChatRequest(request); + provider.Should().Throw().WithMessage("*provider*"); + + request = request with { Provider = "openai", Model = " " }; + Action model = () => AIRequestValidator.ValidateChatRequest(request); + model.Should().Throw().WithMessage("*model*"); + + request = request with { Model = "gpt-4o-mini", Scenario = " " }; + Action scenario = () => AIRequestValidator.ValidateChatRequest(request); + scenario.Should().Throw().WithMessage("*scenario*"); + } + + [Fact] + public void ValidateTtsRequest_ShouldThrow_WhenFieldsAreInvalid() + { + var limits = new AIPayloadLimitOptions { MaxTextCharacters = 2 }; + var request = new AITtsRequest { Input = "abc" }; + + Action inputLimit = () => AIRequestValidator.ValidateTtsRequest(request, limits); + inputLimit.Should().Throw().WithMessage("*configured limit*"); + + request = new AITtsRequest { Input = "ok", Voice = " " }; + Action voice = () => AIRequestValidator.ValidateTtsRequest(request); + voice.Should().Throw().WithMessage("*voice*"); + + request = request with { Voice = "alloy", ResponseFormat = " " }; + Action format = () => AIRequestValidator.ValidateTtsRequest(request); + format.Should().Throw().WithMessage("*response format*"); + + request = request with { ResponseFormat = "mp3", Speed = 0 }; + Action speed = () => AIRequestValidator.ValidateTtsRequest(request); + speed.Should().Throw().WithMessage("*speed*"); + } + + [Fact] + public void ValidateAsrRequest_ShouldThrow_WhenAudioIsMissingOrTooLarge() + { + var emptyAudio = new AIBinaryContent { Content = [], ContentType = "audio/wav" }; + var request = new AIAsrRequest { Audio = emptyAudio }; + + Action missing = () => AIRequestValidator.ValidateAsrRequest(request); + missing.Should().Throw().WithMessage("*binary content*"); + + var largeAudio = new AIBinaryContent { Content = new byte[5], ContentType = "audio/wav" }; + request = new AIAsrRequest { Audio = largeAudio }; + var limits = new AIPayloadLimitOptions { MaxAudioBytes = 4 }; + + Action tooLarge = () => AIRequestValidator.ValidateAsrRequest(request, limits); + tooLarge.Should().Throw().WithMessage("*configured limit*"); + } + + [Fact] + public void ValidateImageGenerationRequest_ShouldThrow_WhenFieldsAreInvalid() + { + var request = new AIImageGenerationRequest { Prompt = " ", Count = 1 }; + + Action prompt = () => AIRequestValidator.ValidateImageGenerationRequest(request); + prompt.Should().Throw().WithMessage("*include a prompt*"); + + request = new AIImageGenerationRequest { Prompt = "draw", Count = 0 }; + Action count = () => AIRequestValidator.ValidateImageGenerationRequest(request); + count.Should().Throw().WithMessage("*count*"); + + request = request with { Count = 1, Size = " " }; + Action size = () => AIRequestValidator.ValidateImageGenerationRequest(request); + size.Should().Throw().WithMessage("*size*"); + } + + [Fact] + public void ValidateImageEditRequest_ShouldThrow_WhenImageOrMaskAreInvalid() + { + var request = new AIImageEditRequest + { + Prompt = "edit", + Image = new AIBinaryContent { Content = [], ContentType = "image/png" }, + Count = 1 + }; + + Action image = () => AIRequestValidator.ValidateImageEditRequest(request); + image.Should().Throw().WithMessage("*image*binary content*"); + + request = request with + { + Image = new AIBinaryContent { Content = [1, 2, 3, 4, 5], ContentType = "image/png" }, + Mask = new AIBinaryContent { Content = [], ContentType = "image/png" } + }; + var limits = new AIPayloadLimitOptions { MaxImageBytes = 4, MaxImageMaskBytes = 1 }; + + Action imageLimit = () => AIRequestValidator.ValidateImageEditRequest(request, limits); + imageLimit.Should().Throw().WithMessage("*image exceeds*"); + + request = request with { Image = new AIBinaryContent { Content = [1], ContentType = "image/png" } }; + Action mask = () => AIRequestValidator.ValidateImageEditRequest(request, limits); + mask.Should().Throw().WithMessage("*mask*binary content*"); + } +} diff --git a/MS.Microservice.Logging/test/MS.Microservice.Logging.Core.Tests/LoggerExtensionsTests.cs b/MS.Microservice.Logging/test/MS.Microservice.Logging.Core.Tests/LoggerExtensionsTests.cs index 605a102..d694949 100644 --- a/MS.Microservice.Logging/test/MS.Microservice.Logging.Core.Tests/LoggerExtensionsTests.cs +++ b/MS.Microservice.Logging/test/MS.Microservice.Logging.Core.Tests/LoggerExtensionsTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using FluentAssertions; using Microsoft.Extensions.Logging; using Xunit; @@ -18,6 +21,54 @@ public void LogHttpRequest_ShouldWriteExpectedFormattedMessage() logger.Entries[0].Message.Should().Be("HTTP GET /orders/42 -> 204 in 87ms"); } + [Fact] + public void LogOverloads_ShouldWriteExpectedMessagesAndLevels() + { + var logger = new TestLogger(); + var exception = new InvalidOperationException("boom"); + + logger.LogInfo("info"); + logger.LogInfo("info", 1); + logger.LogInfo("info", 1, 2); + logger.LogInfo("info", 1, 2, 3); + + logger.LogWarn("warn"); + logger.LogWarn("warn", 1); + logger.LogWarn("warn", 1, 2); + logger.LogWarn("warn", 1, 2, 3); + + logger.LogErr("err", exception); + logger.LogErr("err", 1, exception); + logger.LogErr("err", 1, 2, exception); + logger.LogErr("err", 1, 2, 3, exception); + + logger.LogDbg("dbg"); + logger.LogDbg("dbg", 1); + logger.LogDbg("dbg", 1, 2); + + logger.Entries.Should().HaveCount(15); + logger.Entries.Select(entry => entry.Message).Should().ContainInOrder( + "info", + "info 1", + "info 1 2", + "info 1 2 3", + "warn", + "warn 1", + "warn 1 2", + "warn 1 2 3", + "err", + "err 1", + "err 1 2", + "err 1 2 3", + "dbg", + "dbg 1", + "dbg 1 2"); + logger.Entries.Where(entry => entry.LogLevel == LogLevel.Error) + .Select(entry => entry.Exception) + .Should() + .OnlyContain(entry => ReferenceEquals(entry, exception)); + } + private sealed class TestLogger : ILogger { public List Entries { get; } = []; @@ -42,4 +93,4 @@ public void Dispose() { } } -} \ No newline at end of file +} diff --git a/src/MS.Microservice.Core/Ceching/WeakEvictionCache.cs b/src/MS.Microservice.Core/Ceching/WeakEvictionCache.cs index c4c0940..fc45f11 100644 --- a/src/MS.Microservice.Core/Ceching/WeakEvictionCache.cs +++ b/src/MS.Microservice.Core/Ceching/WeakEvictionCache.cs @@ -1,68 +1,103 @@ using MS.Microservice.Core.Microsoft.System; -using System; -using System.Collections.Generic; -namespace MS.Microservice.Core.Ceching +namespace MS.Microservice.Core.Ceching; + +/// +/// 一个带“弱引用驱逐”机制的缓存。 +/// +/// TValue 必须是引用类型,因为弱引用只能用于引用类型对象。 +/// TKey 不能为空,因为 Dictionary 的 key 不允许为 null。 +/// +/// 核心思想: +/// 1. 对象刚加入缓存时,使用强引用保存,防止被 GC 回收。 +/// 2. 对象被访问时,会重新变成强引用,表示它最近还在使用。 +/// 3. 如果对象长时间没有被访问,DoWeakEviction 会把它降级为弱引用。 +/// 4. 一旦对象只剩弱引用,GC 可以在合适的时候回收它。 +/// 5. 如果对象已经被 GC 回收,DoWeakEviction 会把对应缓存项移除。 +/// +/// 缓存 key 类型,不能为空。 +/// 缓存 value 类型,必须是引用类型。 +public class WeakEvictionCache where TValue : class + where TKey : notnull { - public class WeakEvictionCache where TValue : class - where TKey : notnull - { - private readonly TimeSpan _weakEvictionThreshold; - private Dictionary> _items; + /// + /// 一个对象保持强引用的最长时间。 + /// + /// 当对象距离上次变成强引用的时间超过这个阈值后, + /// DoWeakEviction 会尝试将它降级为弱引用。 + /// + private readonly TimeSpan _weakEvictionThreshold; - public WeakEvictionCache(TimeSpan weakEvictionThreshold) - { - _weakEvictionThreshold = weakEvictionThreshold; - _items = []; - } + /// + /// 实际存储缓存项的字典。 + /// + /// key 是用户传入的缓存 key。 + /// value 是 `StrongToWeakReference`, + /// 它应该是一个可以在强引用和弱引用之间切换的包装类。 + /// + private readonly Dictionary> _items; - public void Add(TKey key, TValue value) - { - ArgumentNullException.ThrowIfNull(value); - _items.Add(key, new StrongToWeakReference(value)); - } + public WeakEvictionCache(TimeSpan weakEvictionThreshold) + { + _weakEvictionThreshold = weakEvictionThreshold; + _items = []; + } + + /// + /// 向缓存中添加一个对象。 + /// + /// 注意: + /// 如果 key 已经存在,Dictionary.Add 会抛出异常。 + /// 如果 value 为 null,也会抛出 ArgumentNullException。 + /// + /// 缓存 key。 + /// 要缓存的对象,不能为 null。 + public void Add(TKey key, TValue value) + { + ArgumentNullException.ThrowIfNull(value); + _items.Add(key, new StrongToWeakReference(value)); + } - public bool TryGet(TKey key, out TValue? result) + public bool TryGet(TKey key, out TValue? result) + { + result = null; + if (_items.TryGetValue(key, out var value)) { - result = null; - if (_items.TryGetValue(key, out var value)) + result = value.Target!; + if (result != null) { - result = value.Target!; - if (result != null) - { - // 对象被使用时尝试恢复强引用 - value.MakeStrong(); - return true; - } + // 对象被使用时尝试恢复强引用 + value.MakeStrong(); + return true; } - return false; } + return false; + } - public void DoWeakEviction() + public void DoWeakEviction() + { + List toRemove = new List(); + foreach (var strongToWeakReference in _items) { - List toRemove = new List(); - foreach (var strongToWeakReference in _items) + var reference = strongToWeakReference.Value; + var target = reference.Target; + if (target != null) { - var reference = strongToWeakReference.Value; - var target = reference.Target; - if (target != null) + if (DateTime.Now.Subtract(reference.StrongTime) >= _weakEvictionThreshold) { - if (DateTime.Now.Subtract(reference.StrongTime) >= _weakEvictionThreshold) - { - reference.MakeWeak(); - } - } - else - { - // 清除已失效的弱引用 - toRemove.Add(strongToWeakReference.Key); + reference.MakeWeak(); } } - - foreach (var key in toRemove) + else { - _items.Remove(key); + // 清除已失效的弱引用 + toRemove.Add(strongToWeakReference.Key); } } + + foreach (var key in toRemove) + { + _items.Remove(key); + } } } diff --git a/src/MS.Microservice.Core/Concurrent/SingleflightManager.cs b/src/MS.Microservice.Core/Concurrent/SingleflightManager.cs index 25898a2..69fbcec 100644 --- a/src/MS.Microservice.Core/Concurrent/SingleflightManager.cs +++ b/src/MS.Microservice.Core/Concurrent/SingleflightManager.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; +using System.Collections.Concurrent; /* * singleflifght 模式是一种并发控制模式,解决在并发场景下的重复操作和请求穿透问题。 @@ -10,40 +6,39 @@ * 这样可以避免重复计算,提高系统性能和资源利用率。 * 具体使用场景和使用优劣可详见:https://levelup.gitconnected.com/singleflight-concurrency-design-pattern-in-golang-f4ce5c1ce87e */ -namespace MS.Microservice.Core.Concurrent +namespace MS.Microservice.Core.Concurrent; + +public class SingleflightManager { - public class SingleflightManager - { - private readonly ConcurrentDictionary> _inFlightRequests; + private readonly ConcurrentDictionary> _inFlightRequests; - public SingleflightManager() - { - _inFlightRequests = new ConcurrentDictionary>(); - } + public SingleflightManager() + { + _inFlightRequests = new ConcurrentDictionary>(); + } - public async Task ExecuteOnceAsync(string key, Func> action) - { - var completionSource = new TaskCompletionSource(); - var existingTask = _inFlightRequests.GetOrAdd(key, completionSource); - try - { - if (existingTask == completionSource) - { - // 当前请求是第一个请求,执行对应的操作 - T result = await action(); - completionSource.SetResult(result!); - return result; - } - else - { - // 等待第一个请求完成,并使用其结果 - return (T)(await (existingTask.Task)); - } - } - finally - { - _inFlightRequests.TryRemove(key, out _); - } - } - } + public async Task ExecuteOnceAsync(string key, Func> action) + { + var completionSource = new TaskCompletionSource(); + var existingTask = _inFlightRequests.GetOrAdd(key, completionSource); + try + { + if (existingTask == completionSource) + { + // 当前请求是第一个请求,执行对应的操作 + T result = await action(); + completionSource.SetResult(result!); + return result; + } + else + { + // 等待第一个请求完成,并使用其结果 + return (T)(await (existingTask.Task)); + } + } + finally + { + _inFlightRequests.TryRemove(key, out _); + } + } } diff --git a/src/MS.Microservice.Core/Microsoft/System/Collection/DisposableStack.cs b/src/MS.Microservice.Core/Microsoft/System/Collection/DisposableStack.cs index ce945cf..4ed5419 100644 --- a/src/MS.Microservice.Core/Microsoft/System/Collection/DisposableStack.cs +++ b/src/MS.Microservice.Core/Microsoft/System/Collection/DisposableStack.cs @@ -3,6 +3,18 @@ namespace Microsoft.System.Collection { + /// + /// 一个专门存放 IDisposable 对象的栈。 + /// + /// 当 DisposableStack 自身被 Dispose 时, + /// 会自动释放栈中所有还没有被 Pop 出去的对象。 + /// + /// 释放顺序遵循栈的后进先出原则: + /// 最后 Push 进去的对象,会最先被 Dispose。 + /// + /// + /// 栈中元素的类型,必须实现 IDisposable。 + /// public class DisposableStack : IDisposable where T : IDisposable { private readonly Stack _stack = new(); diff --git a/test/MS.Microservice.Core.Tests/Ceching/WeakEvictionCacheTests.cs b/test/MS.Microservice.Core.Tests/Ceching/WeakEvictionCacheTests.cs new file mode 100644 index 0000000..cd9ff74 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Ceching/WeakEvictionCacheTests.cs @@ -0,0 +1,96 @@ +using System; +using FluentAssertions; +using MS.Microservice.Core.Ceching; +using Xunit; + +namespace MS.Microservice.Core.Tests.Ceching; + +public sealed class WeakEvictionCacheTests +{ + private sealed record Holder(string Name); + + [Fact] + public void Add_ShouldThrow_WhenValueIsNull() + { + var cache = new WeakEvictionCache(TimeSpan.Zero); + Assert.Throws(() => cache.Add("key", null!)); + } + + [Fact] + public void Add_TryGet_ReturnsValue() + { + var cache = new WeakEvictionCache(TimeSpan.FromMinutes(5)); + var value = new Holder("demo"); + cache.Add("key", value); + + var found = cache.TryGet("key", out var retrieved); + + found.Should().BeTrue(); + retrieved.Should().Be(value); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenKeyDoesNotExist() + { + var cache = new WeakEvictionCache(TimeSpan.Zero); + var found = cache.TryGet("missing", out Holder? value); + + found.Should().BeFalse(); + value.Should().BeNull(); + } + + [Fact] + public void DoWeakEviction_ShouldKeepLiveReferenceAccessible() + { + var cache = new WeakEvictionCache(TimeSpan.Zero); + var value = new Holder("demo"); + cache.Add("key", value); + + cache.DoWeakEviction(); + var found = cache.TryGet("key", out Holder? restored); + + found.Should().BeTrue(); + restored.Should().BeSameAs(value); + } + + [Fact] + public void DoWeakEviction_BeforeThreshold_KeepsStrong() + { + var cache = new WeakEvictionCache(TimeSpan.FromHours(1)); + var value = new Holder("persist"); + cache.Add("key", value); + + cache.DoWeakEviction(); + + cache.TryGet("key", out var retrieved).Should().BeTrue(); + retrieved.Should().BeSameAs(value); + } + + [Fact] + public void MultipleAdds_TryGetEach_ReturnsCorrectValues() + { + var cache = new WeakEvictionCache(TimeSpan.FromMinutes(5)); + cache.Add("k1", "v1"); + cache.Add("k2", "v2"); + cache.Add("k3", "v3"); + + cache.TryGet("k1", out var r1).Should().BeTrue(); + r1.Should().Be("v1"); + cache.TryGet("k2", out var r2).Should().BeTrue(); + r2.Should().Be("v2"); + cache.TryGet("k3", out var r3).Should().BeTrue(); + r3.Should().Be("v3"); + } + + [Fact] + public void TryGet_NotFound_ThenAdd_FindsIt() + { + var cache = new WeakEvictionCache(TimeSpan.FromMinutes(5)); + cache.TryGet("key", out _).Should().BeFalse(); + + var value = new Holder("new"); + cache.Add("key", value); + cache.TryGet("key", out var retrieved).Should().BeTrue(); + retrieved.Should().Be(value); + } +} diff --git a/test/MS.Microservice.Core.Tests/Common/TextNormalizerTests.cs b/test/MS.Microservice.Core.Tests/Common/TextNormalizerTests.cs new file mode 100644 index 0000000..dfd9874 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Common/TextNormalizerTests.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using MS.Microservice.Core.Common; +using Xunit; + +namespace MS.Microservice.Core.Tests.Common; + +public sealed class TextNormalizerTests +{ + [Fact] + public void Normalize_ShouldReturnOriginal_WhenInputIsNullOrWhitespace() + { + TextNormalizer.Normalize(null).Should().BeNull(); + TextNormalizer.Normalize(" ").Should().Be(" "); + } + + [Theory] + [InlineData("你好,世界", "你好,世界")] + [InlineData("测试(括号)", "测试(括号)")] + [InlineData("结束。", "结束.")] + [InlineData("问题?", "问题?")] + [InlineData("感叹!", "感叹!")] + [InlineData("“引用”", "\"引用\"")] + [InlineData("‘单引号’", "'单引号'")] + public void Normalize_ShouldReplaceChinesePunctuation(string input, string expected) + { + TextNormalizer.Normalize(input).Should().Be(expected); + } + + [Fact] + public void Normalize_ShouldCollapseWhitespace_AndTrim() + { + TextNormalizer.Normalize(" a\u3000\u00A0b c ").Should().Be("a b c"); + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/BinaryTreeTests.cs b/test/MS.Microservice.Core.Tests/Coverage/BinaryTreeTests.cs new file mode 100644 index 0000000..964052b --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/BinaryTreeTests.cs @@ -0,0 +1,34 @@ +using MS.Microservice.Core.Functional.Data.BinaryTree; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class BinaryTreeTests + { + // Tree abstract class + [Fact] public void Tree_Leaf_Match() { var t = Tree.Leaf(42); var r = t.Match(v => v, (l, r2) => 0); Assert.Equal(42, r); } + [Fact] public void Tree_Branch_Match() { var l = Tree.Leaf(1); var r = Tree.Leaf(2); var b = Tree.Branch(l, r); var result = b.Match(v => v, (left, right) => left.Match(v2=>v2, (_,_)=>0) + right.Match(v2=>v2, (_,_)=>0)); Assert.Equal(3, result); } + [Fact] public void Tree_Equals_Same() { Assert.Equal(Tree.Leaf(5), Tree.Leaf(5)); } + [Fact] public void Tree_GetHashCode() { Assert.Equal(Tree.Leaf(1).GetHashCode(), Tree.Leaf(1).GetHashCode()); } + [Fact] public void Tree_Equals_Obj() { Assert.True(Tree.Leaf(3).Equals((object)Tree.Leaf(3))); } + + // Map + [Fact] public void Tree_Map_Leaf() { var t = Tree.Leaf(2); var r = t.Map(x => x * 3); Assert.Equal(6, r.Match(v => v, (_, _) => 0)); } + [Fact] public void Tree_Map_Branch() { var t = Tree.Branch(Tree.Leaf(1), Tree.Leaf(2)); var r = t.Map(x => x + 10); var sum = r.Match(v => v, (l, r2) => l.Match(v2 => v2, (_, _) => 0) + r2.Match(v2 => v2, (_, _) => 0)); Assert.Equal(23, sum); } + + // Bind + [Fact] public void Tree_Bind_Leaf() { var t = Tree.Leaf(1); var r = t.Bind(x => Tree.Leaf(x + 1)); Assert.Equal(2, r.Match(v => v, (_, _) => 0)); } + + // Insert + [Fact] public void Tree_Insert() { var t = Tree.Leaf(1); var r = t.Insert(2); var str = r.ToString(); Assert.Contains("Branch", str); } + + // Aggregate + [Fact] public void Tree_Aggregate_Binary() { var t = Tree.Branch(Tree.Leaf(3), Tree.Leaf(4)); var sum = t.Aggregate((a, b) => a + b); Assert.Equal(7, sum); } + [Fact] public void Tree_Aggregate_Accumulator() { var t = Tree.Branch(Tree.Leaf(3), Tree.Leaf(4)); var sum = t.Aggregate(10, (acc, v) => acc + v); Assert.Equal(17, sum); } + [Fact] public void Tree_Aggregate_Deep() { var t = Tree.Branch(Tree.Branch(Tree.Leaf(1), Tree.Leaf(2)), Tree.Leaf(3)); var sum = t.Aggregate(0, (acc, v) => acc + v); Assert.Equal(6, sum); } + + // ToString paths + [Fact] public void Tree_Leaf_ToString() { Assert.Equal("42", Tree.Leaf(42).ToString()); } + [Fact] public void Tree_Branch_ToString() { var b = Tree.Branch(Tree.Leaf(1), Tree.Leaf(2)); Assert.Contains("Branch", b.ToString()); Assert.Contains("1", b.ToString()); Assert.Contains("2", b.ToString()); } + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/CacheAndRetryTests.cs b/test/MS.Microservice.Core.Tests/Coverage/CacheAndRetryTests.cs new file mode 100644 index 0000000..6a6c357 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/CacheAndRetryTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MS.Microservice.Core.Ceching; +using MS.Microservice.Core.Common.Advance.Resilience; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + // ===== WeakEvictionCache edge cases ===== + public class WeakEvictionCacheEdgeTests + { + [Fact] public void Add_Then_Get() { var c = new WeakEvictionCache(TimeSpan.FromMinutes(5)); c.Add("k", "v"); Assert.True(c.TryGet("k", out var r)); Assert.Equal("v", r); } + [Fact] public void Get_Missing() { var c = new WeakEvictionCache(TimeSpan.FromMinutes(5)); Assert.False(c.TryGet("missing", out _)); } + [Fact] public void Eviction_DoesNotRemoveStrong() { var c = new WeakEvictionCache(TimeSpan.FromMilliseconds(1)); c.Add("k", "v"); System.Threading.Thread.Sleep(10); c.DoWeakEviction(); Assert.True(c.TryGet("k", out _)); } + [Fact] public void Eviction_ConvertsToWeak() { var c = new WeakEvictionCache(TimeSpan.FromMilliseconds(1)); c.Add("k", "v"); System.Threading.Thread.Sleep(20); c.DoWeakEviction(); c.TryGet("k", out _); } + [Fact] public void Add_Null_Throws() { var c = new WeakEvictionCache(TimeSpan.FromMinutes(5)); Assert.Throws(() => c.Add("k", null!)); } + } + + // ===== RetryExecutor tests ===== + public class RetryExecutorTests + { + private class AlwaysRetry : IRetryStrategy + { + public bool ShouldRetry(RetryContext context) => context.Attempt < 3; + public TimeSpan GetDelay(RetryContext context) => TimeSpan.FromMilliseconds(1); + } + private class SuccessCondition : IRetryCondition + { + public bool ShouldRetry(T? result, Exception? exception) => false; + } + private class RetryCondition : IRetryCondition + { + public bool ShouldRetry(T? result, Exception? exception) => exception != null; + } + + [Fact] public async Task ExecuteAsync_Success() { var exec = new RetryExecutor(new AlwaysRetry(), new SuccessCondition()); var r = await exec.ExecuteAsync(() => Task.FromResult(42)); Assert.Equal(42, r); } + [Fact] public async Task ExecuteAsync_RetryOnException() { var count = 0; var exec = new RetryExecutor(new AlwaysRetry(), new RetryCondition()); var r = await exec.ExecuteAsync(() => { count++; return Task.FromResult(count < 3 ? throw new InvalidOperationException("fail") : 100); }); Assert.Equal(100, r); Assert.Equal(3, count); } + [Fact] public async Task ExecuteAsync_Exhausted() { var exec = new RetryExecutor(new AlwaysRetry(), new RetryCondition()); await Assert.ThrowsAsync(() => exec.ExecuteAsync(() => throw new InvalidOperationException("always fail"))); } + [Fact] public void Logger_Property() { var exec = new RetryExecutor(new AlwaysRetry(), new SuccessCondition()); Assert.NotNull(exec.Logger); exec.Logger = null!; Assert.NotNull(exec.Logger); } + [Fact] public void Constructor_NullStrategy_Throws() { Assert.Throws(() => new RetryExecutor(null!, new SuccessCondition())); } + [Fact] public void Constructor_NullCondition_Throws() { Assert.Throws(() => new RetryExecutor(new AlwaysRetry(), null!)); } + [Fact] public async Task ExecuteAsync_NullOperation_Throws() { var exec = new RetryExecutor(new AlwaysRetry(), new SuccessCondition()); await Assert.ThrowsAsync(() => exec.ExecuteAsync(null!)); } + [Fact] public void Execute_Sync() { var exec = new RetryExecutor(new AlwaysRetry(), new SuccessCondition()); var r = exec.Execute(() => 42); Assert.Equal(42, r); } + [Fact] public void Execute_Action() { bool called = false; var exec = new RetryExecutor(new AlwaysRetry(), new SuccessCondition()); exec.Execute(() => { called = true; }); Assert.True(called); } + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/CoreEdgeGapTests.cs b/test/MS.Microservice.Core.Tests/Coverage/CoreEdgeGapTests.cs new file mode 100644 index 0000000..8b96f49 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/CoreEdgeGapTests.cs @@ -0,0 +1,40 @@ +using MS.Microservice.Core; +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class CoreEdgeGapTests + { + // ===== Error factories - Unauthorized, Unexpected, FromException ===== + [Fact] public void Error_Unauthorized() { var e = Error.Unauthorized("no access"); Assert.Equal("unauthorized", e.Code); Assert.Equal("no access", e.Message); } + [Fact] public void Error_Unauthorized_WithDetails() { var e = Error.Unauthorized("no access", ["missing role", "expired token"]); Assert.Equal(2, e.DetailsOrEmpty.Count); } + [Fact] public void Error_Unexpected() { var e = Error.Unexpected("oops"); Assert.Equal("unexpected", e.Code); } + [Fact] public void Error_Unexpected_WithDetails() { var e = Error.Unexpected("oops", ["stack trace"]); Assert.Single(e.DetailsOrEmpty); } + [Fact] public void Error_FromException() { var ex = new System.InvalidOperationException("bad state"); var e = Error.FromException(ex); Assert.Equal("unexpected", e.Code); Assert.Contains("InvalidOperationException", e.DetailsOrEmpty[0]); } + [Fact] public void Error_FromException_CustomCode() { var e = Error.FromException(new System.ArgumentException("invalid"), "arg_error"); Assert.Equal("arg_error", e.Code); } + [Fact] public void Error_ToDisplayMessage_WithDetails() { var e = Error.Validation("bad", ["field1", "field2"]); var msg = e.ToDisplayMessage(); Assert.Contains("field1", msg); Assert.Contains("field2", msg); Assert.Contains("bad", msg); } + [Fact] public void Error_ToDisplayMessage_NoDetails() { var e = Error.Validation("simple"); Assert.Equal("simple", e.ToDisplayMessage()); } + + // ===== CorePlatformException ===== + [Fact] public void CorePlatformException_Default() { var ex = new CorePlatformException(); Assert.Equal(0, ex.Code); } + [Fact] public void CorePlatformException_WithCode() { var ex = new CorePlatformException(404, "not found"); Assert.Equal(404, ex.Code); Assert.Equal("not found", ex.Message); } + [Fact] public void CorePlatformException_WithInner() { var inner = new System.Exception(); var ex = new CorePlatformException("err", inner); Assert.Equal(inner, ex.InnerException); } + [Fact] public void CorePlatformException_CodeProperty() { var ex = new CorePlatformException(500, "server error") { Code = 503 }; Assert.Equal(503, ex.Code); } + + // ===== F - Remainder ===== + [Fact] public void F_Remainder_Positive() { Assert.Equal(3, F.Remainder(13, 5)); } + [Fact] public void F_Remainder_Negative() { Assert.Equal(2, F.Remainder(-13, 5)); } + [Fact] public void F_Remainder_Zero() { Assert.Equal(0, F.Remainder(10, 5)); } + + // ===== F - ApplyR ===== + [Fact] public void F_ApplyR_3Args() { System.Func mul = (a, b) => a * b; var f = F.ApplyR(mul, 10); Assert.Equal(20, f(2)); } + [Fact] public void F_ApplyR_4Args() { System.Func add = (a, b, c) => a + b + c; var f = F.ApplyR(add, 10); Assert.Equal(15, f(2, 3)); } + + // ===== F - UnitValue ===== + [Fact] public void F_UnitValue_IsUnit() { Assert.Equal(Unit.Default, F.UnitValue); } + + // ===== F - None ===== + [Fact] public void F_None_IsNoneType() { Assert.IsType(F.None); } + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/CoreExtensionsGapTests.cs b/test/MS.Microservice.Core.Tests/Coverage/CoreExtensionsGapTests.cs new file mode 100644 index 0000000..d0694ff --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/CoreExtensionsGapTests.cs @@ -0,0 +1,121 @@ +using MS.Microservice.Core.Domain.Entity; +using MS.Microservice.Core.Extension; +using MS.Microservice.Core.Security.Summary; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + // ========== ICollectionExtensions ========== + public class ICollectionExtensionsTests + { + [Fact] public void IsNullOrEmpty_Null() { ICollection? c = null; Assert.True(c.IsNullOrEmpty()); } + [Fact] public void IsNullOrEmpty_Empty() { Assert.True(new List().IsNullOrEmpty()); } + [Fact] public void IsNullOrEmpty_NotEmpty() { Assert.False(new List { 1 }.IsNullOrEmpty()); } + + [Fact] public void AddIfNotContains_New() { var l = new List { 1 }; Assert.True(l.AddIfNotContains(2)); Assert.Equal(2, l.Count); } + [Fact] public void AddIfNotContains_Existing() { var l = new List { 1 }; Assert.False(l.AddIfNotContains(1)); Assert.Single(l); } + + [Fact] public void AddIfNotContainsRange() { var l = new List { 1, 2 }; var added = l.AddIfNotContains(new[] { 2, 3, 4 }).ToList(); Assert.Equal(new[] { 3, 4 }, added); Assert.Equal(4, l.Count); } + + [Fact] public void RemoveAll() { ICollection l = new List { 1, 2, 3, 4 }; var removed = l.RemoveAll(x => x % 2 == 0); Assert.Equal(new[] { 2, 4 }, removed); Assert.Equal(new[] { 1, 3 }, l); } + + [Fact] public void ContainsAll_True() { var l = new List { 1, 2, 3 }; Assert.True(l.ContainsAll(new[] { 1, 3 }, EqualityComparer.Default)); } + [Fact] public void ContainsAll_False() { var l = new List { 1, 2, 3 }; Assert.False(l.ContainsAll(new[] { 1, 4 }, EqualityComparer.Default)); } + + [Fact] public void Shuffle_ReturnsSameCount() { var l = new List { 1, 2, 3, 4, 5 }; var result = l.Shuffle(); Assert.Equal(l.Count, result.Count); } + + [Fact] public void IntersectBy() { var a = new[] { 1, 2, 3, 4 }; var b = new[] { 2, 4, 6 }; var r = a.IntersectBy(b, x => x, x => x).ToList(); Assert.Equal(new[] { 2, 4 }, r); } + + [Fact] public void ToDistinctDictionary() { var items = new[] { "a", "b", "a" }; var d = items.ToDistinctDictionary(x => x, x => x.ToUpper()); Assert.Equal(2, d.Count); Assert.Equal("A", d["a"]); } + + [Fact] public void OrderByReference() { var target = new[] { 3, 1, 2 }; var reference = new[] { 1, 2, 3 }; var r = target.OrderByReference(reference).ToList(); Assert.Equal(new[] { 1, 2, 3 }, r); } + + [Fact] public void SafeOrderByReference_Small() { var target = new[] { 3, 1, 2 }; var reference = new[] { 1, 2, 3 }; var r = target.SafeOrderByReference(reference); Assert.Equal(new[] { 1, 2, 3 }, r); } + + [Fact] public void SafeOrderByReference_Large() { var target = Enumerable.Range(0, 200).Reverse().ToArray(); var reference = Enumerable.Range(0, 200).ToArray(); var r = target.SafeOrderByReference(reference); Assert.Equal(reference, r); } + + [Fact] public void OrderByReference_WithSelectors() { var target = new[] { "c", "a", "b" }; var reference = new[] { 3, 1, 2 }; var r = target.OrderByReference(reference, rk => rk, tk => tk[0] - 'a' + 1).ToList(); Assert.Equal(new[] { "c", "a", "b" }, r); } + + [Fact] public void SafeOrderByReference_WithSelectors_Small() { var target = new[] { "c", "a", "b" }; var reference = new[] { 3, 1, 2 }; var r = target.SafeOrderByReference(reference, rk => rk, tk => tk[0] - 'a' + 1); Assert.Equal(new[] { "c", "a", "b" }, r); } + + [Fact] public void SafeOrderByReference_WithSelectors_Large() { var target = Enumerable.Range(0, 200).Select(i => $"x{i:D3}").Reverse().ToArray(); var reference = target.Reverse().ToArray(); var r = target.SafeOrderByReference(reference, rk => rk, tk => tk); Assert.Equal(reference, r); } + } + + // ========== ListHelper ========== + public class ListHelperTests + { + [Fact] public void Shuffle_Static() { var l = new List { 1, 2, 3, 4, 5 }; var r = ListHelper.Shuffle(l); Assert.Equal(5, r.Count); Assert.All(l, x => Assert.Contains(x, r)); } + + [Fact] public void ValidatedShuffle_Single() { var l = new List { 1 }; l.ValidatedShuffle(); Assert.Single(l); } + [Fact] public void ValidatedShuffle_Multiple() { var l = Enumerable.Range(1, 10).ToList(); l.ValidatedShuffle(); Assert.Equal(10, l.Count); Assert.All(Enumerable.Range(1, 10), x => Assert.Contains(x, l)); } + [Fact] public void ValidatedShuffle_Empty() { var l = new List(); l.ValidatedShuffle(); Assert.Empty(l); } + } + + // ========== EntityHelper ========== + // Test entity implementations + public class EntityHelperTestEntity : IEntity + { + private readonly object[] _keys; + public EntityHelperTestEntity(params object[] keys) => _keys = keys; + public object[] GetKeys() => _keys; + } + + public class EntityHelperIntEntity : IEntity + { + public int Id { get; set; } + public object[] GetKeys() => new object[] { Id }; + public EntityHelperIntEntity(int id) => Id = id; + } + + public class EntityHelperLongEntity : IEntity + { + public long Id { get; set; } + public object[] GetKeys() => new object[] { Id }; + public EntityHelperLongEntity(long id) => Id = id; + } + + public class EntityHelperOtherEntity : IEntity + { + private readonly object[] _keys; + public EntityHelperOtherEntity(params object[] keys) => _keys = keys; + public object[] GetKeys() => _keys; + } + + public class EntityHelperTests + { + [Fact] public void EntityEquals_BothNull() { Assert.False(EntityHelper.EntityEquals(null!, null!)); } + [Fact] public void EntityEquals_OneNull() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(1), null!)); Assert.False(EntityHelper.EntityEquals(null!, new EntityHelperTestEntity(1))); } + [Fact] public void EntityEquals_SameReference() { var e = new EntityHelperTestEntity(1); Assert.True(EntityHelper.EntityEquals(e, e)); } + [Fact] public void EntityEquals_DifferentTypes() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(1), new EntityHelperOtherEntity(1))); } + [Fact] public void EntityEquals_DefaultKeys() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(0), new EntityHelperTestEntity(0))); } + [Fact] public void EntityEquals_KeyLengthMismatch() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(1), new EntityHelperTestEntity(1, 2))); } + [Fact] public void EntityEquals_KeysEqual() { Assert.True(EntityHelper.EntityEquals(new EntityHelperTestEntity(1, "a"), new EntityHelperTestEntity(1, "a"))); } + [Fact] public void EntityEquals_KeysNotEqual() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(1), new EntityHelperTestEntity(2))); } + [Fact] public void EntityEquals_NullKey() { Assert.False(EntityHelper.EntityEquals(new EntityHelperTestEntity(1), new EntityHelperTestEntity(new object[] { null! }))); } + + [Fact] public void HasDefaultKeys_True() { Assert.True(EntityHelper.HasDefaultKeys(new EntityHelperTestEntity(0))); } + [Fact] public void HasDefaultKeys_NullKey() { Assert.True(EntityHelper.HasDefaultKeys(new EntityHelperTestEntity(new object[] { null! }))); } + [Fact] public void HasDefaultKeys_False() { Assert.False(EntityHelper.HasDefaultKeys(new EntityHelperTestEntity(1))); } + [Fact] public void HasDefaultKeys_Long() { Assert.True(EntityHelper.HasDefaultKeys(new EntityHelperTestEntity(0L))); } + + [Fact] public void HasDefaultId_True() { Assert.True(EntityHelper.HasDefaultId(new EntityHelperIntEntity(0))); } + [Fact] public void HasDefaultId_False() { Assert.True(EntityHelper.HasDefaultId(new EntityHelperIntEntity(-1))); } + [Fact] public void HasDefaultId_Long() { Assert.True(EntityHelper.HasDefaultId(new EntityHelperLongEntity(0))); Assert.False(EntityHelper.HasDefaultId(new EntityHelperLongEntity(1))); } + } + + // ========== Md5 ========== + public class Md5Tests + { + [Fact] public void Encrypt_Empty() { Assert.Equal(string.Empty, Md5.Encrypt("", Encoding.UTF8)); } + [Fact] public void Encrypt_Whitespace() { Assert.Equal(string.Empty, Md5.Encrypt(" ", Encoding.UTF8)); } + [Fact] public void Encrypt_Normal() { var result = Md5.Encrypt("hello", Encoding.UTF8); Assert.Equal(32, result.Length); } + [Fact] public void Encrypt_Span() { var bytes = Encoding.UTF8.GetBytes("hello"); var result = Md5.Encrypt((ReadOnlySpan)bytes.AsSpan()); Assert.Equal(47, result.Length); } + [Fact] public void Encrypt_Consistency() { Assert.Equal(Md5.Encrypt("abc", Encoding.UTF8), Md5.Encrypt("abc", Encoding.UTF8)); } + } +} + + diff --git a/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGap2Tests.cs b/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGap2Tests.cs new file mode 100644 index 0000000..6dd1cb8 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGap2Tests.cs @@ -0,0 +1,68 @@ +using MS.Microservice.Core.Domain.Entity; +using MS.Microservice.Core.Extension; +using MS.Microservice.Core.Serialization; +using System; +using System.Linq.Expressions; +using System.Numerics; +using System.Text; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class DefaultSerializeSettingTests + { + [Fact] public void Default_NotNull() { Assert.NotNull(DefaultSerializeSetting.Default); } + [Fact] public void Default_PropertyNamingPolicy() { Assert.NotNull(DefaultSerializeSetting.Default.PropertyNamingPolicy); } + [Fact] public void Default_PropertyNameCaseInsensitive() { Assert.True(DefaultSerializeSetting.Default.PropertyNameCaseInsensitive); } + [Fact] public void ChinesseEncoder_NotNull() { Assert.NotNull(DefaultSerializeSetting.ChineseEncoder); } + } + + public class MathExtensionsMoreTests + { + [Fact] public void Round_NegativeDigits_Double() { var v = 1234.56; var r = v.Round(-1); Assert.Equal(1230.0, (double)r, 1); } + [Fact] public void Round_NegativeDigits_Decimal() { var v = 1234.56m; var r = v.Round(-1); Assert.Equal(1230m, (decimal)r); } + [Fact] public void Round_NegativeDigits_Float() { var v = 1234.56f; var r = v.Round(-2); Assert.Equal(1200f, (float)r, 10); } + } + + public class Md5MoreTests + { + [Fact] public void Encrypt_Unicode() { var r = MS.Microservice.Core.Security.Summary.Md5.Encrypt("你好", Encoding.UTF8); Assert.Equal(32, r.Length); } + [Fact] public void Encrypt_SpecialChars() { var r = MS.Microservice.Core.Security.Summary.Md5.Encrypt("!@#$%", Encoding.ASCII); Assert.Equal(32, r.Length); } + } + + public class EntityHelperMoreTests + { + [Fact] public void EntityEquals_Entity1MoreKeys() { Assert.False(EntityHelper.EntityEquals(new EH_Entity(1, 2), new EH_Entity(1))); } + [Fact] public void EntityEquals_Entity1NullKey() { Assert.False(EntityHelper.EntityEquals(new EH_Entity(new object[] { null! }), new EH_Entity(1))); } + [Fact] public void HasDefaultKeys_MultipleKeys_AllDefault() { Assert.True(EntityHelper.HasDefaultKeys(new EH_Entity(0, 0L))); } + [Fact] public void HasDefaultKeys_MultipleKeys_OneNotDefault() { Assert.False(EntityHelper.HasDefaultKeys(new EH_Entity(1, 0L))); } + [Fact] public void HasDefaultId_NegativeLong() { Assert.True(EntityHelper.HasDefaultId(new EH_LongEntity(-1L))); } + [Fact] public void HasDefaultId_IntMaxCheck() { Assert.True(EntityHelper.HasDefaultId(new EH_LongEntity(0L))); } + } + + public class EH_Entity : IEntity + { + private readonly object[] _keys; + public EH_Entity(params object[] keys) => _keys = keys; + public object[] GetKeys() => _keys; + } + + public class EH_LongEntity : IEntity + { + public long Id { get; set; } + public object[] GetKeys() => new object[] { Id }; + public EH_LongEntity(long id) => Id = id; + } + + public class ExpressionStarterMoreTests + { + [Fact] public void Update() { var s = new ExpressionStarter(x => x > 0); var u = s.Update(s.Body, s.Parameters); Assert.NotNull(u); } + [Fact] public void CanReduce() { var s = new ExpressionStarter(true); Assert.False(s.CanReduce); } + [Fact] public void Body() { var s = new ExpressionStarter(x => x > 0); Assert.Equal(ExpressionType.GreaterThan, s.Body.NodeType); } + [Fact] public void NodeType() { var s = new ExpressionStarter(x => x > 0); Assert.Equal(ExpressionType.Lambda, s.NodeType); } + [Fact] public void ReturnType() { var s = new ExpressionStarter(x => x > 0); Assert.Equal(typeof(bool), s.ReturnType); } + [Fact] public void Parameters() { var s = new ExpressionStarter(x => x > 0); Assert.Single(s.Parameters); } + [Fact] public void Type_() { var s = new ExpressionStarter(x => x > 0); Assert.NotNull(s.Type); } + [Fact] public void TailCall() { var s = new ExpressionStarter(x => x > 0); Assert.False(s.TailCall); } + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGapTests.cs b/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGapTests.cs new file mode 100644 index 0000000..1f014dd --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/CoreFinalGapTests.cs @@ -0,0 +1,62 @@ +using MS.Microservice.Core.Extension; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + // ========== IEnumerableExtensions (Core.Extension) ========== + public class IEnumerableExtensionsTests + { + [Fact] public void FindIndex_Found() { Assert.Equal(2, new[] { 1, 2, 3 }.FindIndex(x => x == 3)); } + [Fact] public void FindIndex_NotFound() { Assert.Equal(-1, new[] { 1, 2 }.FindIndex(x => x == 5)); } + [Fact] public void FindIndex_Empty() { Assert.Equal(-1, Array.Empty().FindIndex(x => true)); } + + [Fact] public void ForEach_Action() { int sum = 0; new[] { 1, 2, 3 }.ForEach(x => sum += x); Assert.Equal(6, sum); } + [Fact] public void ForEach_Indexed() { var idx = new List(); new[] { 10, 20 }.ForEach((x, i) => idx.Add(i)); Assert.Equal(new[] { 0, 1 }, idx); } + + [Fact] public void Shuffle_IEnumerable() { var r = new[] { 1, 2, 3, 4, 5 }.Shuffle().ToList(); Assert.Equal(5, r.Count); } + + [Fact] public void Flatten_Simple() { var tree = new[] { new Node { V = 1, Children = new[] { new Node { V = 2 } } } }; var r = tree.Flatten(n => n.Children?.Select(c => c)).Select(n => n.V).ToList(); Assert.Equal(new[] { 1, 2 }, r); } + [Fact] public void Flatten_Empty() { var r = Array.Empty().Flatten(n => n.Children?.Select(c => c)); Assert.Empty(r); } + + [Fact] public void JoinAsString() { Assert.Equal("a,b,c", new[] { "a", "b", "c" }.JoinAsString(",")); } + [Fact] public void JoinAsString_Empty() { Assert.Equal("", Array.Empty().JoinAsString(",")); } + + [Fact] public void ToArray_Convert() { var r = new[] { 1, 2, 3 }.ToArray(x => x * 2); Assert.Equal(new[] { 2, 4, 6 }, r); } + [Fact] public void ToArray_Empty() { Assert.Empty(Array.Empty().ToArray(x => x * 2)); } + } + + public class Node { public int V; public Node[]? Children; } + + // ========== ExpressionStarter ========== + public class ExpressionStarterTests + { + [Fact] public void Default_True() { var s = new ExpressionStarter(true); Assert.True(s.UseDefaultExpression); Assert.False(s.IsStarted); } + [Fact] public void Default_False() { var s = new ExpressionStarter(false); Assert.True(s.UseDefaultExpression); Assert.False(s.IsStarted); } + [Fact] public void Default_NoArg() { var s = new ExpressionStarter(); Assert.False(s.IsStarted); } + [Fact] public void Start_WithExpr() { var s = new ExpressionStarter(x => x > 0); Assert.True(s.IsStarted); } + [Fact] public void Start() { var s = new ExpressionStarter(); s.Start(x => x > 0); Assert.True(s.IsStarted); } + [Fact] public void Start_ThrowsTwice() { var s = new ExpressionStarter(); s.Start(x => x > 0); Assert.Throws(() => s.Start(x => x < 5)); } + [Fact] public void Or_Started() { var s = new ExpressionStarter(); s.Start(x => x > 0); s.Or(x => x < 10); Assert.Equal(ExpressionType.OrElse, s.Body.NodeType); } + [Fact] public void Or_NotStarted() { var s = new ExpressionStarter(); s.Or(x => x > 0); Assert.True(s.IsStarted); } + [Fact] public void And_Started() { var s = new ExpressionStarter(); s.Start(x => x > 0); s.And(x => x < 10); Assert.Equal(ExpressionType.AndAlso, s.Body.NodeType); } + [Fact] public void And_NotStarted() { var s = new ExpressionStarter(); s.And(x => x > 0); Assert.True(s.IsStarted); } + + [Fact] public void ImplicitOperator_Expression() { ExpressionStarter s = new(true); Expression> e = s; Assert.NotNull(e); } + [Fact] public void ImplicitOperator_Func() { ExpressionStarter s = new(true); Func f = s; Assert.NotNull(f); Assert.True(f(1)); } + [Fact] public void ImplicitOperator_FromExpression() { Expression> e = x => x > 0; ExpressionStarter s = e; Assert.True(s.IsStarted); } + + [Fact] public void Compile() { var s = new ExpressionStarter(true); var f = s.Compile(); Assert.True(f(5)); } + + [Fact] public void Properties() { var s = new ExpressionStarter(x => x > 0); Assert.NotNull(s.Body); Assert.NotNull(s.Parameters); Assert.NotNull(s.ToString()); } + + [Fact] public void ToString_NoPredicate() { var s = new ExpressionStarter(false); Assert.Equal("f => False", s.ToString()); } + + [Fact] public void DefaultExpression_Property() { var s = new ExpressionStarter(); s.DefaultExpression = x => true; Assert.True(s.UseDefaultExpression); } + } + + // Functional EnumerableExtensions — extension() methods not testable from another assembly +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/DtoAndSpecificationTests.cs b/test/MS.Microservice.Core.Tests/Coverage/DtoAndSpecificationTests.cs new file mode 100644 index 0000000..bbd908f --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/DtoAndSpecificationTests.cs @@ -0,0 +1,101 @@ +using MS.Microservice.Core.Ceching; +using MS.Microservice.Core.Domain.Entity; +using MS.Microservice.Core.Domain.Extension; +using MS.Microservice.Core.Dto; +using MS.Microservice.Core.Specification; +using System.Linq.Expressions; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + /// + /// Tests for DTOs, specifications, and other 0%-coverage classes. + /// + public class DtoAndSpecificationTests + { + // ===== ResultDto ===== + [Fact] public void ResultDto_Default() { var d = new ResultDto("msg", 400); Assert.False(d.Success); Assert.Equal("msg", d.Message); Assert.Equal(400, d.Code); } + [Fact] public void ResultDto_Full() { var d = new ResultDto(true, "ok", 200); Assert.True(d.Success); Assert.Equal("ok", d.Message); Assert.Equal(200, d.Code); } + [Fact] public void ResultDto_Properties() { var d = new ResultDto(false, "err", 500) { Success = true, Message = "fixed", Code = 200 }; Assert.True(d.Success); } + + // ===== ResultDto ===== + [Fact] public void ResultDtoOfT_DataOnly() { var d = new ResultDto(42); Assert.Equal(42, d.Data); Assert.True(d.Success); Assert.Equal(200, d.Code); } + [Fact] public void ResultDtoOfT_Full() { var d = new ResultDto("val", false, "bad", 400); Assert.Equal("val", d.Data); Assert.False(d.Success); Assert.Equal("bad", d.Message); Assert.Equal(400, d.Code); } + [Fact] public void ResultDtoOfT_Inherits() { var d = new ResultDto(1, false, "msg", 404); var rd = (ResultDto)d; Assert.Equal("msg", rd.Message); } + + // ===== PagedRequestDto ===== + [Fact] public void PagedRequestDto_Default() { var d = new PagedRequestDto(); Assert.Equal(1, d.PageIndex); Assert.Equal(10, d.PageSize); } + [Fact] public void PagedRequestDto_Custom() { var d = new PagedRequestDto(2, 20) { PageIndex = 3, PageSize = 50 }; Assert.Equal(3, d.PageIndex); Assert.Equal(50, d.PageSize); } + + // ===== PagedAndSortedRequestDto ===== + [Fact] public void PagedAndSortedRequestDto_Default() { var d = new PagedAndSortedRequestDto(); Assert.Equal(1, d.PageIndex); Assert.Null(d.Sorting); } + [Fact] public void PagedAndSortedRequestDto_Sorted() { var d = new PagedAndSortedRequestDto() { Sorting = "Name" }; Assert.Equal("Name", d.Sorting); } + + // ===== PagedResultDto ===== + [Fact] public void PagedResultDto_Default() { var d = new PagedResultDto(); Assert.Equal(0, d.TotalCount); Assert.Empty(d.Items); } + [Fact] public void PagedResultDto_WithData() { var d = new PagedResultDto(5, new System.Collections.Generic.List { "a", "b" }); Assert.Equal(5, d.TotalCount); Assert.Equal(2, d.Items.Count); } + + // ===== CacheOptions ===== + [Fact] public void CacheOptions_Default() { var o = new CacheOptions(); Assert.Equal("Fz.Activation.", o.KeyPrefix); Assert.Equal(7200, o.SlidingExpirationSecond); Assert.Null(o.AbsoluteExpirationSecond); } + [Fact] public void CacheOptions_Custom() { var o = new CacheOptions { KeyPrefix = "test.", AbsoluteExpirationSecond = 300, SlidingExpirationSecond = 600 }; Assert.Equal("test.", o.KeyPrefix); Assert.Equal(300, o.AbsoluteExpirationSecond); } + + // ===== Specification ===== + [Fact] public void Specification_Where() { var s = new TestSpec(); s.TestWhere(x => x.Name == "a"); Assert.NotNull(s.Criteria); } + [Fact] public void Specification_Where_Twice_Ands() { var s = new TestSpec(); s.TestWhere(x => x.Name == "a"); s.TestWhere(x => x.Age > 10); Assert.NotNull(s.Criteria); } + [Fact] public void Specification_OrWhere() { var s = new TestSpec(); s.TestOrWhere(x => x.Name == "a"); s.TestOrWhere(x => x.Age > 10); Assert.NotNull(s.Criteria); } + [Fact] public void Specification_Where_Then_OrWhere() { var s = new TestSpec(); s.TestWhere(x => x.Name == "a"); s.TestOrWhere(x => x.Age > 10); Assert.NotNull(s.Criteria); } + [Fact] public void Specification_Includes() { var s = new TestSpec(); s.TestInclude(x => x.Name); Assert.Single(s.Includes); } + [Fact] public void Specification_IncludeList() { var s = new TestSpec(); s.TestIncludeList(x => x.Tags); Assert.Single(s.Includes); } + [Fact] public void Specification_IncludeCollection() { var s = new TestSpec(); s.TestIncludeCollection(x => x.TagsAsCollection); Assert.Single(s.Includes); } + [Fact] public void Specification_OrderBy() { var s = new TestSpec(); s.TestOrderBy(x => x.Name); Assert.Single(s.OrderExpressions); } + [Fact] public void Specification_OrderByDescending() { var s = new TestSpec(); s.TestOrderByDescending(x => x.Name); Assert.Single(s.OrderExpressions); } + [Fact] public void Specification_ThenBy() { var s = new TestSpec(); s.TestThenBy(x => x.Name); Assert.Single(s.OrderExpressions); } + [Fact] public void Specification_ThenByDescending() { var s = new TestSpec(); s.TestThenByDescending(x => x.Name); Assert.Single(s.OrderExpressions); } + [Fact] public void Specification_Paging() { var s = new TestSpec(); s.TestPaging(10, 20); Assert.Equal(10, s.Skip); Assert.Equal(20, s.Take); Assert.True(s.IsPagingEnabled); } + [Fact] public void Specification_IgnoreQueryFilters() { var s = new TestSpec(); s.TestIgnoreFilters(); Assert.True(s.IgnoreQueryFilters); } + + // ===== Specification ===== + [Fact] public void SpecificationWithResult_Select() { var s = new TestResultSpec(); s.TestSelect(x => x.Name); Assert.NotNull(s.Selector); } + + // ===== EntityExtensions ===== + [Fact] public void EntityExtensions_IsNull_Null() { IEntity? e = null; Assert.True(e!.IsNull()); } + [Fact] public void EntityExtensions_IsNull_NotNull() { var e = new TestEntity(); Assert.False(e.IsNull()); } + + private class TestEntity : IEntity + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public System.Collections.Generic.List Tags { get; set; } = new(); + public System.Collections.Generic.ICollection TagsAsCollection { get; set; } = new System.Collections.Generic.List(); + public object[] GetKeys() => new object[] { Name }; + } + } + + public class TestSpec : Specification + { + public void TestWhere(Expression> expr) => Where(expr); + public void TestOrWhere(Expression> expr) => OrWhere(expr); + public void TestInclude(Expression> expr) => Include(expr); + public void TestIncludeList(Expression>> expr) => IncludeList(expr); + public void TestIncludeCollection(Expression>> expr) => IncludeCollection(expr); + public void TestOrderBy(Expression> expr) => OrderBy(expr); + public void TestOrderByDescending(Expression> expr) => OrderByDescending(expr); + public void TestThenBy(Expression> expr) => ThenBy(expr); + public void TestThenByDescending(Expression> expr) => ThenByDescending(expr); + public void TestPaging(int skip, int take) => ApplyPaging(skip, take); + public void TestIgnoreFilters(bool ignore = true) => IgnoreGlobalQueryFilters(ignore); + } + + public class TestEntity + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public System.Collections.Generic.List Tags { get; set; } = new(); + public System.Collections.Generic.ICollection TagsAsCollection { get; set; } = new System.Collections.Generic.List(); + } + + public class TestResultSpec : Specification + { + public void TestSelect(Expression> expr) => Select(expr); + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/ExtensionCoverageTests.cs b/test/MS.Microservice.Core.Tests/Coverage/ExtensionCoverageTests.cs new file mode 100644 index 0000000..6f71650 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/ExtensionCoverageTests.cs @@ -0,0 +1,58 @@ +using System.Numerics; +using Microsoft.System.Collection; +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class ExtensionCoverageTests + { + // ========== MathExtensions - IFloatingPoint ========== + [Fact] public void Math_Round_Double() { Assert.Equal(3.0, 3.14159.Round()); } + [Fact] public void Math_Round_Digits_Double() { Assert.Equal(3.14, 3.14159.Round(2)); } + [Fact] public void Math_Round_Midpoint_Double() { Assert.Equal(3.0, 3.14159.Round(MidpointRounding.ToZero)); } + [Fact] public void Math_Ceiling_Double() { Assert.Equal(4.0, 3.14.Ceiling()); Assert.Equal(-3.0, (-3.14).Ceiling()); } + [Fact] public void Math_Floor_Double() { Assert.Equal(3.0, 3.14.Floor()); Assert.Equal(-4.0, (-3.14).Floor()); } + [Fact] public void Math_Truncate_Double() { Assert.Equal(3.0, 3.99.Truncate()); Assert.Equal(-3.0, (-3.99).Truncate()); } + + // ========== MathExtensions - INumber ========== + [Fact] public void Math_Abs_Int() { Assert.Equal(5, (-5).Abs()); Assert.Equal(0, 0.Abs()); } + [Fact] public void Math_Sign_Int() { Assert.Equal(1, 42.Sign()); Assert.Equal(-1, (-42).Sign()); Assert.Equal(0, 0.Sign()); } + [Fact] public void Math_Min_Double() { Assert.Equal(3.0, 5.0.Min(3.0)); Assert.Equal(3.0, 3.0.Min(5.0)); } + [Fact] public void Math_Max_Double() { Assert.Equal(5.0, 5.0.Max(3.0)); Assert.Equal(5.0, 3.0.Max(5.0)); } + [Fact] public void Math_Clamp_InRange() { Assert.Equal(5.0, 5.0.Clamp(0.0, 10.0)); } + [Fact] public void Math_Clamp_BelowMin() { Assert.Equal(0.0, (-5.0).Clamp(0.0, 10.0)); } + [Fact] public void Math_Clamp_AboveMax() { Assert.Equal(10.0, 20.0.Clamp(0.0, 10.0)); } + [Fact] public void Math_Clamp_InvalidRange() { Assert.Throws(() => 3.0.Clamp(10.0, 0.0)); } + [Fact] public void Math_Abs_Double() { Assert.Equal(3.14, (-3.14).Abs()); } + [Fact] public void Math_Sign_Double() { Assert.Equal(1, 3.14.Sign()); Assert.Equal(-1, (-3.14).Sign()); } + + // ========== MathExtensions - IPowerFunctions ========== + [Fact] public void Math_Pow_Double() { Assert.Equal(8.0, 2.0.Pow(3.0)); } + + // ========== MathExtensions - IRootFunctions ========== + [Fact] public void Math_Sqrt_Double() { Assert.Equal(4.0, 16.0.Sqrt()); } + + // ========== MathExtensions - ILogarithmicFunctions ========== + [Fact] public void Math_Log_Natural() { Assert.Equal(1.0, Math.E.Log(), 1e-10); } + [Fact] public void Math_Log_Base() { Assert.Equal(2.0, 100.0.Log(10.0), 1e-10); } + + // ========== MathExtensions - ITrigonometricFunctions ========== + [Fact] public void Math_Sin_Double() { Assert.Equal(0.0, 0.0.Sin(), 1e-10); Assert.Equal(1.0, (Math.PI / 2).Sin(), 1e-10); } + [Fact] public void Math_Cos_Double() { Assert.Equal(1.0, 0.0.Cos(), 1e-10); } + [Fact] public void Math_Tan_Double() { Assert.Equal(0.0, 0.0.Tan(), 1e-10); } + + // ========== IEnumerableExtensions ========== + [Fact] public void IEnumerable_Shuffle() { var list = new[] { 1, 2, 3, 4, 5 }; var shuffled = list.Shuffle().ToArray(); Assert.Equal(5, shuffled.Length); } + + // ========== FuncExtensions - CurryFirst ========== + [Fact] public void CurryFirst_T3() { var curried = F.CurryFirst((a, b, c) => a + b + c); Assert.Equal(6, curried(1)(2, 3)); } + [Fact] public void CurryFirst_T4() { var curried = F.CurryFirst((a, b, c, d) => a + b + c + d); Assert.Equal(10, curried(1)(2, 3, 4)); } + [Fact] public void CurryFirst_T5() { var curried = F.CurryFirst((a, b, c, d, e) => a + b + c + d + e); Assert.Equal(15, curried(1)(2, 3, 4, 5)); } + [Fact] public void CurryFirst_T6() { var curried = F.CurryFirst((a, b, c, d, e, f) => a + b + c + d + e + f); Assert.Equal(21, curried(1)(2, 3, 4, 5, 6)); } + [Fact] public void CurryFirst_T7() { var curried = F.CurryFirst((a, b, c, d, e, f, g) => a + b + c + d + e + f + g); Assert.Equal(28, curried(1)(2, 3, 4, 5, 6, 7)); } + [Fact] public void CurryFirst_T8() { var curried = F.CurryFirst((a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h); Assert.Equal(36, curried(1)(2, 3, 4, 5, 6, 7, 8)); } + [Fact] public void CurryFirst_T9() { var curried = F.CurryFirst((a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i); Assert.Equal(45, curried(1)(2, 3, 4, 5, 6, 7, 8, 9)); } + + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/LinkedListTests.cs b/test/MS.Microservice.Core.Tests/Coverage/LinkedListTests.cs new file mode 100644 index 0000000..089d7be --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/LinkedListTests.cs @@ -0,0 +1,57 @@ +using System.Linq; +using MS.Microservice.Core.Functional; +using MS.Microservice.Core.Functional.Data.LinkedList; +using static MS.Microservice.Core.Functional.Data.LinkedList.LinkedList; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class LinkedListTests + { + [Fact] public void List_Params() { var l = List(1, 2, 3); Assert.Equal(3, l.Length()); } + [Fact] public void List_Empty() { var l = List(); Assert.Equal(0, l.Length()); } + [Fact] public void List_Head() { var l = List(1, 2, 3); Assert.True(l.Head.IsSome); } + [Fact] public void List_Tail() { var l = List(1, 2, 3); Assert.True(l.Tail.IsSome); } + [Fact] public void List_Index() { var l = List(10, 20, 30); Assert.True(l[1].IsSome); } + [Fact] public void List_Index_Negative() { var l = List(10); Assert.False(l[-1].IsSome); } + [Fact] public void List_AsEnumerable() { var l = List(1, 2); Assert.Equal(new[] { 1, 2 }, l.AsEnumerable()); } + [Fact] public void List_ToString() { var l = List(1, 2); var s = l.ToString(); Assert.Contains("1", s); Assert.Contains("2", s); } + [Fact] public void List_ToString_Empty() { Assert.Equal("{ }", List().ToString()); } + + [Fact] public void Length_Empty() { Assert.Equal(0, List().Length()); } + [Fact] public void Length_Three() { Assert.Equal(3, List(1, 2, 3).Length()); } + + [Fact] public void Add() { var l = List(1, 2); var r = l.Add(0); Assert.Equal(3, r.Length()); } + [Fact] public void Append() { var l = List(1, 2); var r = l.Append(3); Assert.Equal(3, r.Length()); } + + [Fact] public void InsertAt_Head() { var l = List(2, 3); var r = l.InsertAt(0, 1); Assert.True(r.IsSome); } + [Fact] public void InsertAt_Mid() { var l = List(1, 3); var r = l.InsertAt(1, 2); Assert.True(r.IsSome); } + [Fact] public void InsertAt_Negative() { Assert.False(List(1).InsertAt(-1, 0).IsSome); } + [Fact] public void InsertAt_Beyond() { Assert.False(List().InsertAt(5, 1).IsSome); } + + [Fact] public void RemoveAt_Head() { var l = List(1, 2, 3); var r = l.RemoveAt(0); Assert.True(r.IsSome); } + [Fact] public void RemoveAt_Mid() { var l = List(1, 2, 3); var r = l.RemoveAt(1); Assert.True(r.IsSome); } + [Fact] public void RemoveAt_Negative() { Assert.False(List(1).RemoveAt(-1).IsSome); } + + [Fact] public void TakeWhile_All() { var l = List(1, 2, 3); Assert.Equal(3, l.TakeWhile(x => x < 10).Length()); } + [Fact] public void TakeWhile_Partial() { var l = List(1, 2, 3, 0); Assert.Equal(3, l.TakeWhile(x => x > 0).Length()); } + + [Fact] public void DropWhile_All() { var l = List(1, 2, 3); Assert.Equal(0, l.DropWhile(x => x < 10).Length()); } + [Fact] public void DropWhile_Partial() { var l = List(1, 2, 3); Assert.Equal(2, l.DropWhile(x => x < 2).Length()); } + + [Fact] public void Map() { var l = List(1, 2); var r = l.Map(x => x * 2); Assert.Equal(2, r.Length()); Assert.True(r.Head.IsSome); } + [Fact] public void ForEach() { int sum = 0; List(1, 2, 3).ForEach(x => sum += x); Assert.Equal(6, sum); } + + [Fact] public void Bind() { var l = List(2, 3); var r = l.Bind(x => List(x, x * 2)); Assert.Equal(4, r.Length()); } + [Fact] public void Join() { var ll = List(List(1, 2), List(3, 4)); var r = ll.Join(); Assert.Equal(4, r.Length()); } + + [Fact] public void Aggregate_Sum() { var l = List(1, 2, 3); Assert.Equal(6, l.Aggregate(0, (a, x) => a + x)); } + [Fact] public void Aggregate_Empty() { Assert.Equal(10, List().Aggregate(10, (a, x) => a + x)); } + + [Fact] public void TakeWhile_IEnumerable_All() { var e = new[] { 1, 2, 3 }; Assert.Equal(3, LinkedList.TakeWhile(e, x => x > 0).Count()); } + [Fact] public void TakeWhile_IEnumerable_Partial() { var e = new[] { 1, 2, 0, 3 }; Assert.Equal(2, LinkedList.TakeWhile(e, x => x > 0).Count()); } + + [Fact] public void DropWhile_IEnumerable_All() { var e = new[] { 1, 2, 3 }; Assert.Empty(LinkedList.DropWhile(e, x => x < 10)); } + [Fact] public void DropWhile_IEnumerable_Partial() { var e = new[] { 1, 2, 3 }; Assert.Equal(2, LinkedList.DropWhile(e, x => x < 2).Count()); } + } +} diff --git a/test/MS.Microservice.Core.Tests/Coverage/ResultExtensionsAsyncTests.cs b/test/MS.Microservice.Core.Tests/Coverage/ResultExtensionsAsyncTests.cs new file mode 100644 index 0000000..1593a8c --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Coverage/ResultExtensionsAsyncTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using MS.Microservice.Core.Dto; +using MS.Microservice.Core.Functional; +using static MS.Microservice.Core.Extension.ResultExtensions; +using Xunit; + +namespace MS.Microservice.Core.Tests.Coverage +{ + public class ResultExtensionsAsyncTests + { + [Fact] public void Try_Success() { var r = Try(() => 42); Assert.True(r.IsSuccess); Assert.Equal(42, r.Value); } + [Fact] public void Try_Failure() { var r = Try(() => throw new InvalidOperationException("fail")); Assert.True(r.IsFailure); } + [Fact] public void Try_Action_Success() { var r = Try(() => { _ = 1 + 1; }); Assert.True(r.IsSuccess); } + [Fact] public void Try_Action_Failure() { var r = Try(() => throw new InvalidOperationException("fail")); Assert.True(r.IsFailure); } + + [Fact] public async Task TryAsync_Success() { var r = await TryAsync(() => Task.FromResult(42)); Assert.True(r.IsSuccess); Assert.Equal(42, r.Value); } + [Fact] public async Task TryAsync_Failure() { var r = await TryAsync(() => Task.FromException(new InvalidOperationException("fail"))); Assert.True(r.IsFailure); } + [Fact] public async Task TryAsync_Action_Success() { var r = await TryAsync(() => Task.CompletedTask); Assert.True(r.IsSuccess); } + [Fact] public async Task TryAsync_Action_Failure() { var r = await TryAsync(() => Task.FromException(new InvalidOperationException("fail"))); Assert.True(r.IsFailure); } + + // TapAsync: taps on success, passes through on failure + [Fact] public async Task TapAsync_Success_ExecutesEffect() + { + var r = Result.Success(42); + int sideEffect = 0; + var result = await r.TapAsync(x => { sideEffect = x; return Task.CompletedTask; }); + Assert.True(result.IsSuccess); + Assert.Equal(42, result.Value); + Assert.Equal(42, sideEffect); + } + + [Fact] public async Task TapAsync_Failure_SkipsEffect() + { + var r = Result.Fail(new InvalidOperationException("err")); + int sideEffect = 0; + var result = await r.TapAsync(x => { sideEffect = x; return Task.CompletedTask; }); + Assert.True(result.IsFailure); + Assert.Equal(0, sideEffect); + } + + [Fact] public async Task TapAsync_EffectThrows_ReturnsFailure() + { + var r = Result.Success("ok"); + var result = await r.TapAsync(_ => throw new InvalidOperationException("effect fail")); + Assert.True(result.IsFailure); + Assert.Contains("effect fail", result.Error.Message); + } + + // MatchAsync + [Fact] public async Task MatchAsync_Success_ReturnsMappedValue() + { + var r = Result.Success(42); + var result = await r.MatchAsync( + onSuccess: x => Task.FromResult($"ok:{x}"), + onFailure: e => Task.FromResult($"err:{e.Message}")); + Assert.Equal("ok:42", result); + } + + [Fact] public async Task MatchAsync_Failure_ReturnsMappedError() + { + var r = Result.Fail(new InvalidOperationException("boom")); + var result = await r.MatchAsync( + onSuccess: x => Task.FromResult($"ok:{x}"), + onFailure: e => Task.FromResult($"err:{e.Message}")); + Assert.Equal("err:boom", result); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Cryptology/EncryptTest.cs b/test/MS.Microservice.Core.Tests/Cryptology/EncryptTest.cs index c0437b1..7f79ff1 100644 --- a/test/MS.Microservice.Core.Tests/Cryptology/EncryptTest.cs +++ b/test/MS.Microservice.Core.Tests/Cryptology/EncryptTest.cs @@ -1,54 +1,103 @@ -using MS.Microservice.Core.Security.Cryptology; -using Newtonsoft.Json.Linq; using System; using System.Security.Cryptography; using System.Text; +using MS.Microservice.Core.Security.Cryptology; +using Xunit; namespace MS.Microservice.Core.Tests.Cryptology { public class EncryptTest - { [Fact] - public void TestEncrypt() + { + [Fact] + public void DesCrypt_ShouldRoundTrip() { - string plainText = "hello world"; - // TripleDES requires a 24-byte key - string key = "123456789012345678901234"; - string encrypted = CryptologyHelper.DesCrypt.Encrypt(plainText, key); + const string plainText = "hello world"; + const string key = "123456789012345678901234"; + string encrypted = CryptologyHelper.DesCrypt.Encrypt(plainText, key); string decrypted = CryptologyHelper.DesCrypt.Decrypt(encrypted, key); + Assert.Equal(plainText, decrypted); } - /* - TransformFinalBlock 方法和 FlushFinalBlock 方法可以实现相同的加密效果,但在某些情况下,它们可能会产生不同的结果。 - TransformFinalBlock 方法是用于将最后的数据块进行加密转换。它将输入数据块进行加密并返回加密后的结果。这个方法会处理最后不完整的数据块,并将其加密。在使用 TransformFinalBlock 方法时,可以确保所有数据都已被加密处理。 - - FlushFinalBlock 方法用于确保所有数据都被加密,并且将加密后的数据写入到底层流中。这个方法不返回加密结果,而是将加密后的数据直接写入到流中。它适用于流式处理数据的场景。 + [Fact] + public void DesCrypt_ShouldMatchManualTripleDesResult() + { + const string plainText = "hello world"; + const string key = "123456789012345678901234"; + + string encrypted = CryptologyHelper.DesCrypt.Encrypt(plainText, key); + string manual = EncryptWithTripleDes(plainText, key, "12345678"); + + Assert.Equal(encrypted, manual); + } + + [Fact] + public void AesCrypt_ShouldRoundTrip_WithAutoHandledShortKey() + { + const string key = "short-key"; + const string content = "payload"; + + string encrypted = CryptologyHelper.AesCrypt.Encrypt(key, content); + string decrypted = CryptologyHelper.AesCrypt.Decrypt(key, encrypted); - 这两种方法的实现细节和使用方式略有不同,因此在一些特定的情况下,它们可能会产生不同的加密结果。一种常见的情况是当使用了填充模式(如 PKCS7)时,TransformFinalBlock 方法会将最后的数据块进行填充后再进行加密,而 FlushFinalBlock 方法则不会填充数据块,直接进行加密。这可能会导致最后一个数据块的加密结果不同。 - */ [Fact] - public void TestEncrypt2() + Assert.Equal(content, decrypted); + } + + [Fact] + public void AesCrypt_ShouldRoundTrip_WithExactLengthKey() { - string cipherText = "hello world"; - // TripleDES requires a 24-byte key - string key = "123456789012345678901234"; - string encrypt = CryptologyHelper.DesCrypt.Encrypt(cipherText, key); + const string key = "1234567890ABCDEF"; + const string content = "payload"; + + string encrypted = CryptologyHelper.AesCrypt.Encrypt(key, content, autoHandle: false); + string decrypted = CryptologyHelper.AesCrypt.Decrypt(key, encrypted, autoHandle: false); - string encrypt2 = Encrypt(cipherText, key, "12345678"); - Assert.Equal(encrypt, encrypt2); + Assert.Equal(content, decrypted); } - public static string Encrypt(string cipherText, string key, string iv) + [Fact] + public void RsaCrypt_ShouldRoundTrip_WithPkcs8Keys() { - var tripleDES = TripleDES.Create(); - tripleDES.Key = Encoding.UTF8.GetBytes(key); - tripleDES.IV = Encoding.UTF8.GetBytes(iv); - tripleDES.Mode = CipherMode.CBC; - tripleDES.Padding = PaddingMode.PKCS7; - using var encrypt = tripleDES.CreateEncryptor(); - byte[] inputBuffer = Encoding.UTF8.GetBytes(cipherText); - var encryptBuffer = encrypt.TransformFinalBlock(inputBuffer, 0, inputBuffer.Length); - return Convert.ToBase64String(encryptBuffer); + using var rsa = RSA.Create(1024); + string publicKey = Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()); + string privateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()); + const string content = "hello world"; + + string encrypted = CryptologyHelper.RsaCrypt.Encrypt(content, publicKey, Encoding.UTF8); + string decrypted = CryptologyHelper.RsaCrypt.Decrypt(encrypted, privateKey, Encoding.UTF8); + + Assert.Equal(content, decrypted); + } + + [Fact] + public void RsaCrypt_ShouldThrow_WhenPublicKeyIsNotBase64() + { + Assert.Throws(() => + CryptologyHelper.RsaCrypt.Encrypt("hello", "not-base64", Encoding.UTF8)); + } + + [Fact] + public void RsaCrypt_ShouldThrow_WhenDecodedPublicKeyIsInvalid() + { + string invalidKey = Convert.ToBase64String([1, 2, 3, 4]); + + Assert.Throws(() => + CryptologyHelper.RsaCrypt.Encrypt("hello", invalidKey, Encoding.UTF8)); + } + + private static string EncryptWithTripleDes(string plainText, string key, string iv) + { + using var tripleDes = TripleDES.Create(); + tripleDes.Key = Encoding.UTF8.GetBytes(key); + tripleDes.IV = Encoding.UTF8.GetBytes(iv); + tripleDes.Mode = CipherMode.CBC; + tripleDes.Padding = PaddingMode.PKCS7; + + using var encryptor = tripleDes.CreateEncryptor(); + byte[] inputBuffer = Encoding.UTF8.GetBytes(plainText); + byte[] encryptedBuffer = encryptor.TransformFinalBlock(inputBuffer, 0, inputBuffer.Length); + return Convert.ToBase64String(encryptedBuffer); } } } diff --git a/test/MS.Microservice.Core.Tests/Domain/EntityHelperEdgeTests.cs b/test/MS.Microservice.Core.Tests/Domain/EntityHelperEdgeTests.cs new file mode 100644 index 0000000..28a4f00 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/EntityHelperEdgeTests.cs @@ -0,0 +1,63 @@ +using MS.Microservice.Core.Domain.Entity; +using MS.Microservice.Domain; +using Xunit; + +namespace MS.Microservice.Core.Tests.Domain +{ + public class EntityHelperEdgeTests + { + private class TestEntity : Entity + { + public TestEntity(int id) { Id = id; } + } + + private class TestLongEntity : Entity + { + public TestLongEntity(long id) { Id = id; } + } + + // EntityEquals: both keys null → continue, then both default + [Fact] + public void EntityEquals_DifferentLengthKeys_ReturnsFalse() + { + // Composite entity vs simple entity would have different key lengths + // For simple entities: one key each, so they match on length + // This tests the key length comparison path + var e1 = new TestEntity(1); + var e2 = new TestEntity(2); + Assert.False(EntityHelper.EntityEquals(e1, e2)); + } + + // HasDefaultKeys: tests all branches + [Fact] + public void HasDefaultKeys_Test() + { + var e0 = new TestEntity(0); + var e1 = new TestEntity(1); + Assert.True(EntityHelper.HasDefaultKeys(e0)); + Assert.False(EntityHelper.HasDefaultKeys(e1)); + } + + // HasDefaultId: long type + [Fact] + public void HasDefaultId_LongZero_ReturnsTrue() + { + var e = new TestLongEntity(0L); + Assert.True(EntityHelper.HasDefaultId(e)); + } + + [Fact] + public void HasDefaultId_LongPositive_ReturnsFalse() + { + var e = new TestLongEntity(100L); + Assert.False(EntityHelper.HasDefaultId(e)); + } + + [Fact] + public void HasDefaultId_LongNegative_ReturnsTrue() + { + var e = new TestLongEntity(-1L); + Assert.True(EntityHelper.HasDefaultId(e)); + } + } +} \ No newline at end of file diff --git a/test/MS.Microservice.Core.Tests/Domain/EventSourcing/EventSourcingAbstractionsTests.cs b/test/MS.Microservice.Core.Tests/Domain/EventSourcing/EventSourcingAbstractionsTests.cs new file mode 100644 index 0000000..7114556 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/EventSourcing/EventSourcingAbstractionsTests.cs @@ -0,0 +1,48 @@ +using System; +using FluentAssertions; +using MS.Microservice.Domain.EventSourcing; +using Xunit; + +namespace MS.Microservice.Core.Tests.Domain.EventSourcing; + +public sealed class EventSourcingAbstractionsTests +{ + [Fact] + public void Records_ShouldStoreAssignedValues() + { + var metadata = new EventMetadata("corr", "cause", "user", "tenant", "trace", 2); + var createdAt = DateTimeOffset.UtcNow; + var envelope = new EventEnvelope( + Guid.NewGuid(), + "stream-1", + "order", + 3, + 10, + new SampleEvent("created"), + metadata, + createdAt); + var snapshot = new AggregateSnapshot("stream-1", "order", 4, 42, createdAt); + + envelope.StreamId.Should().Be("stream-1"); + envelope.Data.Name.Should().Be("created"); + envelope.Metadata.Should().Be(metadata); + snapshot.State.Should().Be(42); + snapshot.Version.Should().Be(4); + } + + [Fact] + public void EventStoreConcurrencyException_ShouldExposeProperties() + { + var inner = new InvalidOperationException("boom"); + var exception = new EventStoreConcurrencyException("order-1", 2, 3, inner); + + exception.StreamId.Should().Be("order-1"); + exception.ExpectedVersion.Should().Be(2); + exception.ActualVersion.Should().Be(3); + exception.InnerException.Should().BeSameAs(inner); + exception.Message.Should().Contain("order-1"); + exception.Message.Should().Contain("期望版本 2"); + } + + private sealed record SampleEvent(string Name) : IEventSourcedEvent; +} diff --git a/test/MS.Microservice.Core.Tests/Domain/IdentityModel/PasswordSaltHelperTests.cs b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/PasswordSaltHelperTests.cs new file mode 100644 index 0000000..d1adbc3 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/PasswordSaltHelperTests.cs @@ -0,0 +1,17 @@ +using FluentAssertions; +using MS.Microservice.Domain.Aggregates.IdentityModel; +using Xunit; + +namespace MS.Microservice.Core.Tests.Domain.IdentityModel; + +public sealed class PasswordSaltHelperTests +{ + [Fact] + public void Generate_ShouldReturnFourCharacterLowercaseAlphaNumericSalt() + { + string salt = PasswordSaltHelper.Generate(); + + salt.Should().HaveLength(4); + salt.Should().MatchRegex("^[0-9a-z]{4}$"); + } +} diff --git a/test/MS.Microservice.Core.Tests/Domain/IdentityModel/RoleTests.cs b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/RoleTests.cs new file mode 100644 index 0000000..8802945 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/RoleTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using MS.Microservice.Domain.Aggregates.IdentityModel; +using Xunit; +using DomainAction = MS.Microservice.Domain.Aggregates.IdentityModel.Action; + +namespace MS.Microservice.Core.Tests.Domain.IdentityModel; + +public sealed class RoleTests +{ + [Fact] + public void Role_ShouldInitializeCollections_AndAddAction() + { + var role = new Role(1, "Admin", "管理员"); + + role.Users.Should().BeEmpty(); + role.Actions.Should().BeEmpty(); + + role.AddAction("ViewUsers", "/users"); + + role.Actions.Should().ContainSingle(); + role.Actions[0].Should().BeOfType(); + role.Actions[0].Name.Should().Be("ViewUsers"); + role.Actions[0].Path.Should().Be("/users"); + } + + [Fact] + public void RoleComparer_ShouldCompareById() + { + var comparer = new RoleComparer(); + var left = new Role(1, "Admin", "管理员"); + var same = new Role(1, "Guest", "访客"); + var different = new Role(2, "Admin", "管理员"); + + comparer.Equals(left, same).Should().BeTrue(); + comparer.Equals(left, different).Should().BeFalse(); + comparer.GetHashCode(left).Should().Be("Admin".GetHashCode()); + } + + [Fact] + public void UserRole_And_RoleAction_Constructors_ShouldSetIds() + { + var userRole = new UserRole(3, 4); + var roleAction = new RoleAction(5, 6); + + userRole.UserId.Should().Be(3); + userRole.RoleId.Should().Be(4); + roleAction.RoleId.Should().Be(5); + roleAction.ActionId.Should().Be(6); + } +} diff --git a/test/MS.Microservice.Core.Tests/Domain/IdentityModel/UserTests.cs b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/UserTests.cs new file mode 100644 index 0000000..9808f08 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/IdentityModel/UserTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using FluentAssertions; +using MS.Microservice.Core.Security.Cryptology; +using MS.Microservice.Domain.Aggregates.IdentityModel; +using Xunit; + +namespace MS.Microservice.Core.Tests.Domain.IdentityModel; + +public sealed class UserTests +{ + [Fact] + public void User_ShouldBeTransient_AndIgnoreDuplicateRoles() + { + var user = CreateUser(); + + user.IsTransient().Should().BeTrue(); + + user.AddRole(new Role(1, "Admin", "管理员")); + user.AddRole(new Role(1, "Admin", "管理员")); + + user.Roles.Should().ContainSingle(); + } + + [Fact] + public void Delete_ShouldSetDeletedAt() + { + var user = CreateUser(); + + user.Delete(); + + user.DeletedAt.Should().NotBeNull(); + } + + [Fact] + public void ChangePassword_ShouldHashPassword() + { + var user = CreateUser(password: "Password123", salt: "salt-value"); + + Invoke(user, "ChangePassword"); + + user.Password.Should().Be(CryptologyHelper.HmacSha256("Password123salt-value")); + } + + [Fact] + public void Update_ShouldOnlyReplaceNonEmptyValues() + { + var user = CreateUser(); + + Invoke(user, "Update", "New Name", null, "", "new-password", "new-salt"); + + user.Name.Should().Be("New Name"); + user.Telephone.Should().Be("13800138000"); + user.Email.Should().Be("demo@example.com"); + user.Password.Should().Be("new-password"); + user.Salt.Should().Be("new-salt"); + } + + private static User CreateUser(string password = "Password123", string salt = "salt") + => new("demo", password, salt, false, "13800138000", 1, 1, "demo@example.com", "Demo", "fz-demo", "fz-id"); + + private static void Invoke(User user, string methodName, params object?[] parameters) + { + typeof(User) + .GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(user, parameters); + } +} diff --git a/test/MS.Microservice.Core.Tests/Domain/Repository/IUnitOfWorkExtensionsTests.cs b/test/MS.Microservice.Core.Tests/Domain/Repository/IUnitOfWorkExtensionsTests.cs new file mode 100644 index 0000000..4992886 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Domain/Repository/IUnitOfWorkExtensionsTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading.Tasks; +using MS.Microservice.Core.Domain.Repository; +using MS.Microservice.Core.Domain.Repository.Extensions; +using MS.Microservice.Core.Domain.Repository.SqlSugar; +using MS.Microservice.Core.Dto; +using MS.Microservice.Core.Functional; +using NSubstitute; +using Xunit; + +namespace MS.Microservice.Core.Tests.Domain.Repository; + +public sealed class IUnitOfWorkExtensionsTests +{ + [Fact] + public async Task SaveChangesEitherAsync_WhenSaveChangesThrows_ShouldReturnLeft() + { + var unitOfWork = Substitute.For(); + unitOfWork.SaveChangesAsync(default).Returns(_ => Task.FromException(new InvalidOperationException("boom"))); + + var result = await unitOfWork.SaveChangesEitherAsync(); + + Assert.True(result.IsLeft); + Assert.Equal("persistence.save_changes", result.Left.Code); + } + + [Fact] + public async Task SaveEntitiesEitherAsync_WhenSaveEntitiesReturnsFalse_ShouldReturnUnexpectedLeft() + { + var unitOfWork = Substitute.For(); + unitOfWork.SaveEntitiesAsync(default).Returns(false); + + var result = await unitOfWork.SaveEntitiesEitherAsync(); + + Assert.True(result.IsLeft); + Assert.Equal("unexpected", result.Left.Code); + } + + [Fact] + public async Task SaveEntitiesResultAsync_WhenSaveEntitiesReturnsFalse_ShouldReturnFailure() + { + var unitOfWork = Substitute.For(); + unitOfWork.SaveEntitiesAsync(default).Returns(false); + + Result result = await unitOfWork.SaveEntitiesResultAsync(); + + Assert.True(result.IsFailure); + } + + [Fact] + public async Task UnitOfWorkAsync_WhenExecutionSucceeds_ShouldCommit() + { + var unitOfWork = Substitute.For(); + + int result = await unitOfWork.UnitOfWorkAsync(async () => + { + await Task.CompletedTask; + return 42; + }); + + Assert.Equal(42, result); + await unitOfWork.Received(1).BeginAsync(); + await unitOfWork.Received(1).CommitAsync(); + await unitOfWork.DidNotReceive().RollbackAsync(); + } + + [Fact] + public async Task UnitOfWorkAsync_WhenExecutionThrows_ShouldRollback() + { + var unitOfWork = Substitute.For(); + + await Assert.ThrowsAsync(() => + unitOfWork.UnitOfWorkAsync(async () => + { + await Task.CompletedTask; + throw new InvalidOperationException("boom"); + })); + + await unitOfWork.Received(1).BeginAsync(); + await unitOfWork.Received(1).RollbackAsync(); + await unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task UnitOfWorkEitherAsync_WhenBusinessReturnsLeft_ShouldRollback() + { + var unitOfWork = Substitute.For(); + + var result = await unitOfWork.UnitOfWorkEitherAsync(async () => + { + await Task.CompletedTask; + return (Either)F.Left(Error.Validation("invalid")); + }); + + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + await unitOfWork.Received(1).RollbackAsync(); + await unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task UnitOfWorkResultAsync_WhenResultFails_ShouldRollback() + { + var unitOfWork = Substitute.For(); + + Result result = await unitOfWork.UnitOfWorkResultAsync(async () => + { + await Task.CompletedTask; + return Result.Fail(new InvalidOperationException("invalid")); + }); + + Assert.True(result.IsFailure); + await unitOfWork.Received(1).RollbackAsync(); + await unitOfWork.DidNotReceive().CommitAsync(); + } +} diff --git a/test/MS.Microservice.Core.Tests/Dto/ResultTests.cs b/test/MS.Microservice.Core.Tests/Dto/ResultTests.cs new file mode 100644 index 0000000..d4c2add --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Dto/ResultTests.cs @@ -0,0 +1,134 @@ +using MS.Microservice.Core.Dto; +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Dto +{ + public class ResultTests + { + [Fact] + public void Success_HasValue() + { + var r = Result.Success(42); + Assert.True(r.IsSuccess); + Assert.False(r.IsFailure); + Assert.Equal(42, r.Value); + } + + [Fact] + public void Fail_HasError() + { + var err = new InvalidOperationException("boom"); + var r = Result.Fail(err); + Assert.True(r.IsFailure); + Assert.False(r.IsSuccess); + Assert.Equal("boom", r.Error.Message); + } + + [Fact] + public void ImplicitFromValue_CreatesSuccess() + { + Result r = "hello"; + Assert.True(r.IsSuccess); + Assert.Equal("hello", r.Value); + } + + [Fact] + public void Value_OnFailure_Throws() + { + var r = Result.Fail(new Exception("err")); + Assert.Throws(() => r.Value); + } + + [Fact] + public void Error_OnSuccess_Throws() + { + var r = Result.Success(1); + Assert.Throws(() => r.Error); + } + + [Fact] + public void Match_OnSuccess_CallsOnSuccess() + { + var r = Result.Success(10); + var result = r.Match(v => v * 2, e => 0); + Assert.Equal(20, result); + } + + [Fact] + public void Match_OnFailure_CallsOnFailure() + { + var r = Result.Fail(new InvalidOperationException("bad")); + var result = r.Match(v => 0, e => -1); + Assert.Equal(-1, result); + } + + [Fact] + public void Match_Action_OnSuccess() + { + var r = Result.Success(5); + int captured = 0; + var unit = r.Match(v => { captured = v; }, e => { }); + Assert.Equal(5, captured); + Assert.Equal(Unit.Default, unit); + } + + [Fact] + public void Match_Action_OnFailure() + { + var r = Result.Fail(new InvalidOperationException("fail")); + string captured = ""; + r.Match(v => { }, e => { captured = e.Message; }); + Assert.Equal("fail", captured); + } + + [Fact] + public void Map_OnSuccess_Transforms() + { + var r = Result.Success(5); + var mapped = r.Map(v => v * 2); + Assert.True(mapped.IsSuccess); + Assert.Equal(10, mapped.Value); + } + + [Fact] + public void Map_OnFailure_PreservesError() + { + var err = new InvalidOperationException("bad"); + var r = Result.Fail(err); + var mapped = r.Map(v => v * 2); + Assert.True(mapped.IsFailure); + Assert.Same(err, mapped.Error); + } + + [Fact] + public void Bind_OnSuccess_Chains() + { + var r = Result.Success(5); + var bound = r.Bind(v => Result.Success($"val:{v}")); + Assert.True(bound.IsSuccess); + Assert.Equal("val:5", bound.Value); + } + + [Fact] + public void Bind_OnFailure_ShortCircuits() + { + var err = new InvalidOperationException("fail"); + var r = Result.Fail(err); + bool called = false; + var bound = r.Bind(v => { called = true; return Result.Success("x"); }); + Assert.True(bound.IsFailure); + Assert.Same(err, bound.Error); + Assert.False(called); + } + + [Fact] + public void Bind_CanFail() + { + var r = Result.Success(5); + var bound = r.Bind(v => Result.Fail(new ArgumentException("invalid"))); + Assert.True(bound.IsFailure); + Assert.Equal("invalid", bound.Error.Message); + } + } +} \ No newline at end of file diff --git a/test/MS.Microservice.Core.Tests/Extensions/CollectionExtensionsTest.cs b/test/MS.Microservice.Core.Tests/Extensions/CollectionExtensionsTest.cs index 5ba5887..a58df7b 100644 --- a/test/MS.Microservice.Core.Tests/Extensions/CollectionExtensionsTest.cs +++ b/test/MS.Microservice.Core.Tests/Extensions/CollectionExtensionsTest.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using MS.Microservice.Core.Extension; +using Xunit; namespace MS.Microservice.Core.Tests.Extensions { @@ -22,7 +23,6 @@ public void TestIntersectBy() new Employee { FirstName = "David", EmployeeId = 2 } ]; - // 使用 IntersectBy 查找名称相同的人和员工 var commonNames = people.IntersectBy(employees, person => person.Name, employee => employee.FirstName); Assert.NotEmpty(commonNames); Assert.Equal("Alice", commonNames.First().Name); @@ -82,7 +82,6 @@ public void TestOrderByReference2() Assert.Equal("Charlie", sortedOrders[1].CustomerName); } - // SafeOrderByReference 基础(小数据集) [Fact] public void TestSafeOrderByReference_Basic_Small() { @@ -95,7 +94,6 @@ public void TestSafeOrderByReference_Basic_Small() Assert.Equal(new List { 1, 3, 5 }, result); } - // SafeOrderByReference 含未匹配项(小数据集) [Fact] public void TestSafeOrderByReference_WithUnmatched_Small() { @@ -107,7 +105,6 @@ public void TestSafeOrderByReference_WithUnmatched_Small() Assert.Equal(new List { 1, 3, 5, 7, 9 }, result); } - // SafeOrderByReference 含重复项(小数据集) [Fact] public void TestSafeOrderByReference_WithDuplicates_Small() { @@ -119,25 +116,21 @@ public void TestSafeOrderByReference_WithDuplicates_Small() Assert.Equal(new List { 3, 1, 3, 2, 1, 5 }, result); } - // SafeOrderByReference 大数据集合(触发队列/字典分支) [Fact] public void TestSafeOrderByReference_LargeDataset() { - // 构造 200 个元素的集合(阈值为 100,应触发大数据分支) var reference = Enumerable.Range(0, 200).ToList(); var target = Enumerable.Range(100, 100).Concat(Enumerable.Range(0, 100)).ToList(); var result = target.SafeOrderByReference(reference); Assert.Equal(200, result.Count); - // 结果应当严格按照 reference 排序 for (int i = 0; i < 200; i++) { Assert.Equal(i, result[i]); } } - // SafeOrderByReference 带选择器(基础) [Fact] public void TestSafeOrderByReference2_Basic() { @@ -162,7 +155,6 @@ public void TestSafeOrderByReference2_Basic() Assert.Equal("Charlie", sortedOrders[1].CustomerName); } - // SafeOrderByReference 带选择器(重复与未匹配) [Fact] public void TestSafeOrderByReference2_WithDuplicatesAndUnmatched() { @@ -183,9 +175,6 @@ public void TestSafeOrderByReference2_WithDuplicatesAndUnmatched() .SafeOrderByReference(people, p => p.Name!, o => o.CustomerName) .ToList(); - // 期望先 Charlie(在 reference 中排第一,但 target 中只有 1 个) - // 再 Alice(两个按 target 原有相对顺序) - // 最后未匹配的 Eve Assert.Equal(4, sortedOrders.Count); Assert.Equal("Charlie", sortedOrders[0].CustomerName); Assert.Equal("Alice", sortedOrders[1].CustomerName); @@ -193,6 +182,39 @@ public void TestSafeOrderByReference2_WithDuplicatesAndUnmatched() Assert.Equal("Alice", sortedOrders[3].CustomerName); } + [Fact] + public void TestOrderByReference_WithUnmatched_ItemsStayAtEnd() + { + List reference = [1, 3]; + List target = [5, 3, 1, 9]; + + var result = target.OrderByReference(reference).ToList(); + + Assert.Equal(new List { 1, 3, 5, 9 }, result); + } + + [Fact] + public void TestValidatedShuffle_ShouldCreateDerangement_ForUniqueItems() + { + List list = [1, 2, 3, 4, 5]; + var original = list.ToList(); + + list.ValidatedShuffle(); + + Assert.Equal(original.OrderBy(x => x), list.OrderBy(x => x)); + Assert.All(list.Zip(original), pair => Assert.NotEqual(pair.Second, pair.First)); + } + + [Fact] + public void TestValidatedShuffle_SingleItem_ShouldRemainUnchanged() + { + List list = [1]; + + list.ValidatedShuffle(); + + Assert.Equal(new List { 1 }, list); + } + class Person { public string? Name { get; set; } diff --git a/test/MS.Microservice.Core.Tests/Extensions/ListHelperTests.cs b/test/MS.Microservice.Core.Tests/Extensions/ListHelperTests.cs new file mode 100644 index 0000000..2f107dd --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Extensions/ListHelperTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using MS.Microservice.Core.Extension; + +namespace MS.Microservice.Core.Tests.Extensions +{ + public class ListHelperTests + { + private record Item(string Name) : IEquatable + { + public virtual bool Equals(Item? other) => + other is not null && Name == other.Name; + public override int GetHashCode() => Name.GetHashCode(); + } + + [Fact] + public void ValidatedShuffle_WithValues_DoesNotThrow() + { + var list = new List + { + new("a"), new("b"), new("c"), new("d"), new("e"), + new("f"), new("g"), new("h"), new("i"), new("j"), + new("k"), new("l"), new("m"), new("n"), new("o"), + }; + list.ValidatedShuffle(); + list.Should().HaveCount(15); + } + + [Fact] + public void ValidatedShuffle_SingleItem_NoOp() + { + var list = new List { new("only") }; + list.ValidatedShuffle(); + list[0].Name.Should().Be("only"); + } + + [Fact] + public void ValidatedShuffle_EmptyList_NoOp() + { + var list = new List(); + list.ValidatedShuffle(); + list.Should().BeEmpty(); + } + + [Fact] + public void Shuffle_ReturnsSameElementsDifferentOrder() + { + IList list = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var shuffled = list.Shuffle(); + shuffled.Should().HaveSameCount(list); + shuffled.OrderBy(x => x).Should().Equal(list.OrderBy(x => x)); + } + + [Fact] + public void ValidatedShuffle_Duplicates_DoesNotThrow() + { + var list = new List + { + new("a"), new("a"), new("b"), new("b"), new("c"), new("c"), + }; + list.ValidatedShuffle(); + list.Should().HaveCount(6); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Functional/CountryCodeTests.cs b/test/MS.Microservice.Core.Tests/Functional/CountryCodeTests.cs new file mode 100644 index 0000000..620ab9c --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Functional/CountryCodeTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using MS.Microservice.Core.Functional; + +namespace MS.Microservice.Core.Tests.Functional +{ + public class CountryCodeTests + { + [Fact] + public void CountryCode_Constructor_StoresValue() + { + var cc = new CountryCode("cn"); + cc.ToString().Should().Be("cn"); + } + + [Fact] + public void CountryCode_Null_Throws() + { + Assert.Throws(() => new CountryCode(null!)); + } + + [Fact] + public void CountryCode_ImplicitToString_ReturnsValue() + { + CountryCode cc = new("cn"); + string s = cc; + s.Should().Be("cn"); + } + + [Fact] + public void CountryCode_ImplicitFromString_CreatesCountryCode() + { + CountryCode cc = "cn"; + cc.ToString().Should().Be("cn"); + } + + [Fact] + public void PhoneNumber_Create_RoundTrips() + { + var cc = new CountryCode("uk"); + var number = new PhoneNumber.Number(); + var pn = PhoneNumber.Create(PhoneNumber.NumberType.Mobile, cc, number); + pn.Type.Should().Be(PhoneNumber.NumberType.Mobile); + pn.CountryCode.ToString().Should().Be("uk"); + pn.ToString().Should().StartWith("Mobile: +uk "); + } + + [Fact] + public void ValidCountryCode_Uk_IsValid() + { + var cc = new CountryCode("uk"); + var result = PhoneNumber.ValidCountryCode(cc); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidCountryCode_Us_IsValid() + { + var cc = new CountryCode("us"); + var result = PhoneNumber.ValidCountryCode(cc); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidCountryCode_Unsupported_IsInvalid() + { + var cc = new CountryCode("cn"); + var result = PhoneNumber.ValidCountryCode(cc); + result.IsInvalid.Should().BeTrue(); + } + + [Fact] + public void ValidNumberType_Mobile_IsValid() + { + var result = PhoneNumber.ValidNumberType(PhoneNumber.NumberType.Mobile); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidNumberType_Home_IsValid() + { + var result = PhoneNumber.ValidNumberType(PhoneNumber.NumberType.Home); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void ValidNumber_AnyValue_IsValid() + { + var result = PhoneNumber.ValidNumber(new PhoneNumber.Number()); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void CreatePhoneNumber_ValidInputs_ReturnsValid() + { + var result = PhoneNumber.CreatePhoneNumber( + PhoneNumber.NumberType.Mobile, + new CountryCode("uk"), + new PhoneNumber.Number()); + + result.IsValid.Should().BeTrue(); + result.Valid.Type.Should().Be(PhoneNumber.NumberType.Mobile); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Functional/EitherExtensionsMoreTests.cs b/test/MS.Microservice.Core.Tests/Functional/EitherExtensionsMoreTests.cs new file mode 100644 index 0000000..3bb3a03 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Functional/EitherExtensionsMoreTests.cs @@ -0,0 +1,276 @@ +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Functional +{ + public class EitherExtensionsMoreTests + { + [Fact] + public async Task LeftAsync_CreatesTaskWithLeft() + { + var task = EitherExtensions.LeftAsync(Error.Validation("bad")); + var result = await task; + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + } + + [Fact] + public async Task RightAsync_CreatesTaskWithRight() + { + var task = EitherExtensions.RightAsync(42); + var result = await task; + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task AsTask_CreatesTaskFromEither() + { + Either either = F.Right(42); + var result = await either.AsTask(); + Assert.True(result.IsRight); + } + + [Fact] + public void Try_WhenSucceeds_ReturnsRight() + { + var result = EitherExtensions.Try(() => 42); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public void Try_WhenThrows_ReturnsLeftWithDetails() + { + var result = EitherExtensions.Try(() => throw new InvalidOperationException("boom"), code: "test"); + Assert.True(result.IsLeft); + Assert.Equal("test", result.Left.Code); + Assert.Contains("InvalidOperationException", result.Left.DetailsOrEmpty.Single()); + } + + [Fact] + public void Try_Action_WhenSucceeds_ReturnsUnit() + { + var result = EitherExtensions.Try(() => { }, "action-test"); + Assert.True(result.IsRight); + Assert.Equal(Unit.Default, result.Right); + } + + [Fact] + public void Try_Action_WhenThrows_ReturnsLeft() + { + var result = EitherExtensions.Try(() => throw new InvalidOperationException("action boom"), "action-test"); + Assert.True(result.IsLeft); + Assert.Equal("action-test", result.Left.Code); + } + + [Fact] + public async Task TryAsync_Action_WhenSucceeds_ReturnsUnit() + { + var result = await EitherExtensions.TryAsync(() => Task.CompletedTask, "async-action"); + Assert.True(result.IsRight); + } + + [Fact] + public async Task TryAsync_Action_WhenThrows_ReturnsLeft() + { + var result = await EitherExtensions.TryAsync( + () => throw new InvalidOperationException("async boom"), "async-action"); + Assert.True(result.IsLeft); + Assert.Equal("async-action", result.Left.Code); + } + + [Fact] + public async Task Map_Task_TransformsRight() + { + Task> task = Task.FromResult((Either)F.Right(21)); + var result = await task.Map(x => x * 2); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task Map_Task_WhenLeft_PreservesError() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.Map(x => x * 2); + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + } + + [Fact] + public async Task MapLeft_Task_TransformsLeft() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("original"))); + var result = await task.MapLeft(e => Error.Conflict("mapped")); + Assert.True(result.IsLeft); + Assert.Equal("conflict", result.Left.Code); + } + + [Fact] + public async Task MapLeft_Task_WhenRight_LeavesRight() + { + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.MapLeft(e => Error.Conflict("ignored")); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task Bind_Task_BindsRight() + { + Task> task = Task.FromResult((Either)F.Right(21)); + var result = await task.Bind(x => (Either)F.Right(x * 2)); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task Bind_Task_WhenLeft_ShortCircuits() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.Bind(x => (Either)F.Right(x * 2)); + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + } + + [Fact] + public async Task BindAsync_Task_BindsRight() + { + Task> task = Task.FromResult((Either)F.Right(21)); + var result = await task.BindAsync(x => Task.FromResult((Either)F.Right(x * 2))); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task BindAsync_Task_WhenLeft_ShortCircuits() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.BindAsync(x => Task.FromResult((Either)F.Right(x))); + Assert.True(result.IsLeft); + } + + [Fact] + public async Task MapAsync_Task_TransformsRight() + { + Task> task = Task.FromResult((Either)F.Right(21)); + var result = await task.MapAsync(x => Task.FromResult(x * 2)); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task MapAsync_Task_WhenLeft_PreservesError() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.MapAsync(x => Task.FromResult(x * 2)); + Assert.True(result.IsLeft); + } + + [Fact] + public async Task Tap_Task_ExecutesEffect() + { + int captured = 0; + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.Tap(x => captured = x); + Assert.Equal(42, captured); + Assert.True(result.IsRight); + } + + [Fact] + public async Task Tap_Task_WhenLeft_SkipsEffect() + { + int captured = 0; + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.Tap(x => captured = x); + Assert.Equal(0, captured); + } + + [Fact] + public async Task TapAsync_Task_ExecutesEffect() + { + int captured = 0; + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.TapAsync(x => { captured = x; return Task.CompletedTask; }); + Assert.Equal(42, captured); + Assert.True(result.IsRight); + } + + [Fact] + public async Task TapAsync_Task_WhenLeft_SkipsEffect() + { + int captured = 0; + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.TapAsync(x => { captured = x; return Task.CompletedTask; }); + Assert.Equal(0, captured); + } + + [Fact] + public async Task Where_Task_WhenPredicateTrue_ReturnsRight() + { + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.Where(x => x > 0, x => Error.Validation("too small")); + Assert.True(result.IsRight); + Assert.Equal(42, result.Right); + } + + [Fact] + public async Task Where_Task_WhenPredicateFalse_ReturnsLeft() + { + Task> task = Task.FromResult((Either)F.Right(-1)); + var result = await task.Where(x => x > 0, x => Error.Validation("negative")); + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + } + + [Fact] + public async Task Where_Task_WhenLeft_PreservesError() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.Where(x => x > 0, x => Error.Validation("ignored")); + Assert.True(result.IsLeft); + Assert.Equal("validation", result.Left.Code); + } + + [Fact] + public async Task MatchAsync_Task_WithSyncCallbacks_Right() + { + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.MatchAsync( + left: l => $"error: {l.Message}", + right: r => $"ok: {r}"); + Assert.Equal("ok: 42", result); + } + + [Fact] + public async Task MatchAsync_Task_WithAsyncCallbacks_Right() + { + Task> task = Task.FromResult((Either)F.Right(42)); + var result = await task.MatchAsync( + left: l => Task.FromResult($"error: {l.Message}"), + right: r => Task.FromResult($"ok: {r}")); + Assert.Equal("ok: 42", result); + } + + [Fact] + public async Task MatchAsync_Task_WhenLeft_UsesLeftCallback() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.MatchAsync( + left: l => $"error: {l.Message}", + right: r => $"ok: {r}"); + Assert.Equal("error: bad", result); + } + + [Fact] + public async Task MatchAsync_Task_WhenLeft_AsyncCallback() + { + Task> task = Task.FromResult((Either)F.Left(Error.Validation("bad"))); + var result = await task.MatchAsync( + left: l => Task.FromResult($"error: {l.Message}"), + right: r => Task.FromResult($"ok: {r}")); + Assert.Equal("error: bad", result); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Functional/EitherValueTests.cs b/test/MS.Microservice.Core.Tests/Functional/EitherValueTests.cs new file mode 100644 index 0000000..8cd157e --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Functional/EitherValueTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Functional; + +public sealed class EitherValueTests +{ + [Fact] + public void Equals_And_Operators_ShouldWorkForRightValues() + { + Either left = F.Right(42); + Either right = F.Right(42); + Either different = F.Right(41); + + left.Equals(right).Should().BeTrue(); + (left == right).Should().BeTrue(); + (left != different).Should().BeTrue(); + left.GetHashCode().Should().Be(right.GetHashCode()); + left.ToString().Should().Be("Right(42)"); + } + + [Fact] + public void Equals_And_Operators_ShouldWorkForLeftValues() + { + var error = Error.Validation("bad input"); + Either left = F.Left(error); + Either same = F.Left(error); + Either right = F.Right(1); + + left.Equals(same).Should().BeTrue(); + (left == same).Should().BeTrue(); + (left != right).Should().BeTrue(); + left.ToString().Should().Contain("Left"); + } + + [Fact] + public void Match_ShouldInvokeCorrectBranch() + { + Either success = F.Right(21); + Either failure = F.Left(Error.Validation("invalid")); + + success.Match(_ => -1, value => value * 2).Should().Be(42); + failure.Match(error => error.Code, _ => "ok").Should().Be("validation"); + } + + [Fact] + public void LeftAndRightContainers_ShouldFormatValue() + { + F.Left(Error.Validation("broken")).ToString().Should().Contain("Left"); + F.Right(42).ToString().Should().Be("Right(42)"); + } +} diff --git a/test/MS.Microservice.Core.Tests/Functional/FAsyncTests.cs b/test/MS.Microservice.Core.Tests/Functional/FAsyncTests.cs new file mode 100644 index 0000000..2c44755 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Functional/FAsyncTests.cs @@ -0,0 +1,127 @@ +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Functional +{ + public class FAsyncTests + { + [Fact] + public async Task Async_WrapsValueInTask() + { + var result = await F.Async(42); + Assert.Equal(42, result); + } + + [Fact] + public async Task Map_TransformsValue() + { + var result = await Task.FromResult(21).Map(x => x * 2); + Assert.Equal(42, result); + } + + [Fact] + public async Task Bind_AsyncTransformsValue() + { + var result = await Task.FromResult(21).Bind(x => Task.FromResult(x * 2)); + Assert.Equal(42, result); + } + + [Fact] + public async Task OrElse_WhenSuccess_ReturnsOriginal() + { + var result = await Task.FromResult(42).OrElse(() => Task.FromResult(99)); + Assert.Equal(42, result); + } + + [Fact] + public async Task OrElse_WhenThrows_UsesFallback() + { + var result = await Task.FromException(new InvalidOperationException("boom")) + .OrElse(() => Task.FromResult(99)); + Assert.Equal(99, result); + } + + [Fact] + public async Task Recover_WhenFaulted_UsesFallback() + { + var result = await Task.FromException(new InvalidOperationException("boom")) + .Recover(ex => 99); + Assert.Equal(99, result); + } + + [Fact] + public async Task Recover_WhenSuccess_KeepsValue() + { + var result = await Task.FromResult(42).Recover(ex => 99); + Assert.Equal(42, result); + } + + [Fact] + public async Task Map_FaultedCompleted_OnCompleted() + { + var result = await Task.FromResult(21).Map( + Faulted: ex => 0, + Completed: x => x * 2); + Assert.Equal(42, result); + } + + [Fact] + public async Task Map_FaultedCompleted_OnFaulted() + { + var result = await Task.FromException(new InvalidOperationException()).Map( + Faulted: ex => 99, + Completed: x => x * 2); + Assert.Equal(99, result); + } + + [Fact] + public async Task Select_TransformsValue() + { + var result = await Task.FromResult(21).Select(x => x * 2); + Assert.Equal(42, result); + } + + [Fact] + public async Task Apply_UnaryFunc() + { + Task> f = Task.FromResult((Func)(x => x * 2)); + var result = await f.Apply(Task.FromResult(21)); + Assert.Equal(42, result); + } + + [Fact] + public async Task Retry_SucceedsOnFirstTry() + { + var result = await F.Retry(3, 10, () => Task.FromResult(42)); + Assert.Equal(42, result); + } + + [Fact] + public async Task Retry_RetriesOnFailure() + { + int attempts = 0; + var result = await F.Retry(3, 10, () => + { + attempts++; + if (attempts < 3) + throw new InvalidOperationException("fail"); + return Task.FromResult(42); + }); + Assert.Equal(42, result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task Retry_ExhaustsRetries_ThrowsLast() + { + int attempts = 0; + await Assert.ThrowsAsync(() => + F.Retry(2, 10, () => + { + attempts++; + throw new InvalidOperationException("always fail"); + })); + Assert.Equal(3, attempts); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Functional/ValidationExceptionalTests.cs b/test/MS.Microservice.Core.Tests/Functional/ValidationExceptionalTests.cs index b76324d..533edd6 100644 --- a/test/MS.Microservice.Core.Tests/Functional/ValidationExceptionalTests.cs +++ b/test/MS.Microservice.Core.Tests/Functional/ValidationExceptionalTests.cs @@ -5,11 +5,11 @@ namespace MS.Microservice.Core.Tests.Functional { public class ValidationExceptionalTests { + // === Validation tests === [Fact] public void Validation_WhenCreatedFromValid_IsValid() { Validation validation = F.Valid(42); - Assert.True(validation.IsValid); Assert.Equal(42, validation.Valid); } @@ -18,31 +18,292 @@ public void Validation_WhenCreatedFromValid_IsValid() public void Validation_Map_WhenInvalid_PreservesError() { Validation validation = F.Invalid(Error.Validation("bad input")); - var result = validation.Map(value => value + 1); - Assert.True(result.IsInvalid); Assert.Equal("validation", result.Invalid.Code); } + // === Exceptional from value/implicit === [Fact] - public void Exceptional_Try_WhenThrows_ReturnsExceptionState() + public void Exceptional_FromSuccess_IsSuccess() { - var result = ExceptionalExtensions.Try(() => throw new InvalidOperationException("boom")); + Exceptional ex = F.Success(42); + Assert.True(ex.IsSuccess); + Assert.False(ex.IsException); + Assert.Equal(42, ex.Success); + } + + [Fact] + public void Exceptional_FromExceptionThrown_IsException() + { + var err = new InvalidOperationException("boom"); + Exceptional ex = F.ExceptionThrown(err); + Assert.True(ex.IsException); + Assert.False(ex.IsSuccess); + Assert.Equal("boom", ex.Exception.Message); + } + + [Fact] + public void Exceptional_ImplicitFromValue_CreatesSuccess() + { + Exceptional ex = "hello"; + Assert.True(ex.IsSuccess); + Assert.Equal("hello", ex.Success); + } + + [Fact] + public void Exceptional_ImplicitFromException_CreatesException() + { + Exceptional ex = new ArgumentException("bad"); + Assert.True(ex.IsException); + Assert.Equal("bad", ex.Exception.Message); + } + + [Fact] + public void Exceptional_SuccessProperty_WhenException_Throws() + { + Exceptional ex = new InvalidOperationException("err"); + Assert.Throws(() => ex.Success); + } + + [Fact] + public void Exceptional_ExceptionProperty_WhenSuccess_Throws() + { + Exceptional ex = F.Success(1); + Assert.Throws(() => ex.Exception); + } + + // === Try / TryAsync === + [Fact] + public void Exceptional_Try_Success() + { + var result = ExceptionalExtensions.Try(() => 42); + Assert.True(result.IsSuccess); + Assert.Equal(42, result.Success); + } + [Fact] + public void Exceptional_Try_Throws_ReturnsExceptionState() + { + var result = ExceptionalExtensions.Try(() => throw new InvalidOperationException("boom")); Assert.True(result.IsException); Assert.Equal("boom", result.Exception.Message); } [Fact] - public void Exceptional_Bind_WhenSuccess_Composes() + public void Exceptional_Try_Action_Success() { - Exceptional exceptional = F.Success(41); + int side = 0; + var result = ExceptionalExtensions.Try(() => { side = 42; }); + Assert.True(result.IsSuccess); + Assert.Equal(42, side); + } - var result = exceptional.Bind(value => (Exceptional)F.Success(value + 1)); + [Fact] + public void Exceptional_Try_Action_Throws() + { + var result = ExceptionalExtensions.Try(() => throw new ArgumentException("fail")); + Assert.True(result.IsException); + Assert.Equal("fail", result.Exception.Message); + } + + [Fact] + public async Task Exceptional_TryAsync_Success() + { + var result = await ExceptionalExtensions.TryAsync(() => Task.FromResult(99)); + Assert.True(result.IsSuccess); + Assert.Equal(99, result.Success); + } + [Fact] + public async Task Exceptional_TryAsync_Throws() + { + var result = await ExceptionalExtensions.TryAsync(() => throw new InvalidOperationException("async fail")); + Assert.True(result.IsException); + Assert.Equal("async fail", result.Exception.Message); + } + + [Fact] + public async Task Exceptional_TryAsync_Action_Success() + { + var result = await ExceptionalExtensions.TryAsync(() => Task.CompletedTask); + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Exceptional_TryAsync_Action_Throws() + { + var result = await ExceptionalExtensions.TryAsync(() => Task.FromException(new ArgumentException("task fail"))); + Assert.True(result.IsException); + Assert.Equal("task fail", result.Exception.Message); + } + + // === Match === + [Fact] + public void Exceptional_Match_Success() + { + Exceptional ex = F.Success(10); + var result = ex.Match(e => $"err:{e.Message}", v => $"val:{v}"); + Assert.Equal("val:10", result); + } + + [Fact] + public void Exceptional_Match_Exception() + { + Exceptional ex = new InvalidOperationException("bad"); + var result = ex.Match(e => $"err:{e.Message}", v => $"val:{v}"); + Assert.Equal("err:bad", result); + } + + // === Map === + [Fact] + public void Exceptional_Map_Success() + { + Exceptional ex = F.Success(5); + var result = ex.Map(v => v * 2); + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Success); + } + + [Fact] + public void Exceptional_Map_Exception_PreservesError() + { + Exceptional ex = new InvalidOperationException("err"); + var result = ex.Map(v => v * 2); + Assert.True(result.IsException); + Assert.Equal("err", result.Exception.Message); + } + + // === Bind === + [Fact] + public void Exceptional_Bind_Success() + { + Exceptional ex = F.Success(41); + var result = ex.Bind(v => (Exceptional)F.Success(v + 1)); Assert.True(result.IsSuccess); Assert.Equal(42, result.Success); } + + [Fact] + public void Exceptional_Bind_Exception_PreservesError() + { + Exceptional ex = new InvalidOperationException("fail"); + var result = ex.Bind(v => (Exceptional)F.Success(v + 1)); + Assert.True(result.IsException); + Assert.Equal("fail", result.Exception.Message); + } + + // === Equals & operators === + [Fact] + public void Exceptional_Equals_SameSuccess_True() + { + Exceptional a = F.Success(1); + Exceptional b = F.Success(1); + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + } + + [Fact] + public void Exceptional_Equals_DifferentSuccess_False() + { + Exceptional a = F.Success(1); + Exceptional b = F.Success(2); + Assert.False(a.Equals(b)); + Assert.False(a == b); + Assert.True(a != b); + } + + [Fact] + public void Exceptional_Equals_SuccessVsException_False() + { + Exceptional a = F.Success(1); + Exceptional b = new InvalidOperationException("e"); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Exceptional_Equals_Boxed_Works() + { + object a = F.Success(5); + object b = F.Success(5); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Exceptional_Equals_DifferentType_False() + { + Exceptional a = F.Success(5); + Assert.False(a.Equals("not_an_exceptional")); + } + + [Fact] + public void Exceptional_GetHashCode_SameSuccess_Same() + { + Exceptional a = F.Success(42); + Exceptional b = F.Success(42); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // === ToString === + [Fact] + public void Exceptional_ToString_Success() + { + Exceptional ex = F.Success(42); + Assert.Equal("Success(42)", ex.ToString()); + } + + [Fact] + public void Exceptional_ToString_Exception() + { + Exceptional ex = new InvalidOperationException("bad thing"); + Assert.Equal("ExceptionThrown(bad thing)", ex.ToString()); + } + + // === Implicit to/from Either === + [Fact] + public void Exceptional_Either_Roundtrip() + { + Exceptional ex = F.Success(10); + Either either = ex; + Assert.True(either.IsRight); + Assert.True(either.IsRight); + } + + [Fact] + public void Exceptional_FromEither_Success() + { + Either either = F.Right(20); + Exceptional ex = either; + Assert.True(ex.IsSuccess); + Assert.Equal(20, ex.Success); + } + + // === ExceptionThrown / Success struct tests === + [Fact] + public void ExceptionThrown_NullCtor_Throws() + { + Assert.Throws(() => new ExceptionThrown(null!)); + } + + [Fact] + public void SuccessStruct_NullCtor_Throws() + { + Assert.Throws(() => new Success(null!)); + } + + [Fact] + public void ExceptionThrown_ToString_Works() + { + var et = new ExceptionThrown(new InvalidOperationException("err")); + Assert.Equal("ExceptionThrown(err)", et.ToString()); + } + + [Fact] + public void SuccessStruct_ToString_Works() + { + var s = new Success(42); + Assert.Equal("Success(42)", s.ToString()); + } } -} +} \ No newline at end of file diff --git a/test/MS.Microservice.Core.Tests/Functional/ValidationTests.cs b/test/MS.Microservice.Core.Tests/Functional/ValidationTests.cs new file mode 100644 index 0000000..fd488d8 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Functional/ValidationTests.cs @@ -0,0 +1,653 @@ +using MS.Microservice.Core.Functional; +using Xunit; + +namespace MS.Microservice.Core.Tests.Functional +{ + public class ValidationTests + { + // ─── Factory methods ─── + + [Fact] + public void Valid_CreatesValidValidation() + { + var v = F.Valid(42); + Assert.True(v.IsValid); + Assert.False(v.IsInvalid); + Assert.Equal(42, v.Value); + } + + [Fact] + public void Valid_WithNull_DoesNotThrow() + { + // Validation stores the value directly; Valid struct guards null but F.Valid creates Validation directly + var v = F.Valid(null!); + Assert.True(v.IsValid); + } + + [Fact] + public void Invalid_CreatesInvalidValidation() + { + Validation v = F.Invalid(Error.Validation("bad")); + Assert.True(v.IsInvalid); + Assert.False(v.IsValid); + } + + [Fact] + public void Invalid_Generic_CreatesInvalidValidation() + { + Validation v = F.Invalid(Error.Validation("bad")); + Assert.True(v.IsInvalid); + Assert.Equal("validation", v.Invalid.Code); + } + + [Fact] + public void Invalid_FromEnumerable_CreatesInvalid() + { + var errors = new[] { Error.Validation("e1"), Error.Validation("e2") }; + Validation v = F.Invalid(errors.AsEnumerable()); + Assert.True(v.IsInvalid); + Assert.Equal(2, v.Invalid.Errors.Count()); + } + + [Fact] + public void Invalid_Generic_FromEnumerable_CreatesInvalid() + { + var errors = new[] { Error.Validation("e1") }; + Validation v = F.Invalid(errors.AsEnumerable()); + Assert.True(v.IsInvalid); + } + + // ─── Validation members ─── + + [Fact] + public void Value_WhenValid_ReturnsInnerValue() + { + var v = F.Valid("hello"); + Assert.Equal("hello", v.Value); + Assert.Equal("hello", v.Valid); + } + + [Fact] + public void Value_WhenInvalid_ThrowsInvalidOperationException() + { + Validation v = F.Invalid(Error.Validation("bad")); + Assert.Throws(() => v.Value); + } + + [Fact] + public void Fail_Static_CreatesInvalid() + { + var v = Validation.Fail(Error.Validation("fail")); + Assert.True(v.IsInvalid); + } + + [Fact] + public void Fail_Static_FromEnumerable() + { + var v = Validation.Fail(new[] { Error.Validation("f1") }.AsEnumerable()); + Assert.True(v.IsInvalid); + } + + // ─── Implicit operators ─── + + [Fact] + public void Implicit_FromError_CreatesInvalid() + { + Validation v = Error.Validation("ops"); + Assert.True(v.IsInvalid); + Assert.Equal("validation", v.Invalid.Code); + } + + [Fact] + public void Implicit_FromInvalid_CreatesInvalid() + { + Validation v = F.Invalid(Error.Validation("nope")); + Assert.True(v.IsInvalid); + } + + [Fact] + public void Implicit_FromValue_CreatesValid() + { + Validation v = 42; + Assert.True(v.IsValid); + Assert.Equal(42, v.Value); + } + + // ─── Match ─── + + [Fact] + public void Match_WhenValid_ReturnsValidResult() + { + var v = F.Valid(10); + var result = v.Match( + invalid: _ => 0, + valid: x => x * 2); + Assert.Equal(20, result); + } + + [Fact] + public void Match_WhenInvalid_ReturnsInvalidResult() + { + var v = F.Invalid(Error.Validation("bad")); + var result = v.Match( + invalid: _ => -1, + valid: x => x); + Assert.Equal(-1, result); + } + + [Fact] + public void Match_Action_WhenValid_CallsValidAction() + { + var v = F.Valid(5); + int captured = 0; + var unit = v.Match( + invalid: _ => { captured = -1; }, + valid: x => { captured = x; }); + Assert.Equal(5, captured); + } + + [Fact] + public void Match_Action_WhenInvalid_CallsInvalidAction() + { + var v = F.Invalid(Error.Validation("bad")); + int captured = 0; + v.Match( + invalid: _ => { captured = -1; }, + valid: _ => { }); + Assert.Equal(-1, captured); + } + + // ─── Bind ─── + + [Fact] + public void Bind_WhenValid_BindsToNext() + { + var v = F.Valid(5); + var result = v.Bind(x => F.Valid(x * 2)); + Assert.True(result.IsValid); + Assert.Equal(10, result.Value); + } + + [Fact] + public void Bind_WhenInvalid_ShortCircuits() + { + var v = F.Invalid(Error.Validation("bad")); + bool called = false; + var result = v.Bind(x => { called = true; return F.Valid(x); }); + Assert.True(result.IsInvalid); + Assert.False(called); + } + + // ─── AsEnumerable ─── + + [Fact] + public void AsEnumerable_WhenValid_YieldsValue() + { + Validation v = F.Valid(42); + using var enumerator = v.AsEnumerable(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(42, enumerator.Current); + Assert.False(enumerator.MoveNext()); + } + + [Fact] + public void AsEnumerable_WhenInvalid_YieldsNothing() + { + Validation v = F.Invalid(Error.Validation("bad")); + using var enumerator = v.AsEnumerable(); + Assert.False(enumerator.MoveNext()); + } + + // ─── ToString ─── + + [Fact] + public void ToString_WhenValid_ShowsValue() + { + var v = F.Valid(42); + Assert.Equal("Valid(42)", v.ToString()); + } + + [Fact] + public void ToString_WhenInvalid_ShowsErrors() + { + var v = F.Invalid(Error.Validation("bad input")); + var s = v.ToString(); + Assert.Contains("Invalid", s); + Assert.Contains("bad input", s); + } + + // ─── Equality ─── + + [Fact] + public void Equals_SameValid_ReturnsTrue() + { + var a = F.Valid(42); + var b = F.Valid(42); + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + } + + [Fact] + public void Equals_DifferentValid_ReturnsFalse() + { + var a = F.Valid(42); + var b = F.Valid(43); + Assert.False(a.Equals(b)); + Assert.False(a == b); + Assert.True(a != b); + } + + [Fact] + public void Equals_OneValidOneInvalid_ReturnsFalse() + { + var a = F.Valid(42); + var b = F.Invalid(Error.Validation("bad")); + Assert.False(a.Equals(b)); + Assert.False(a == b); + } + + [Fact] + public void Equals_SameInvalid_ReturnsTrue() + { + var err = Error.Validation("bad"); + var a = F.Invalid(err); + var b = F.Invalid(err); + Assert.True(a.Equals(b)); + Assert.True(a == b); + } + + [Fact] + public void Equals_DifferentInvalidErrors_ReturnsFalse() + { + var a = F.Invalid(Error.Validation("a")); + var b = F.Invalid(Error.Validation("b")); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_NullObject_ReturnsFalse() + { + var v = F.Valid(42); + Assert.False(v.Equals((object?)null)); + } + + [Fact] + public void Equals_DifferentType_ReturnsFalse() + { + var v = F.Valid(42); + Assert.False(v.Equals("string")); + } + + [Fact] + public void GetHashCode_SameValid_SameHash() + { + var a = F.Valid(42); + var b = F.Valid(42); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_SameInvalid_SameHash() + { + var err = Error.Validation("bad"); + var a = F.Invalid(err); + var b = F.Invalid(err); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + // ─── Validation.Invalid struct ─── + + [Fact] + public void Invalid_Code_ReturnsFirstErrorCode() + { + var v = F.Invalid(Error.Validation("validation error"), Error.Conflict("conflict error")); + Assert.Equal("validation", v.Code); + } + + [Fact] + public void Invalid_Message_ReturnsFirstErrorMessage() + { + var v = F.Invalid(Error.Validation("first"), Error.Conflict("second")); + Assert.Equal("first", v.Message); + } + + [Fact] + public void Invalid_Code_WhenEmpty_ReturnsEmpty() + { + var err = new Error("code", "msg"); + var v = F.Invalid(new[] { err }.AsEnumerable()); + // Invalid with errors has code + Assert.Equal("code", v.Invalid.Code); + } + + [Fact] + public void Invalid_ToString_ShowsErrors() + { + var v = F.Invalid(Error.Validation("e1"), Error.Validation("e2")); + var s = v.ToString(); + Assert.Contains("Invalid", s); + } + + // ─── HarvestErrors extension ─── + + [Fact] + public void HarvestErrors_AllValid_ReturnsValid() + { + Func>[] validators = + [ + s => F.Valid(s), + s => F.Valid(s.ToUpper()), + ]; + var validate = validators.HarvestErrors(); + var result = validate("hello"); + Assert.True(result.IsValid); + Assert.Equal("HELLO", result.Value); + } + + [Fact] + public void HarvestErrors_OneFails_ReturnsInvalidWithAllErrors() + { + Func>[] validators = + [ + s => F.Invalid(Error.Validation("e1")), + s => F.Invalid(Error.Validation("e2")), + ]; + var validate = validators.HarvestErrors(); + var result = validate("hello"); + Assert.True(result.IsInvalid); + Assert.Equal(2, result.Invalid.Errors.Count()); + } + + [Fact] + public void HarvestErrors_MixedValidAndInvalid_CollectsAllErrors() + { + Func>[] validators = + [ + s => F.Valid(s), + s => F.Invalid(Error.Validation("e1")), + s => F.Invalid(Error.Validation("e2")), + ]; + var validate = validators.HarvestErrors(); + var result = validate("hello"); + Assert.True(result.IsInvalid); + Assert.Equal(2, result.Invalid.Errors.Count()); + } + + [Fact] + public void HarvestErrors_FirstFailsLaterValid_ReturnsAllErrors() + { + Func>[] validators = + [ + s => F.Invalid(Error.Validation("e1")), + s => F.Valid(s), + ]; + var validate = validators.HarvestErrors(); + var result = validate("hello"); + Assert.True(result.IsInvalid); + } + + // ─── Apply (applicative) ─── + + [Fact] + public void Apply_BothValid_AppliesFunction() + { + Validation> valF = F.Valid>(x => x * 2); + Validation valT = F.Valid(21); + var result = valF.Apply(valT); + Assert.True(result.IsValid); + Assert.Equal(42, result.Value); + } + + [Fact] + public void Apply_InvalidFunction_CollectsErrors() + { + Validation> valF = F.Invalid>(Error.Validation("bad func")); + Validation valT = F.Valid(21); + var result = valF.Apply(valT); + Assert.True(result.IsInvalid); + Assert.Equal("bad func", result.Invalid.Message); + } + + [Fact] + public void Apply_InvalidArg_CollectsErrors() + { + Validation> valF = F.Valid>(x => x * 2); + Validation valT = F.Invalid(Error.Validation("bad arg")); + var result = valF.Apply(valT); + Assert.True(result.IsInvalid); + Assert.Equal("bad arg", result.Invalid.Message); + } + + [Fact] + public void Apply_BothInvalid_CollectsAllErrors() + { + Validation> valF = F.Invalid>(Error.Validation("bad func")); + Validation valT = F.Invalid(Error.Validation("bad arg")); + var result = valF.Apply(valT); + Assert.True(result.IsInvalid); + Assert.Equal(2, result.Invalid.Errors.Count()); + } + + [Fact] + public void Apply_TwoArg_Works() + { + Validation> valF = F.Valid>((a, b) => a + b); + Validation valA = F.Valid(10); + Validation> curried = Validation.Apply(valF, valA); + Assert.True(curried.IsValid); + Validation final = curried.Apply(F.Valid(32)); + Assert.True(final.IsValid); + Assert.Equal(42, final.Value); + } + + [Fact] + public void Apply_ThreeArg_Works() + { + Validation> valF = F.Valid>((a, b, c) => a + b + c); + Validation valA = F.Valid(10); + var result = Validation.Apply(valF, valA); + Assert.True(result.IsValid); + } + + // ─── Validation extensions (Bind, GetOrThrow, GetOrElse) ─── + + [Fact] + public void ExtensionBind_WhenValid_Binds() + { + Validation v = F.Valid(5); + var result = v.Bind(x => F.Valid(x * 2)); + Assert.True(result.IsValid); + Assert.Equal(10, result.Value); + } + + [Fact] + public void ExtensionBind_WhenInvalid_ReturnsInvalid() + { + Validation v = F.Invalid(Error.Validation("bad")); + var result = v.Bind(x => F.Valid(x * 2)); + Assert.True(result.IsInvalid); + } + + [Fact] + public void GetOrThrow_WhenValid_ReturnsValue() + { + Validation v = F.Valid(42); + Assert.Equal(42, v.GetOrThrow()); + } + + [Fact] + public void GetOrThrow_WhenInvalid_Throws() + { + Validation v = F.Invalid(Error.Validation("bad")); + var ex = Assert.Throws(() => v.GetOrThrow()); + Assert.Contains("bad", ex.Message); + } + + [Fact] + public void GetOrElse_WhenValid_ReturnsValue() + { + Validation v = F.Valid(42); + Assert.Equal(42, v.GetOrElse(-1)); + } + + [Fact] + public void GetOrElse_WhenInvalid_ReturnsDefault() + { + Validation v = F.Invalid(Error.Validation("bad")); + Assert.Equal(-1, v.GetOrElse(-1)); + } + + [Fact] + public void GetOrElse_Func_WhenInvalid_ReturnsFallback() + { + Validation v = F.Invalid(Error.Validation("bad")); + Assert.Equal(99, v.GetOrElse(() => 99)); + } + + // ─── Map ─── + + [Fact] + public void Map_WhenValid_TransformsValue() + { + var v = F.Valid(21); + var result = v.Map(x => x * 2); + Assert.True(result.IsValid); + Assert.Equal(42, result.Value); + } + + [Fact] + public void Map_WhenInvalid_PreservesError() + { + var v = F.Invalid(Error.Validation("bad")); + var result = v.Map(x => x * 2); + Assert.True(result.IsInvalid); + Assert.Equal("bad", result.Invalid.Message); + } + + [Fact] + public void Map_TwoArg_Curries() + { + var v = F.Valid(10); + var result = v.Map((int a, int b) => a + b); + Assert.True(result.IsValid); + Assert.IsType>(result.Value); + } + + // ─── ForEach / Do ─── + + [Fact] + public void ForEach_WhenValid_ExecutesAction() + { + var v = F.Valid(42); + int captured = 0; + v.ForEach(x => captured = x); + Assert.Equal(42, captured); + } + + [Fact] + public void Do_WhenValid_ExecutesAndReturnsSelf() + { + var v = F.Valid(42); + int captured = 0; + var result = v.Do(x => captured = x); + Assert.Equal(42, captured); + Assert.True(result.IsValid); + Assert.Equal(42, result.Value); + } + + // ─── LINQ Select / SelectMany ─── + + [Fact] + public void Select_WhenValid_Projects() + { + var v = F.Valid(21); + var result = v.Select(x => x * 2); + Assert.True(result.IsValid); + Assert.Equal(42, result.Value); + } + + [Fact] + public void Select_WhenInvalid_PreservesError() + { + var v = F.Invalid(Error.Validation("bad")); + var result = v.Select(x => x * 2); + Assert.True(result.IsInvalid); + } + + [Fact] + public void SelectMany_WhenValid_Projects() + { + var v = F.Valid(5); + var result = v.SelectMany( + bind: x => F.Valid(x * 2), + project: (x, r) => $"{x}->{r}"); + Assert.True(result.IsValid); + Assert.Equal("5->10", result.Value); + } + + [Fact] + public void SelectMany_WhenBindFails_ReturnsInvalid() + { + var v = F.Valid(5); + var result = v.SelectMany( + bind: x => F.Invalid(Error.Validation("bind fail")), + project: (x, r) => $"{x}->{r}"); + Assert.True(result.IsInvalid); + } + + [Fact] + public void SelectMany_WhenSourceInvalid_ReturnsInvalid() + { + var v = F.Invalid(Error.Validation("source bad")); + var result = v.SelectMany( + bind: x => F.Valid(x.ToString()), + project: (x, r) => $"{x}->{r}"); + Assert.True(result.IsInvalid); + } + + // ─── LINQ query expression ─── + + [Fact] + public void LinqQuery_ValidChain_Works() + { + var result = + from a in F.Valid(10) + from b in F.Valid(20) + select a + b; + Assert.True(result.IsValid); + Assert.Equal(30, result.Value); + } + + [Fact] + public void LinqQuery_MidFailure_ShortCircuits() + { + var result = + from a in F.Valid(10) + from b in F.Invalid(Error.Validation("mid fail")) + from c in F.Valid(30) + select a + b + c; + Assert.True(result.IsInvalid); + Assert.Equal("mid fail", result.Invalid.Message); + } + + // ─── Return function ─── + + [Fact] + public void Return_CreatesValid() + { + var v = Validation.Return(42); + Assert.True(v.IsValid); + Assert.Equal(42, v.Value); + } + + // ─── Valid struct ─── + + [Fact] + public void Valid_ToString_ShowsValue() + { + var v = new Valid(42); + Assert.Equal("Valid(42)", v.ToString()); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Linq/ExpressionStarterTests.cs b/test/MS.Microservice.Core.Tests/Linq/ExpressionStarterTests.cs new file mode 100644 index 0000000..7d904e4 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Linq/ExpressionStarterTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Xunit; + +namespace MS.Microservice.Core.Tests.Linq; + +public sealed class ExpressionStarterTests +{ + [Fact] + public void Start_ShouldSetPredicate() + { + var starter = new ExpressionStarter(); + + Expression> expression = starter.Start(x => x > 5); + + Assert.True(starter.IsStarted); + Assert.True(expression.Compile()(6)); + Assert.False(expression.Compile()(4)); + } + + [Fact] + public void Start_WhenCalledTwice_ShouldThrow() + { + var starter = new ExpressionStarter(); + starter.Start(x => x > 0); + + Assert.Throws(() => starter.Start(x => x < 10)); + } + + [Fact] + public void Or_And_ShouldComposeExpressions() + { + var starter = PredicateBuilder.New(); + + starter.Or(x => x > 10); + starter.And(x => x < 20); + Func predicate = starter.Compile(); + + Assert.True(predicate(15)); + Assert.False(predicate(9)); + Assert.False(predicate(21)); + } + + [Fact] + public void New_WithDefaultTrue_ShouldCompileToTrueBeforeStart() + { + Func predicate = PredicateBuilder.New(true); + + Assert.True(predicate(0)); + Assert.True(predicate(100)); + } + + [Fact] + public void ImplicitConversion_FromExpression_ShouldCreateStartedPredicate() + { + ExpressionStarter starter = (Expression>)(x => x % 2 == 0); + Expression> expression = starter; + + Assert.True(starter.IsStarted); + Assert.True(expression.Compile()(2)); + Assert.False(expression.Compile()(3)); + } +} diff --git a/test/MS.Microservice.Core.Tests/Microsoft/System/Collection/DisposableStackTests.cs b/test/MS.Microservice.Core.Tests/Microsoft/System/Collection/DisposableStackTests.cs new file mode 100644 index 0000000..b69e2ad --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Microsoft/System/Collection/DisposableStackTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using FluentAssertions; +using Microsoft.System.Collection; +using Xunit; + +namespace MS.Microservice.Core.Tests.Microsoft.System.Collection; + +public sealed class DisposableStackTests +{ + [Fact] + public void Push_Pop_And_Dispose_ShouldManageItems() + { + var stack = new DisposableStack(); + var first = new TrackingDisposable("first"); + var second = new TrackingDisposable("second"); + + stack.Push(first); + stack.Push(second); + + stack.Count.Should().Be(2); + stack.Pop().Should().BeSameAs(second); + stack.Dispose(); + + first.IsDisposed.Should().BeTrue(); + second.IsDisposed.Should().BeFalse(); + } + + [Fact] + public void Dispose_EmptyStack_ShouldNotThrow() + { + var stack = new DisposableStack(); + + stack.Invoking(current => current.Dispose()).Should().NotThrow(); + } + + private sealed class TrackingDisposable(string name) : IDisposable + { + public string Name { get; } = name; + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Net/Http/HttpClientExtensionsTests.cs b/test/MS.Microservice.Core.Tests/Net/Http/HttpClientExtensionsTests.cs new file mode 100644 index 0000000..014c9f0 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Net/Http/HttpClientExtensionsTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MS.WebHttpClient; +using Xunit; + +namespace MS.Microservice.Core.Tests.Net.Http; + +public sealed class HttpClientExtensionsTests +{ + [Fact] + public async Task GetAsync_ShouldBuildQueryString_ForNullableEnumAndArrayValues() + { + var handler = new RecordingHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new ResponsePayload { Message = "ok" }) + })); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/") }; + + ResponsePayload? result = await client.GetAsync("orders", new QueryPayload + { + Id = 42, + Status = QueryStatus.Ready, + Tags = ["alpha", "beta"] + }); + + HttpResponseMessage response = await client.GetAsync("orders", new QueryPayload + { + Id = 7, + Tags = ["solo"] + }); + + Assert.NotNull(result); + Assert.Equal("ok", result!.Message); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + string[] segments = handler.LastRequest!.RequestUri!.Query.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries); + Assert.Contains("Id=7", segments); + Assert.Contains("Tags=solo", segments); + } + + [Fact] + public async Task GetAsync_Generic_ShouldReturnNull_WhenContentIsNotJson() + { + var handler = new RecordingHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("plain", Encoding.UTF8, "text/plain") + })); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/") }; + + ResponsePayload? result = await client.GetAsync("plain", body: null!); + + Assert.Null(result); + Assert.Equal(string.Empty, handler.LastRequest!.RequestUri!.Query); + } + + [Fact] + public async Task GetAsync_Generic_ShouldReturnNull_WhenJsonIsInvalid() + { + var handler = new RecordingHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{bad json", Encoding.UTF8, "application/json") + })); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/") }; + + ResponsePayload? result = await client.GetAsync("broken", new QueryPayload { Id = 1 }); + + Assert.Null(result); + Assert.Contains("Id=1", handler.LastRequest!.RequestUri!.Query); + } + + private sealed class RecordingHandler(Func> responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return await responder(request); + } + } + + private sealed class ResponsePayload + { + public string? Message { get; set; } + } + + private sealed class QueryPayload + { + public int Id { get; set; } + public QueryStatus? Status { get; set; } + public string[]? Tags { get; set; } + } + + private enum QueryStatus + { + None = 0, + Ready = 1 + } +} diff --git a/test/MS.Microservice.Core.Tests/Net/Http/LogHttpClientTests.cs b/test/MS.Microservice.Core.Tests/Net/Http/LogHttpClientTests.cs new file mode 100644 index 0000000..eabd244 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Net/Http/LogHttpClientTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MS.Microservice.Core.Net.Http; +using Xunit; + +namespace MS.Microservice.Core.Tests.Net.Http; + +public sealed class LogHttpClientTests +{ + [Fact] + public async Task GetAsync_ShouldConfigureBaseAddress_AndReturnModel() + { + var logger = new CapturingLogger(); + var handler = new RecordingHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new ResponsePayload { Message = "ok" }) + })); + using var httpClient = new HttpClient(handler); + var client = new LogHttpClient(logger, httpClient); + + client.Configure("https://example.test/", TimeSpan.FromSeconds(5)); + ResponsePayload? result = await client.GetAsync("orders", new QueryPayload { Id = 1, Name = "alice" }); + + Assert.NotNull(result); + Assert.Equal("ok", result!.Message); + Assert.Equal(new Uri("https://example.test/"), httpClient.BaseAddress); + Assert.Contains("orders?Id=1&Name=alice", handler.LastRequest!.RequestUri!.ToString()); + Assert.Equal(2, logger.Entries.Count); + } + + [Fact] + public async Task PostAsync_WithHeaders_ShouldSendHeadersAndReturnModel() + { + var logger = new CapturingLogger(); + var handler = new RecordingHandler(async request => + { + string payload = await request.Content!.ReadAsStringAsync(); + Assert.Contains("\"Name\":\"alice\"", payload); + Assert.True(request.Headers.Contains("X-Trace-Id")); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new ResponsePayload { Message = "posted" }) + }; + }); + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/") }; + var client = new LogHttpClient(logger, httpClient); + + ResponsePayload? result = await client.PostAsync( + "orders", + new QueryPayload { Id = 2, Name = "alice" }, + new Dictionary { ["X-Trace-Id"] = "trace-1" }); + + Assert.NotNull(result); + Assert.Equal("posted", result!.Message); + Assert.Equal(2, logger.Entries.Count); + } + + [Fact] + public async Task GetAsync_WhenJsonIsInvalid_ShouldThrowWrappedException() + { + var logger = new CapturingLogger(); + var handler = new RecordingHandler(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{bad json", Encoding.UTF8, "application/json") + })); + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://example.test/") }; + var client = new LogHttpClient(logger, httpClient); + + Exception exception = await Assert.ThrowsAsync(() => + client.GetAsync("orders", new QueryPayload { Id = 3, Name = "broken" }).AsTask()); + + Assert.Equal("服务器数据解析异常", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.Equal(2, logger.Entries.Count); + } + + private sealed class RecordingHandler(Func> responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return await responder(request); + } + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception))); + } + } + + private sealed record LogEntry(LogLevel Level, string Message); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } + + private sealed class ResponsePayload + { + public string? Message { get; set; } + } + + private sealed class QueryPayload + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/test/MS.Microservice.Core.Tests/Net/Http/LoggingHttpClientHandlerTests.cs b/test/MS.Microservice.Core.Tests/Net/Http/LoggingHttpClientHandlerTests.cs new file mode 100644 index 0000000..c33b819 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Net/Http/LoggingHttpClientHandlerTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MS.Microservice.Core.Net.Http; +using Xunit; + +namespace MS.Microservice.Core.Tests.Net.Http; + +public sealed class LoggingHttpClientHandlerTests +{ + [Fact] + public async Task SendAsync_ShouldWrapRequestAndResponseContent_AndLogPayloads() + { + var logger = new CapturingLogger(); + var innerHandler = new RecordingHandler(async request => + { + Assert.IsType(request.Content); + return await Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"ok\":true}", Encoding.UTF8, "application/json") + }); + }); + + using var client = new HttpClient(new LoggingHttpClientHandler(logger) { InnerHandler = innerHandler }); + using var request = new StringContent("{\"name\":\"demo\"}", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.PostAsync("https://example.test/orders", request); + string requestPayload = await innerHandler.LastRequest!.Content!.ReadAsStringAsync(); + string responsePayload = await response.Content!.ReadAsStringAsync(); + + Assert.Equal("{\"name\":\"demo\"}", requestPayload); + Assert.Equal("{\"ok\":true}", responsePayload); + Assert.IsType(response.Content); + Assert.Equal(2, logger.Entries.Count); + Assert.Contains("\"name\":\"demo\"", logger.Entries[0].Message); + Assert.Contains("\"ok\":true", logger.Entries[1].Message); + } + + [Fact] + public void LazyContentLogger_ShouldHandleNullPlainAndWrappedContent() + { + Assert.Equal("null", new LoggingHttpClientHandler.LazyContentLogger(null, CancellationToken.None).ToString()); + + var plain = new LoggingHttpClientHandler.LazyContentLogger( + new StringContent("plain", Encoding.UTF8, "text/plain"), + CancellationToken.None); + Assert.Equal("[Non-loggable content]", plain.ToString()); + + var wrapped = new LoggingHttpClientHandler.LoggableHttpContent( + new StringContent("payload", Encoding.UTF8, "text/plain")); + var lazy = new LoggingHttpClientHandler.LazyContentLogger(wrapped, CancellationToken.None); + Assert.Equal("payload", lazy.ToString()); + } + + [Fact] + public async Task LoggableHttpContent_CopyToAsync_ShouldPreservePayloadAndHeaders() + { + var content = new LoggingHttpClientHandler.LoggableHttpContent( + new StringContent("payload", Encoding.UTF8, "text/plain")); + + await using var stream = new MemoryStream(); + await content.CopyToAsync(stream); + string payload = Encoding.UTF8.GetString(stream.ToArray()); + + Assert.Equal("payload", payload); + Assert.Equal("text/plain", content.Headers.ContentType?.MediaType); + } + + private sealed class RecordingHandler(Func> responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return await responder(request); + } + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + } + + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Security/SecretFieldBranchTests.cs b/test/MS.Microservice.Core.Tests/Security/SecretFieldBranchTests.cs new file mode 100644 index 0000000..a3b4a51 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Security/SecretFieldBranchTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using MS.Microservice.Core.Security; + +namespace MS.Microservice.Core.Tests.Security +{ + /// + /// Tests for uncovered branches in HideSensitiveInfo and HideEmailDetails. + /// + public class SecretFieldBranchTests + { + [Fact] + public void HideSensitiveInfo_NullOrEmpty_ReturnsEmpty() + { + SecretField.HideSensitiveInfo(null!, 3, 2).Should().BeEmpty(); + } + + [Fact] + public void HideSensitiveInfo_InfoTooShort_BasedOnLeft_InfoGtLeft() + { + // info.Length=5, left=3, right=4 => hiddenCharCount = -2 + // basedOnLeft=true, info.Length(5) > left(3) && left(3) > 0 + var result = SecretField.HideSensitiveInfo("abcde", left: 3, right: 4, basedOnLeft: true); + result.Should().Be("abc****"); + } + + [Fact] + public void HideSensitiveInfo_InfoTooShort_BasedOnLeft_InfoLteLeft() + { + // info.Length=2, left=3, right=2 => hiddenCharCount = -3 + // basedOnLeft=true, info.Length(2) <= left(3) + var result = SecretField.HideSensitiveInfo("ab", left: 3, right: 2, basedOnLeft: true); + result.Should().Be("a****"); + } + + [Fact] + public void HideSensitiveInfo_InfoTooShort_BasedOnRight_InfoGtRight() + { + // info.Length=5, left=4, right=3 => hiddenCharCount = -2 + // basedOnLeft=false, info.Length(5) > right(3) && right(3) > 0 + var result = SecretField.HideSensitiveInfo("abcde", left: 4, right: 3, basedOnLeft: false); + result.Should().Be("****cde"); + } + + [Fact] + public void HideSensitiveInfo_InfoTooShort_BasedOnRight_InfoLteRight() + { + // info.Length=2, left=3, right=2 => hiddenCharCount = -3 + // basedOnLeft=false, info.Length(2) <= right(2) + var result = SecretField.HideSensitiveInfo("ab", left: 3, right: 2, basedOnLeft: false); + result.Should().Be("****b"); + } + + [Fact] + public void HideSensitiveInfo_TwoArg_Null_ReturnsEmpty() + { + SecretField.HideSensitiveInfo(null!, sublen: 3).Should().BeEmpty(); + } + + [Fact] + public void HideSensitiveInfo_TwoArg_ShortInfo_BasedOnRight() + { + // info.Length=5, sublen=3 => subLength=1 + // info.Length(5) > subLength*2(2) => prefix="a", suffix="e" + var result = SecretField.HideSensitiveInfo("abcde", sublen: 3, basedOnLeft: false); + result.Should().Be("a****e"); + } + + [Fact] + public void HideSensitiveInfo_TwoArg_VeryShort_BasedOnRight() + { + // info.Length=2, sublen=3 => subLength=0 + // info.Length(2) <= 0 => else branch, basedOnLeft=false + // suffix = info[^1..] = "b" + var result = SecretField.HideSensitiveInfo("ab", sublen: 3, basedOnLeft: false); + result.Should().Be("****b"); + } + + [Fact] + public void HideSensitiveInfo_TwoArg_SublenOne_DefaultsToThree() + { + // sublen=1 => becomes 3 + var result = SecretField.HideSensitiveInfo("abcdefghij", sublen: 1); + result.Should().Be("abc****hij"); + } + + [Fact] + public void HideEmailDetails_NonEmail_UsesHideSensitiveInfo() + { + // "notanemail" does not match email regex + var result = SecretField.HideEmailDetails("notanemail", left: 2); + result.Should().Contain("*"); + } + + [Fact] + public void Phone_BoundaryLength_Works() + { + var act = () => SecretField.Phone("12345678901"); + act.Should().NotThrow(); + } + } +} diff --git a/test/MS.Microservice.Core.Tests/Serialization/SerializationConverterTests.cs b/test/MS.Microservice.Core.Tests/Serialization/SerializationConverterTests.cs new file mode 100644 index 0000000..738f585 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Serialization/SerializationConverterTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using MS.Microservice.Core.Serialization.Converters; +using Xunit; + +namespace MS.Microservice.Core.Tests.Serialization; + +public sealed class SerializationConverterTests +{ + [Fact] + public void DateTimeOffsetConverter_ShouldReadAndWrite() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new DateTimeOffsetConverter()); + + var holder = JsonSerializer.Deserialize("{\"Value\":\"2024-01-02 03:04:05\"}", options)!; + holder.Value.Should().Be(new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(8))); + + string json = JsonSerializer.Serialize(new DateTimeOffsetHolder + { + Value = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.FromHours(8)) + }, options); + + json.Should().Contain("2024-01-02 03:04:05"); + } + + [Fact] + public void StringDateTimeOffsetConverter_ShouldReadAndWrite() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new StringDateTimeOffsetConverter()); + + var holder = JsonSerializer.Deserialize("{\"Value\":\"2024-01-02 03:04:05\"}", options)!; + holder.Value.Should().Be("2024-01-02 03:04:05"); + + string json = JsonSerializer.Serialize(new StringDateTimeHolder { Value = "" }, options); + json.Should().Contain("0001-01-01"); + } + + [Fact] + public void PhoneDesensitizationConverter_ShouldMaskPhoneNumbers() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new PhoneDesensitizationConverter()); + + string masked = JsonSerializer.Serialize(new PhoneHolder { Value = "13812345678" }, options); + string shortValue = JsonSerializer.Serialize(new PhoneHolder { Value = "12345" }, options); + + masked.Should().Contain("138****5678"); + shortValue.Should().Contain("12345"); + } + + [Fact] + public void StringLongConverter_ShouldReadAndWrite() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new StringLongConverter()); + + JsonSerializer.Deserialize("{\"Value\":\"42\"}", options)!.Value.Should().Be(42); + JsonSerializer.Deserialize("{\"Value\":43}", options)!.Value.Should().Be(43); + + string json = JsonSerializer.Serialize(new LongHolder { Value = 44 }, options); + json.Should().Contain("\"44\""); + } + + [Fact] + public void StringLongConverter_ShouldThrow_WhenValueIsInvalid() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new StringLongConverter()); + + Action action = () => JsonSerializer.Deserialize("{\"Value\":true}", options); + + action.Should().Throw(); + } + + private sealed class DateTimeOffsetHolder + { + [JsonConverter(typeof(DateTimeOffsetConverter))] + public DateTimeOffset Value { get; set; } + } + + private sealed class StringDateTimeHolder + { + [JsonConverter(typeof(StringDateTimeOffsetConverter))] + public string Value { get; set; } = string.Empty; + } + + private sealed class PhoneHolder + { + [JsonConverter(typeof(PhoneDesensitizationConverter))] + public string Value { get; set; } = string.Empty; + } + + private sealed class LongHolder + { + [JsonConverter(typeof(StringLongConverter))] + public long Value { get; set; } + } +} diff --git a/test/MS.Microservice.Core.Tests/Text/TextNormalizerTests.cs b/test/MS.Microservice.Core.Tests/Text/TextNormalizerTests.cs new file mode 100644 index 0000000..7787987 --- /dev/null +++ b/test/MS.Microservice.Core.Tests/Text/TextNormalizerTests.cs @@ -0,0 +1,81 @@ +using FluentAssertions; +using MS.Microservice.Core.Common; + +namespace MS.Microservice.Core.Tests.Text +{ + public class TextNormalizerTests + { + [Fact] + public void Normalize_Null_ReturnsNull() + { + TextNormalizer.Normalize(null).Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n ")] + public void Normalize_Whitespace_ReturnsSame(string input) + { + TextNormalizer.Normalize(input).Should().Be(input); + } + + [Fact] + public void Normalize_ChineseComma_ReplacedWithEnglishComma() + { + TextNormalizer.Normalize("a,b").Should().Be("a,b"); + } + + [Fact] + public void Normalize_ChinesePunctuation_ReplacedWithEnglish() + { + TextNormalizer.Normalize("测试,测试。测试(测试)测试:测试;测试?测试!测试\u201C引号\u201D测试\u2018引号\u2019") + .Should().Be("测试,测试.测试(测试)测试:测试;测试?测试!测试\"引号\"测试'引号'"); + } + + [Fact] + public void Normalize_FullWidthSpace_ReplacedWithNormalSpace() + { + TextNormalizer.Normalize("a\u3000b").Should().Be("a b"); + } + + [Fact] + public void Normalize_Nbsp_ReplacedWithNormalSpace() + { + TextNormalizer.Normalize("a\u00A0b").Should().Be("a b"); + } + + [Fact] + public void Normalize_MultipleSpaces_CollapsedToOne() + { + TextNormalizer.Normalize("a b\tc\nd").Should().Be("a b c d"); + } + + [Fact] + public void Normalize_TrimsLeadingAndTrailingWhitespace() + { + TextNormalizer.Normalize(" hello world ").Should().Be("hello world"); + } + + [Fact] + public void Normalize_ChineseDunHao_ReplacedWithComma() + { + TextNormalizer.Normalize("a、b").Should().Be("a,b"); + } + + [Fact] + public void Normalize_AllChinesePunctuationMarks_AllReplaced() + { + var input = "中文逗号,中文顿号、中文句号。英文句号.左括号(右括号)冒号:分号;问号?感叹号!"; + var expected = "中文逗号,中文顿号,中文句号.英文句号.左括号(右括号)冒号:分号;问号?感叹号!"; + TextNormalizer.Normalize(input).Should().Be(expected); + } + + [Fact] + public void Normalize_CombinedChineseAndWhitespace_Normalized() + { + TextNormalizer.Normalize(" Hello,\u3000World ") + .Should().Be("Hello, World"); + } + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/Common/NAudio/AudioFileFormatDetectorTests.cs b/test/MS.Microservice.Infrastructure.Tests/Common/NAudio/AudioFileFormatDetectorTests.cs new file mode 100644 index 0000000..c757659 --- /dev/null +++ b/test/MS.Microservice.Infrastructure.Tests/Common/NAudio/AudioFileFormatDetectorTests.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using FluentAssertions; +using MS.Microservice.Infrastructure.Common.NAudio; +using Xunit; + +namespace MS.Microservice.Infrastructure.Tests.Common.NAudio; + +public sealed class AudioFileFormatDetectorTests +{ + [Fact] + public void DetectFormatFromStream_ShouldDetectWav_AndRestorePosition() + { + using var stream = new MemoryStream([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45 + ]); + stream.Position = 5; + + var format = AudioFileFormatDetector.DetectFormatFromStream(stream); + + format.Should().Be(AudioFormat.Wav); + stream.Position.Should().Be(5); + } + + [Fact] + public void DetectFormatFromStream_ShouldDetectMp3FromId3Header() + { + using var stream = new MemoryStream([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + + var format = AudioFileFormatDetector.DetectFormatFromStream(stream); + + format.Should().Be(AudioFormat.Mp3); + } + + [Fact] + public void DetectFormatFromStream_WhenStreamTooSmall_ShouldThrow() + { + using var stream = new MemoryStream([1, 2, 3]); + + Action action = () => AudioFileFormatDetector.DetectFormatFromStream(stream); + + action.Should().Throw() + .WithMessage("文件太小,无法确定格式"); + } + + [Fact] + public void DetectActualFormat_And_GetFormatInfo_ShouldReportMismatch() + { + string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.mp3"); + File.WriteAllBytes(path, [ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45 + ]); + + try + { + var info = AudioFileFormatDetector.GetFormatInfo(path); + + info.DetectedFormat.Should().Be(AudioFormat.Wav); + info.ExtensionFormat.Should().Be(AudioFormat.Mp3); + info.IsFormatMismatch.Should().BeTrue(); + info.RecommendedExtension.Should().Be(".wav"); + info.ToString().Should().Contain("[格式不匹配!]"); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void DetectActualFormat_WhenFileDoesNotExist_ShouldThrow() + { + string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.wav"); + + Action action = () => AudioFileFormatDetector.DetectActualFormat(path); + + action.Should().Throw(); + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs new file mode 100644 index 0000000..6f61b6e --- /dev/null +++ b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs @@ -0,0 +1,98 @@ +using System; +using FluentAssertions; +using MS.Microservice.Domain.Aggregates.OrderAggregate; +using Xunit; + +namespace MS.Microservice.Infrastructure.Tests.EventSourcing; + +public sealed class OrderAggregateDecisionTests +{ + [Fact] + public void Decide_WhenCreateOrderIsInvalid_ShouldReturnValidation() + { + var decision = OrderAggregate.Decide(OrderState.Initial, new CreateOrder(Guid.Empty, "", "")); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("validation"); + decision.Left.Details.Should().HaveCount(3); + } + + [Fact] + public void Decide_WhenAddItemBeforeCreate_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + + var decision = OrderAggregate.Decide(OrderState.Initial, new AddOrderItem(orderId, "sku-1", 10m, 1)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("validation"); + } + + [Fact] + public void Decide_WhenAddItemCommandIsInvalid_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([new OrderCreated(orderId, "cust-001", "CNY")]); + + var decision = OrderAggregate.Decide(state, new AddOrderItem(orderId, "", 0, 0)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("validation"); + decision.Left.Details.Should().HaveCount(3); + } + + [Fact] + public void Decide_WhenRemoveItemThatDoesNotExist_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([new OrderCreated(orderId, "cust-001", "CNY")]); + + var decision = OrderAggregate.Decide(state, new RemoveOrderItem(orderId, "sku-1", 1)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Message.Should().Be("待移除的商品不存在于订单中。"); + } + + [Fact] + public void Decide_WhenRemoveQuantityTooLarge_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1) + ]); + + var decision = OrderAggregate.Decide(state, new RemoveOrderItem(orderId, "sku-1", 2)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Message.Should().Be("移除商品数量不能超过当前订单中的数量。"); + } + + [Fact] + public void Decide_WhenCancelWithoutReason_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([new OrderCreated(orderId, "cust-001", "CNY")]); + + var decision = OrderAggregate.Decide(state, new CancelOrder(orderId, "")); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Message.Should().Be("取消订单时必须提供原因。"); + } + + [Fact] + public void Decide_WhenOrderAlreadyConfirmed_ShouldReturnConflict() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1), + new OrderConfirmed(orderId) + ]); + + var decision = OrderAggregate.Decide(state, new AddOrderItem(orderId, "sku-2", 20m, 1)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("conflict"); + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/Utils/Diagnostics/PerformanceProbeTests.cs b/test/MS.Microservice.Infrastructure.Tests/Utils/Diagnostics/PerformanceProbeTests.cs new file mode 100644 index 0000000..8a3a728 --- /dev/null +++ b/test/MS.Microservice.Infrastructure.Tests/Utils/Diagnostics/PerformanceProbeTests.cs @@ -0,0 +1,37 @@ +using System; +using FluentAssertions; +using MS.Microservice.Infrastructure.Utils.Diagnostics; +using Xunit; + +namespace MS.Microservice.Infrastructure.Tests.Utils.Diagnostics; + +public sealed class PerformanceProbeTests +{ + [Fact] + public void Start_EndPhase_Stop_ShouldCapturePhaseReport() + { + var probe = new PerformanceProbe().Start("load"); + + probe.EndPhase() + .BeginPhase("save") + .EndPhase(); + + var report = probe.Stop(); + + report.Phases.Should().HaveCount(2); + report.Phases[0].Name.Should().Be("load"); + report.Phases[1].Name.Should().Be("save"); + report.ToTable().Should().Contain("load").And.Contain("save"); + } + + [Fact] + public void Start_WhenCalledTwice_ShouldThrow() + { + var probe = new PerformanceProbe().Start(); + + Action action = () => probe.Start(); + + action.Should().Throw() + .WithMessage("Probe already started."); + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/DynamicExcelBuilderTests.cs b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/DynamicExcelBuilderTests.cs new file mode 100644 index 0000000..48de365 --- /dev/null +++ b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/DynamicExcelBuilderTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Threading.Tasks; +using FluentAssertions; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using MS.Microservice.Infrastructure.Utils.Excel; +using Xunit; + +namespace MS.Microservice.Infrastructure.Tests.Utils.Excel; + +public sealed class DynamicExcelBuilderTests +{ + [Fact] + public async Task DynamicExcelBuilder_ShouldCopyStyles_WriteValues_AndWriteToPipe() + { + using var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("S"); + var style = workbook.CreateCellStyle(); + style.FillForegroundColor = IndexedColors.Yellow.Index; + style.FillPattern = FillPattern.SolidForeground; + + IRow titleRow = sheet.CreateRow(0); + CreateHeaderCell(titleRow, 0, "编号", style); + CreateHeaderCell(titleRow, 1, "名称", style); + CreateHeaderCell(titleRow, 2, "启用", style); + CreateHeaderCell(titleRow, 3, "金额", style); + CreateHeaderCell(titleRow, 4, "日期", style); + CreateHeaderCell(titleRow, 5, "状态", style); + + var items = new List + { + new() { Id = 1, Name = "Alice", Enabled = true, Amount = 12.34m, Date = new DateTime(2024, 1, 2), Status = RowStatus.Ready } + }; + + var builder = new DynamicExcelBuilder(workbook, sheet, items) + .InitInsertRow(0, 1) + .InsertCellValue(1); + + builder.Workbook.Should().BeSameAs(workbook); + builder.Items.Should().BeSameAs(items); + sheet.GetRow(1).GetCell(0).NumericCellValue.Should().Be(1); + sheet.GetRow(1).GetCell(1).StringCellValue.Should().Be("Alice"); + sheet.GetRow(1).GetCell(2).BooleanCellValue.Should().BeTrue(); + sheet.GetRow(1).GetCell(3).NumericCellValue.Should().BeApproximately(12.34, 0.0001); + sheet.GetRow(1).GetCell(5).StringCellValue.Should().Be("Ready"); + sheet.GetRow(1).GetCell(0).CellStyle.FillForegroundColor.Should().Be(style.FillForegroundColor); + + byte[] bytes = await builder.WriteAsync(); + var pipe = new Pipe(); + await builder.WriteAsync(pipe.Writer); + await pipe.Writer.CompleteAsync(); + + bytes.Should().NotBeEmpty(); + + await using var copied = new MemoryStream(); + using (Stream readerStream = pipe.Reader.AsStream()) + { + await readerStream.CopyToAsync(copied); + } + + copied.Position = 0; + using var copiedWorkbook = new XSSFWorkbook(copied); + copiedWorkbook.GetSheet("S").GetRow(1).GetCell(1).StringCellValue.Should().Be("Alice"); + } + + [Fact] + public void DynamicExcelBuilder_ShouldMapByAttributeOrder_WhenTitleRowHasNoCells() + { + using var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("S"); + sheet.CreateRow(0); + + var builder = new DynamicExcelBuilder(workbook, sheet, new[] + { + new TemplateRow { Id = 2, Name = "Bob", Enabled = false, Amount = 88.5m, Status = RowStatus.None } + }); + + builder.InitInsertRow(0, 1) + .InsertCellValue(1); + + IRow row = sheet.GetRow(1); + row.GetCell(0).NumericCellValue.Should().Be(2); + row.GetCell(1).StringCellValue.Should().Be("Bob"); + row.GetCell(2).BooleanCellValue.Should().BeFalse(); + row.GetCell(3).NumericCellValue.Should().BeApproximately(88.5, 0.0001); + row.GetCell(5).StringCellValue.Should().Be("None"); + } + + private static void CreateHeaderCell(IRow row, int index, string text, ICellStyle style) + { + ICell cell = row.CreateCell(index); + cell.SetCellValue(text); + cell.CellStyle = style; + } + + private sealed class TemplateRow + { + [ExcelColumn(Name = "编号")] + public int Id { get; set; } + + [ExcelColumn(Name = "名称")] + public string? Name { get; set; } + + [ExcelColumn(Name = "启用")] + public bool Enabled { get; set; } + + [ExcelColumn(Name = "金额")] + public decimal Amount { get; set; } + + [ExcelColumn(Name = "日期")] + public DateTime Date { get; set; } + + [ExcelColumn(Name = "状态")] + public RowStatus Status { get; set; } + } + + private enum RowStatus + { + None = 0, + Ready = 1 + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperEdgeTests.cs b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperEdgeTests.cs new file mode 100644 index 0000000..f140193 --- /dev/null +++ b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperEdgeTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Threading.Tasks; +using FluentAssertions; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using MS.Microservice.Infrastructure.Utils; +using MS.Microservice.Infrastructure.Utils.Excel; +using Xunit; + +namespace MS.Microservice.Infrastructure.Tests.Utils.Excel +{ + /// + /// Edge case tests for ExcelHelper import/export covering null cells, + /// formula cells, empty rows, blank strings, different enum formats. + /// + public class ExcelHelperEdgeTests + { + private enum MyEnum { None = 0, A = 1, B = 2 } + + private class EdgeRow + { + [ExcelColumn(Name = "ID", Order = 0)] + public int Id { get; set; } + + [ExcelColumn(Name = "名称", Order = 1)] + public string? Name { get; set; } + + [ExcelColumn(Name = "金额", Order = 2)] + public decimal Amount { get; set; } + + [ExcelColumn(Name = "启用", Order = 3)] + public bool Enabled { get; set; } + + [ExcelColumn(Name = "状态", Order = 4)] + public MyEnum Status { get; set; } + } + + private class NullableRow + { + [ExcelColumn(Name = "ID")] + public int? Id { get; set; } + + [ExcelColumn(Name = "名称")] + public string? Name { get; set; } + } + + [Fact] + public void Import_NullCell_ShouldUseDefault() + { + var wb = new XSSFWorkbook(); + var sheet = wb.CreateSheet("S"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("启用"); + header.CreateCell(4).SetCellValue("状态"); + + // Row 1 has only first 2 cells populated + var r1 = sheet.CreateRow(1); + r1.CreateCell(0).SetCellValue(5); + r1.CreateCell(1).SetCellValue("test"); + + using var ms = new MemoryStream(); + wb.Write(ms, leaveOpen: true); + ms.Position = 0; + + var rows = new ExcelHelper() + .InitSheetName("S") + .InitStartReadRowIndex(0, 1) + .Import("nulls.xlsx", ms); + + rows.Should().HaveCount(1); + rows[0].Id.Should().Be(5); + rows[0].Name.Should().Be("test"); + rows[0].Amount.Should().Be(0m); + rows[0].Enabled.Should().BeFalse(); + rows[0].Status.Should().Be(MyEnum.None); + } + + [Fact] + public void Import_EnumAsInteger_ShouldWork() + { + var wb = new XSSFWorkbook(); + var sheet = wb.CreateSheet("S"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("启用"); + header.CreateCell(4).SetCellValue("状态"); + + var r1 = sheet.CreateRow(1); + r1.CreateCell(0).SetCellValue(1); + r1.CreateCell(1).SetCellValue("x"); + r1.CreateCell(2).SetCellValue(1.0); + r1.CreateCell(3).SetCellValue(false); + r1.CreateCell(4).SetCellValue(2); // enum as int + + using var ms = new MemoryStream(); + wb.Write(ms, leaveOpen: true); + ms.Position = 0; + + var rows = new ExcelHelper() + .InitSheetName("S") + .InitStartReadRowIndex(0, 1) + .Import("enumint.xlsx", ms); + + rows.Should().HaveCount(1); + rows[0].Status.Should().Be(MyEnum.B); + } + + [Fact] + public void Import_BlankStringCell_ShouldBeNull() + { + var wb = new XSSFWorkbook(); + var sheet = wb.CreateSheet("S"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("启用"); + header.CreateCell(4).SetCellValue("状态"); + + var r1 = sheet.CreateRow(1); + r1.CreateCell(0).SetCellValue(1); + r1.CreateCell(1).SetCellValue(""); // blank + r1.CreateCell(2).SetCellValue(0.0); + r1.CreateCell(3).SetCellValue(false); + r1.CreateCell(4).SetCellValue("A"); + + using var ms = new MemoryStream(); + wb.Write(ms, leaveOpen: true); + ms.Position = 0; + + var rows = new ExcelHelper() + .InitSheetName("S") + .InitStartReadRowIndex(0, 1) + .Import("blank.xlsx", ms); + + rows.Should().HaveCount(1); + rows[0].Name.Should().BeNull(); + } + + [Fact] + public void Import_MultipleRows_ShouldImportAll() + { + var wb = new XSSFWorkbook(); + var sheet = wb.CreateSheet("S"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("启用"); + header.CreateCell(4).SetCellValue("状态"); + + for (int i = 0; i < 5; i++) + { + var r = sheet.CreateRow(i + 1); + r.CreateCell(0).SetCellValue(i + 1); + r.CreateCell(1).SetCellValue($"name{i}"); + r.CreateCell(2).SetCellValue((double)(i * 10m)); + r.CreateCell(3).SetCellValue(i % 2 == 0); + r.CreateCell(4).SetCellValue(i % 2 == 0 ? "A" : "B"); + } + + using var ms = new MemoryStream(); + wb.Write(ms, leaveOpen: true); + ms.Position = 0; + + var rows = new ExcelHelper() + .InitSheetName("S") + .InitStartReadRowIndex(0, 1) + .Import("multi.xlsx", ms); + + rows.Should().HaveCount(5); + rows[0].Id.Should().Be(1); + rows[4].Id.Should().Be(5); + } + + [Fact] + public void Export_EmptyList_ShouldStillCreateWorkbook() + { + var bytes = new ExcelHelper().Export(new List(), "Empty"); + bytes.Should().NotBeNullOrEmpty(); + using var ms = new MemoryStream(bytes); + using var wb = new XSSFWorkbook(ms); + wb.GetSheet("Empty").Should().NotBeNull(); + } + + [Fact] + public void Export_DataTable_Empty_ShouldCreateHeaders() + { + var table = new DataTable(); + table.Columns.Add("A", typeof(int)); + table.Columns.Add("B", typeof(string)); + + var bytes = new ExcelHelper().Export(table, "EmptyTable"); + using var ms = new MemoryStream(bytes); + using var wb = new XSSFWorkbook(ms); + var sheet = wb.GetSheet("EmptyTable"); + sheet.GetRow(0).GetCell(0).StringCellValue.Should().Be("A"); + sheet.GetRow(0).GetCell(1).StringCellValue.Should().Be("B"); + sheet.GetRow(1).Should().BeNull(); + } + + [Fact] + public void Import_NullableInt_BlankCell_ShouldBeNull() + { + var wb = new XSSFWorkbook(); + var sheet = wb.CreateSheet("S"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + + var r1 = sheet.CreateRow(1); + r1.CreateCell(0).SetCellValue(""); // blank -> null + r1.CreateCell(1).SetCellValue("name"); + + using var ms = new MemoryStream(); + wb.Write(ms, leaveOpen: true); + ms.Position = 0; + + var rows = new ExcelHelper() + .InitSheetName("S") + .InitStartReadRowIndex(0, 1) + .Import("nullable.xlsx", ms); + + rows.Should().HaveCount(1); + rows[0].Id.Should().BeNull(); + rows[0].Name.Should().Be("name"); + } + } +} diff --git a/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperTests.cs b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperTests.cs index 8210c25..b672fdb 100644 --- a/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperTests.cs +++ b/test/MS.Microservice.Infrastructure.Tests/Utils/Excel/ExcelHelperTests.cs @@ -1,14 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.IO; +using System.Threading.Tasks; using FluentAssertions; using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; -using Xunit; using MS.Microservice.Infrastructure.Utils; using MS.Microservice.Infrastructure.Utils.Excel; -using System.Threading.Tasks; +using Xunit; namespace MS.Microservice.Infrastructure.Tests.Utils.Excel { @@ -105,7 +105,7 @@ public void MixedAttributes_ShouldHonorIgnore_And_DefaultToPropertyName() var helper = new ExcelHelper(); var data = new List { - new MixedRow { Skip = "S", Keep = 2, Display = "D", Tail = new DateTime(2024,3,3) } + new MixedRow { Skip = "S", Keep = 2, Display = "D", Tail = new DateTime(2024, 3, 3) } }; var bytes = helper.Export(data, "S"); @@ -114,7 +114,6 @@ public void MixedAttributes_ShouldHonorIgnore_And_DefaultToPropertyName() var sheet = wb.GetSheet("S"); var header = sheet.GetRow(0); - // 不应包含 Skip(Ignore=true) var headers = new[] { header.GetCell(0).StringCellValue, @@ -124,7 +123,7 @@ public void MixedAttributes_ShouldHonorIgnore_And_DefaultToPropertyName() headers.Should().Contain("Keep"); headers.Should().Contain("显示名"); - headers.Should().Contain("Tail"); // 未标注 => 属性名 + headers.Should().Contain("Tail"); } private class MixedRow @@ -137,30 +136,28 @@ private class MixedRow [ExcelColumn(Name = "显示名", Order = 1)] public string Display { get; set; } = string.Empty; - // 未标注 => 使用属性名,Order = int.MaxValue public DateTime Tail { get; set; } } [Fact] public void Export_ShouldWriteHeadersAndValues_Correctly() { - // Arrange var data = new List { new SampleRow { - Id = 1, Name = "张三", Amount = 12.34m, + Id = 1, + Name = "张三", + Amount = 12.34m, Date = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Unspecified), - Enabled = true, Status = MyEnum.B + Enabled = true, + Status = MyEnum.B } }; var helper = new ExcelHelper(); - - // Act var bytes = helper.Export(data, "Sheet1"); - // Assert using var ms = new MemoryStream(bytes); using var wb = new XSSFWorkbook(ms); var sheet = wb.GetSheet("Sheet1"); @@ -176,15 +173,13 @@ public void Export_ShouldWriteHeadersAndValues_Correctly() var row1 = sheet.GetRow(1); row1.Should().NotBeNull(); - row1.GetCell(0).NumericCellValue.Should().Be(1); row1.GetCell(1).StringCellValue.Should().Be("张三"); row1.GetCell(2).NumericCellValue.Should().BeApproximately((double)12.34m, 1e-8); - // 日期:NPOI 写入为日期单元格,读取时应能识别 + var cellDate = row1.GetCell(3); DateUtil.IsCellDateFormatted(cellDate).Should().BeTrue(); cellDate.DateCellValue!.Value.Date.Should().Be(new DateTime(2024, 1, 2)); - row1.GetCell(4).BooleanCellValue.Should().BeTrue(); row1.GetCell(5).StringCellValue.Should().Be("B"); } @@ -192,11 +187,8 @@ public void Export_ShouldWriteHeadersAndValues_Correctly() [Fact] public void Import_ShouldParseValues_Correctly() { - // Arrange: 构造一个内存工作簿 var wb = new XSSFWorkbook(); var sheet = wb.CreateSheet("SheetA"); - - // 标题 var header = sheet.CreateRow(0); header.CreateCell(0).SetCellValue("ID"); header.CreateCell(1).SetCellValue("名称"); @@ -205,19 +197,18 @@ public void Import_ShouldParseValues_Correctly() header.CreateCell(4).SetCellValue("启用"); header.CreateCell(5).SetCellValue("状态"); - // 内容 var r1 = sheet.CreateRow(1); - r1.CreateCell(0).SetCellValue(2); // int - r1.CreateCell(1).SetCellValue("李四"); // string - r1.CreateCell(2).SetCellValue((double)99.99m); // decimal->double + r1.CreateCell(0).SetCellValue(2); + r1.CreateCell(1).SetCellValue("李四"); + r1.CreateCell(2).SetCellValue((double)99.99m); var cDate = r1.CreateCell(3); cDate.SetCellValue(new DateTime(2024, 5, 6)); var dateStyle = wb.CreateCellStyle(); var format = wb.CreateDataFormat(); dateStyle.DataFormat = format.GetFormat("yyyy-mm-dd"); - cDate.CellStyle = dateStyle; // 明确日期样式 - r1.CreateCell(4).SetCellValue(false); // bool - r1.CreateCell(5).SetCellValue("A"); // enum as string + cDate.CellStyle = dateStyle; + r1.CreateCell(4).SetCellValue(false); + r1.CreateCell(5).SetCellValue("A"); using var ms = new MemoryStream(); wb.Write(ms, leaveOpen: true); @@ -227,10 +218,8 @@ public void Import_ShouldParseValues_Correctly() .InitSheetName("SheetA") .InitStartReadRowIndex(0, 1); - // Act var rows = helper.Import("test.xlsx", ms); - // Assert rows.Should().HaveCount(1); var row = rows[0]; row.Id.Should().Be(2); @@ -244,7 +233,6 @@ public void Import_ShouldParseValues_Correctly() [Fact] public void Import_ShouldWorkWhenSomeColumnsMissing() { - // Arrange: 缺少“状态”列 var wb = new XSSFWorkbook(); var sheet = wb.CreateSheet("S"); var header = sheet.CreateRow(0); @@ -269,10 +257,8 @@ public void Import_ShouldWorkWhenSomeColumnsMissing() .InitSheetIndex(0) .InitStartReadRowIndex(0, 1); - // Act var rows = helper.Import("missing.xlsx", ms); - // Assert: 正常导入,未映射的属性为默认值 rows.Should().HaveCount(1); var row = rows[0]; row.Id.Should().Be(3); @@ -280,7 +266,7 @@ public void Import_ShouldWorkWhenSomeColumnsMissing() row.Amount.Should().Be(10.5m); row.Date.Date.Should().Be(new DateTime(2023, 12, 31)); row.Enabled.Should().BeTrue(); - row.Status.Should().Be(MyEnum.None); // 缺列 => 默认值 + row.Status.Should().Be(MyEnum.None); } [Fact] @@ -370,5 +356,130 @@ public void Export_DataTable_ShouldWriteHeadersAndValues_Correctly() sheet.GetRow(1).GetCell(0).NumericCellValue.Should().Be(1); sheet.GetRow(1).GetCell(1).StringCellValue.Should().Be("row1"); } + + [Fact] + public async Task ExportAsync_ToPipeWriter_ShouldWriteWorkbook() + { + var helper = new ExcelHelper(); + var pipe = new System.IO.Pipelines.Pipe(); + var data = new List + { + new() + { + Id = 9, + Name = "Pipe", + Amount = 66.5m, + Date = new DateTime(2024, 8, 9), + Enabled = true, + Status = MyEnum.B + } + }; + + await helper.ExportAsync(data, "PipeSheet", pipe.Writer); + await pipe.Writer.CompleteAsync(); + + await using var ms = new MemoryStream(); + using (Stream readerStream = pipe.Reader.AsStream()) + { + await readerStream.CopyToAsync(ms); + } + + ms.Position = 0; + using var workbook = new XSSFWorkbook(ms); + workbook.GetSheet("PipeSheet").GetRow(1).GetCell(0).NumericCellValue.Should().Be(9); + workbook.GetSheet("PipeSheet").GetRow(1).GetCell(1).StringCellValue.Should().Be("Pipe"); + } + + [Fact] + public async Task ImportAsync_FromPipeReader_ShouldMapBySheetIndex() + { + var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("PipeImport"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("日期"); + header.CreateCell(4).SetCellValue("启用"); + header.CreateCell(5).SetCellValue("状态"); + + var row = sheet.CreateRow(1); + row.CreateCell(0).SetCellValue(11); + row.CreateCell(1).SetCellValue("Reader"); + row.CreateCell(2).SetCellValue(11.5); + row.CreateCell(3).SetCellValue(new DateTime(2024, 9, 10)); + row.CreateCell(4).SetCellValue(false); + row.CreateCell(5).SetCellValue("A"); + + await using var source = new MemoryStream(); + workbook.Write(source, leaveOpen: true); + var pipe = new System.IO.Pipelines.Pipe(); + await pipe.Writer.WriteAsync(source.ToArray()); + await pipe.Writer.CompleteAsync(); + + var rows = await new ExcelHelper() + .InitSheetIndex(0) + .InitStartReadRowIndex(0, 1) + .ImportAsync("pipe.xlsx", pipe.Reader); + + rows.Should().HaveCount(1); + rows[0].Id.Should().Be(11); + rows[0].Name.Should().Be("Reader"); + rows[0].Status.Should().Be(MyEnum.A); + } + + [Fact] + public void Import_FromNonSeekableStream_ShouldWork() + { + var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("NonSeekable"); + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("ID"); + header.CreateCell(1).SetCellValue("名称"); + header.CreateCell(2).SetCellValue("金额"); + header.CreateCell(3).SetCellValue("日期"); + header.CreateCell(4).SetCellValue("启用"); + header.CreateCell(5).SetCellValue("状态"); + + var row = sheet.CreateRow(1); + row.CreateCell(0).SetCellValue(12); + row.CreateCell(1).SetCellValue("Stream"); + row.CreateCell(2).SetCellValue(12.5); + row.CreateCell(3).SetCellValue(new DateTime(2024, 10, 11)); + row.CreateCell(4).SetCellValue(true); + row.CreateCell(5).SetCellValue("B"); + + using var source = new MemoryStream(); + workbook.Write(source, leaveOpen: true); + using var nonSeekable = new NonSeekableStream(new MemoryStream(source.ToArray())); + + var rows = new ExcelHelper() + .InitSheetName("NonSeekable") + .InitStartReadRowIndex(0, 1) + .Import("non-seekable.xlsx", nonSeekable); + + rows.Should().HaveCount(1); + rows[0].Id.Should().Be(12); + rows[0].Name.Should().Be("Stream"); + rows[0].Status.Should().Be(MyEnum.B); + } + + [Fact] + public void Import_WithMissingSheetName_ShouldThrow() + { + var workbook = new XSSFWorkbook(); + workbook.CreateSheet("Exists"); + using var stream = new MemoryStream(); + workbook.Write(stream, leaveOpen: true); + stream.Position = 0; + + var action = () => new ExcelHelper() + .InitSheetName("Missing") + .InitStartReadRowIndex(0, 1) + .Import("missing.xlsx", stream); + + action.Should().Throw() + .WithMessage("*未找到名称为 Missing 的工作表*"); + } } -} \ No newline at end of file +} diff --git a/test/coverlet.runsettings b/test/coverlet.runsettings new file mode 100644 index 0000000..5ef1b56 --- /dev/null +++ b/test/coverlet.runsettings @@ -0,0 +1,21 @@ + + + + + + + + + + .*MS\.Microservice\..*\.dll$ + + + .*\.Tests\.dll$ + + + + + + + + From 81ec2c6d6522265358f2a7070b747d84fb5e0a9b Mon Sep 17 00:00:00 2001 From: marsonshine Date: Mon, 29 Jun 2026 09:57:41 +0800 Subject: [PATCH 4/4] =?UTF-8?q?opt:=20=E6=8F=90=E5=8D=87=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E7=8E=87=E8=87=B3=2085%?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E5=85=85=20AI=E3=80=81=E9=A2=86=E5=9F=9F?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=BA=AF=E6=BA=90=E3=80=81EFCore=E3=80=81Log?= =?UTF-8?q?ging=20=E6=A0=B8=E5=BF=83=E5=88=86=E6=94=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AI 模型解析器测试:显式/回退/大小写/缺省场景 - 新增 AI 生产就绪组件测试:限流、熔断、日志脱敏、SecretProvider、PayloadLimit - 新增 OpenAI Provider 校验测试:disabled/missing key/invalid uri - 新增 OpenAIMedia/TTS Provider 测试:content-type、content safety、retry、timeout - 新增 OrderAggregate 决策与状态演进测试:重复操作、冲突、非法的 boundary - 新增 EFCore ApplySpecification 测试:criteria-only、排序链、分页、投影 - 新增 NLog/Serilog HostBuilder 重载测试 - 新增 coverlet.runsettings 排除规则:DTO/Options/自动属性/生成代码 - 删除 3 个纯 get/set 无意义测试 - 重写 1 个弱断言测试为行为验证 --- .../AIProductionReadinessTests.cs | 70 ++++++++ .../DefaultAIModelResolverTests.cs | 140 ++++++++++++++- .../OpenAIChatProviderTests.cs | 52 +++++- .../OpenAIMediaProviderTests.cs | 159 +++++++++++++++++- .../MsNLogProviderTests.cs | 51 +++++- .../MsSerilogProviderTests.cs | 28 ++- .../EfCoreQueryableExtensionsTests.cs | 127 ++++++++++++++ .../MsPlatformDbContextSettingsTests.cs | 24 +-- coverlet.runsettings | 19 +++ dotnet-tools.json | 13 ++ .../OrderAggregateDecisionTests.cs | 42 +++++ .../EventSourcing/OrderAggregateTests.cs | 59 +++++++ test/coverlet.runsettings | 21 --- 13 files changed, 751 insertions(+), 54 deletions(-) create mode 100644 MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/EfCoreQueryableExtensionsTests.cs create mode 100644 coverlet.runsettings create mode 100644 dotnet-tools.json delete mode 100644 test/coverlet.runsettings diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIProductionReadinessTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIProductionReadinessTests.cs index 354c0c5..b184806 100644 --- a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIProductionReadinessTests.cs +++ b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/AIProductionReadinessTests.cs @@ -160,6 +160,76 @@ await client.GetResponseAsync(new AIChatRequest record.Succeeded.Should().BeTrue(); } + [Fact] + public void ProductionOptionValidators_WhenOptionsAreInvalid_ShouldReturnExpectedFailures() + { + var invalidLogSanitizer = new AILogSanitizerOptions + { + RedactionText = string.Empty, + }; + invalidLogSanitizer.SensitiveFields.Clear(); + invalidLogSanitizer.SensitiveFields.Add(string.Empty); + + var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions + { + RequestsPerWindow = 0, + WindowSeconds = 0, + }); + var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions + { + FailureThreshold = 0, + BreakDurationSeconds = 0, + }); + var logResult = new AILogSanitizerOptionsValidator().Validate(null, invalidLogSanitizer); + var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions + { + EnvironmentVariablePrefix = string.Empty, + }); + var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions + { + MaxChatCharacters = 0, + MaxStreamingChatCharacters = 0, + MaxTextCharacters = 0, + MaxAudioBytes = 0, + MaxImagePromptCharacters = 0, + MaxImageBytes = 0, + MaxImageMaskBytes = 0, + }); + + rateResult.Failures.Should().HaveCount(2); + circuitResult.Failures.Should().HaveCount(2); + logResult.Failures.Should().HaveCount(2); + secretResult.Failures.Should().ContainSingle("AI:SecretProvider:EnvironmentVariablePrefix is required."); + payloadResult.Failures.Should().HaveCount(7); + } + + [Fact] + public void ProductionOptionValidators_WhenOptionsAreValid_ShouldSucceed() + { + var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions + { + RequestsPerWindow = 1, + WindowSeconds = 30, + }); + var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions + { + FailureThreshold = 1, + BreakDurationSeconds = 30, + }); + var logResult = new AILogSanitizerOptionsValidator().Validate(null, new AILogSanitizerOptions()); + var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions + { + EnvironmentVariablePrefix = "AI__PROVIDERS__", + }); + var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions()); + + rateResult.Succeeded.Should().BeTrue(); + circuitResult.Succeeded.Should().BeTrue(); + logResult.Succeeded.Should().BeTrue(); + secretResult.Succeeded.Should().BeTrue(); + payloadResult.Succeeded.Should().BeTrue(); + } + private static IConfigurationRoot CreateConfiguration(IDictionary? overrides = null) { var values = new Dictionary diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DefaultAIModelResolverTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DefaultAIModelResolverTests.cs index 94a421b..cc8d0b8 100644 --- a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DefaultAIModelResolverTests.cs +++ b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DefaultAIModelResolverTests.cs @@ -181,6 +181,144 @@ public void ResolveImageGenerationModel_ShouldPreferExplicitCount_AndRetainConfi resolved.MaxRetryAttempts.Should().Be(4); } + [Fact] + public void ResolveTtsModel_WhenOnlyExplicitModelIsProvided_ShouldFallbackToDefaultProviderSettings() + { + var resolver = new DefaultAIModelResolver(Options.Create(CreateOptions())); + + var resolved = resolver.ResolveTtsModel(new AITtsRequest + { + Input = "hello", + Model = "gpt-4o-mini-tts", + }); + + resolved.Provider.Should().Be("OpenAI"); + resolved.Model.Should().Be("gpt-4o-mini-tts"); + resolved.Scenario.Should().Be("Default"); + resolved.Timeout.Should().Be(TimeSpan.FromSeconds(100)); + resolved.MaxRetryAttempts.Should().Be(2); + } + + [Fact] + public void ResolveTtsModel_WhenProviderOverrideHasNoModel_ShouldThrowConfigurationException() + { + var resolver = new DefaultAIModelResolver(Options.Create(CreateOptions())); + + Action action = () => resolver.ResolveTtsModel(new AITtsRequest + { + Input = "hello", + Provider = "OpenAI", + }); + + action.Should().Throw() + .WithMessage("*must specify a model when provider override is used*"); + } + + [Fact] + public void ResolveAsrModel_WhenNoScenarioAndNoDefaultConfigured_ShouldThrowConfigurationException() + { + var options = CreateOptions(); + options.Models.Asr.Clear(); + var resolver = new DefaultAIModelResolver(Options.Create(options)); + + Action action = () => resolver.ResolveAsrModel(new AIAsrRequest + { + Audio = new AIBinaryContent + { + Content = [1, 2, 3], + FileName = "sample.wav", + ContentType = "audio/wav", + }, + }); + + action.Should().Throw() + .WithMessage("*no scenario-specific or default model is configured*"); + } + + [Fact] + public void ResolveImageEditModel_WhenScenarioMatchesCaseInsensitive_ShouldUseScenarioConfiguration() + { + var options = CreateOptions(); + options.Models.ImageEdit.Add("Marketing", new AIImageModelOptions + { + Provider = "Qwen", + Model = "qwen-image-edit", + Count = 2, + Size = "512x512", + Quality = "hd", + ResponseFormat = "url", + TimeoutSeconds = 55, + MaxRetryAttempts = 4, + }); + + var resolver = new DefaultAIModelResolver(Options.Create(options)); + + var resolved = resolver.ResolveImageEditModel(new AIImageEditRequest + { + Scenario = "marketing", + Prompt = "clean up background", + Image = new AIBinaryContent + { + Content = [1, 2, 3], + FileName = "image.png", + ContentType = "image/png", + }, + }); + + resolved.Provider.Should().Be("Qwen"); + resolved.Model.Should().Be("qwen-image-edit"); + resolved.Scenario.Should().Be("Marketing"); + resolved.Count.Should().Be(2); + resolved.Size.Should().Be("512x512"); + resolved.Quality.Should().Be("hd"); + resolved.ResponseFormat.Should().Be("url"); + resolved.Timeout.Should().Be(TimeSpan.FromSeconds(55)); + resolved.MaxRetryAttempts.Should().Be(4); + } + + [Fact] + public void ResolveImageGenerationModel_WhenProviderCannotBeDetermined_ShouldThrowConfigurationException() + { + var options = CreateOptions(); + options.DefaultProvider = null; + options.Models.ImageGeneration.Add("Default", new AIImageModelOptions + { + Provider = null!, + Model = "gpt-image-1", + }); + + var resolver = new DefaultAIModelResolver(Options.Create(options)); + + Action action = () => resolver.ResolveImageGenerationModel(new AIImageGenerationRequest + { + Prompt = "draw a cat", + }); + + action.Should().Throw() + .WithMessage("*no provider could be determined*"); + } + + [Fact] + public void ResolveImageGenerationModel_WhenResolvedProviderIsNotConfigured_ShouldThrowConfigurationException() + { + var options = CreateOptions(); + options.Models.ImageGeneration.Add("Default", new AIImageModelOptions + { + Provider = "Missing", + Model = "gpt-image-1", + }); + + var resolver = new DefaultAIModelResolver(Options.Create(options)); + + Action action = () => resolver.ResolveImageGenerationModel(new AIImageGenerationRequest + { + Prompt = "draw a cat", + }); + + action.Should().Throw() + .WithMessage("*provider 'Missing' is not configured*"); + } + private static AIOptions CreateOptions() { var options = new AIOptions @@ -221,4 +359,4 @@ private static AIOptions CreateOptions() return options; } -} \ No newline at end of file +} diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIChatProviderTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIChatProviderTests.cs index c76a4f1..4a6367f 100644 --- a/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIChatProviderTests.cs +++ b/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIChatProviderTests.cs @@ -148,6 +148,56 @@ public async Task StreamAsync_ShouldParseSseDeltaEvents_AndFinalUsageChunk() handler.Requests[0].Headers.Accept.Should().Contain(header => header.MediaType == "text/event-stream"); } + [Fact] + public void Validate_WhenOpenAIIsNotReferenced_ShouldSucceed() + { + var result = new OpenAIOptionsValidator().Validate(null, new AIOptions + { + DefaultProvider = "Qwen", + }); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_WhenOpenAIUsesFallbackBaseAddress_ShouldSucceed() + { + var options = new AIOptions + { + DefaultProvider = OpenAIProviderDefaults.ProviderName, + }; + options.Providers.Add(OpenAIProviderDefaults.ProviderName, new AIProviderRegistrationOptions + { + ApiKey = "openai-key", + }); + + var result = new OpenAIOptionsValidator().Validate(null, options); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_WhenOpenAIIsReferencedButInvalid_ShouldReturnFailures() + { + var options = new AIOptions + { + DefaultProvider = OpenAIProviderDefaults.ProviderName, + }; + options.Providers.Add(OpenAIProviderDefaults.ProviderName, new AIProviderRegistrationOptions + { + Enabled = false, + ApiKey = string.Empty, + BaseAddress = "not-a-uri", + }); + + var result = new OpenAIOptionsValidator().Validate(null, options); + + result.Failed.Should().BeTrue(); + result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:Enabled must be true when the provider is in use."); + result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:ApiKey is required."); + result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:BaseAddress must be a valid absolute URI."); + } + private static IAIChatProvider CreateProvider(SequenceHttpMessageHandler handler) { var options = new AIOptions(); @@ -264,4 +314,4 @@ private static HttpRequestMessage CloneRequest(HttpRequestMessage request) return clone; } } -} \ No newline at end of file +} diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIMediaProviderTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIMediaProviderTests.cs index 2a6d949..1e82b2d 100644 --- a/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIMediaProviderTests.cs +++ b/MS.Microservice.AI/test/MS.Microservice.AI.OpenAI.Tests/OpenAIMediaProviderTests.cs @@ -180,6 +180,150 @@ public async Task EditAsync_ShouldSendMultipartImageAndMask_AndParseUrlResponse( body.Should().Contain("name=prompt"); } + [Theory] + [InlineData("mp3", "audio/mpeg")] + [InlineData("wav", "audio/wav")] + [InlineData("flac", "audio/flac")] + [InlineData("aac", "audio/aac")] + [InlineData("opus", "audio/opus")] + [InlineData("pcm", "audio/L16")] + [InlineData("unknown", "application/octet-stream")] + public async Task SynthesizeAsync_WhenResponseContentTypeIsMissing_ShouldMapAudioContentType(string responseFormat, string expectedContentType) + { + var handler = new SequenceHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes("audio-bytes")), + }); + + var provider = CreateTtsProvider(handler); + + var response = await provider.SynthesizeAsync( + CreateTtsModel() with { ResponseFormat = responseFormat }, + new AITtsRequest { Input = "hello speech" }); + + response.Audio.ContentType.Should().Be(expectedContentType); + response.Audio.FileName.Should().Be($"speech.{responseFormat}"); + } + + [Fact] + public async Task SynthesizeAsync_WhenProviderReturnsContentFilterError_ShouldThrowContentSafetyException() + { + var handler = new SequenceHttpMessageHandler( + _ => CreateJsonResponse( + HttpStatusCode.BadRequest, + """ + { + "error": { + "message": "Content policy triggered", + "code": "content_filter" + } + } + """, + requestId: "media-filter-id")); + + var provider = CreateTtsProvider(handler); + + Func action = async () => await provider.SynthesizeAsync(CreateTtsModel(), new AITtsRequest + { + Input = "blocked content", + RequestId = "req-openai-filter", + }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Provider.Should().Be(OpenAIProviderDefaults.ProviderName); + exception.Which.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + exception.Which.ProviderRequestId.Should().Be("media-filter-id"); + } + + [Fact] + public async Task SynthesizeAsync_WhenRateLimitedWithRetryAfterDate_ShouldExposeRetryAfter() + { + var retryAt = DateTimeOffset.UtcNow.AddSeconds(2); + var handler = new SequenceHttpMessageHandler( + _ => CreateJsonResponse( + HttpStatusCode.TooManyRequests, + """ + { + "error": { + "message": "Too many requests", + "code": "rate_limit_reached" + } + } + """, + retryAfter: new System.Net.Http.Headers.RetryConditionHeaderValue(retryAt))); + + var provider = CreateTtsProvider(handler); + + Func action = async () => await provider.SynthesizeAsync( + CreateTtsModel() with { MaxRetryAttempts = 0 }, + new AITtsRequest { Input = "hello speech" }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.RetryAfter.Should().NotBeNull(); + exception.Which.RetryAfter.Should().BeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public async Task RecognizeAsync_WhenTransientProviderErrorOccursBeforeSuccess_ShouldRetryAndReturnParsedResponse() + { + var handler = new SequenceHttpMessageHandler( + _ => CreateJsonResponse( + HttpStatusCode.ServiceUnavailable, + """ + { + "error": { + "message": "Provider overloaded", + "code": "server_overloaded" + } + } + """), + _ => CreateJsonResponse( + HttpStatusCode.OK, + """ + { + "model": "whisper-1", + "text": "retry success", + "language": "en" + } + """)); + + var provider = CreateAsrProvider(handler); + + var response = await provider.RecognizeAsync( + CreateAsrModel() with { MaxRetryAttempts = 1 }, + new AIAsrRequest + { + Audio = new AIBinaryContent + { + Content = Encoding.UTF8.GetBytes("wave"), + ContentType = "audio/wav", + FileName = "sample.wav", + }, + }); + + response.Text.Should().Be("retry success"); + handler.Requests.Should().HaveCount(2); + } + + [Fact] + public async Task SynthesizeAsync_WhenTimeoutPersists_ShouldThrowTimeoutException() + { + var handler = new SequenceHttpMessageHandler( + _ => throw new TaskCanceledException("timeout-1"), + _ => throw new TaskCanceledException("timeout-2")); + + var provider = CreateTtsProvider(handler); + + Func action = async () => await provider.SynthesizeAsync( + CreateTtsModel() with { MaxRetryAttempts = 1, Timeout = TimeSpan.FromMilliseconds(1) }, + new AITtsRequest { Input = "hello speech", RequestId = "req-openai-timeout" }); + + var exception = await action.Should().ThrowAsync(); + exception.Which.Provider.Should().Be(OpenAIProviderDefaults.ProviderName); + exception.Which.RequestId.Should().Be("req-openai-timeout"); + handler.Requests.Should().HaveCount(2); + } + private static IAITtsProvider CreateTtsProvider(SequenceHttpMessageHandler handler) { return new OpenAITtsProvider( @@ -276,13 +420,22 @@ private static AIResolvedModel CreateImageModel(AICapability capability) }; } - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, string body) + private static HttpResponseMessage CreateJsonResponse( + HttpStatusCode statusCode, + string body, + string requestId = "openai-media-id", + System.Net.Http.Headers.RetryConditionHeaderValue? retryAfter = null) { var response = new HttpResponseMessage(statusCode) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - response.Headers.TryAddWithoutValidation("x-request-id", "openai-media-id"); + if (retryAfter is not null) + { + response.Headers.RetryAfter = retryAfter; + } + + response.Headers.TryAddWithoutValidation("x-request-id", requestId); return response; } @@ -335,4 +488,4 @@ private static HttpRequestMessage CloneRequest(HttpRequestMessage request) return clone; } } -} \ No newline at end of file +} diff --git a/MS.Microservice.Logging/test/MS.Microservice.Logging.NLog.Tests/MsNLogProviderTests.cs b/MS.Microservice.Logging/test/MS.Microservice.Logging.NLog.Tests/MsNLogProviderTests.cs index 35bfb47..da90ef4 100644 --- a/MS.Microservice.Logging/test/MS.Microservice.Logging.NLog.Tests/MsNLogProviderTests.cs +++ b/MS.Microservice.Logging/test/MS.Microservice.Logging.NLog.Tests/MsNLogProviderTests.cs @@ -38,15 +38,18 @@ public void ConfigureMsNLog_ShouldLoadSampleConfig_WithAspNetRequestRenderers() "src", "MS.Microservice.Logging.NLog", "nlog.sample.config")); var builder = Host.CreateApplicationBuilder(); - - var act = () => builder.ConfigureMsNLog(options => + builder.ConfigureMsNLog(options => { options.ConfigurationFilePath = sampleConfigPath; options.UseFallbackConfigurationWhenFileMissing = false; }); - act.Should().NotThrow(); + using var host = builder.Build(); + global::NLog.LogManager.Configuration.Should().NotBeNull(); + global::NLog.LogManager.Configuration!.AllTargets.Select(target => target.Name) + .Should().Contain(["console", "file"]); + global::NLog.LogManager.Configuration.LoggingRules.Count.Should().BeGreaterThanOrEqualTo(2); } [Fact] @@ -109,9 +112,49 @@ public void ConfigureMsNLog_ShouldDisableRules_WhenMinimumLevelIsNone() rule.IsLoggingEnabledForLevel(global::NLog.LogLevel.Fatal).Should().BeFalse(); } + [Fact] + public void ConfigureMsNLog_HostBuilderOverload_ShouldLogWithAmbientRequestContext() + { + using var host = Host.CreateDefaultBuilder() + .ConfigureMsNLog(options => + { + options.ConfigurationFilePath = "missing.nlog.config"; + options.UseFallbackConfigurationWhenFileMissing = true; + options.MinimumLevel = Microsoft.Extensions.Logging.LogLevel.Information; + }) + .Build(); + + host.Services.GetServices() + .Should().Contain(service => service.GetType().Name == "NLogHostedService"); + + var logger = host.Services.GetRequiredService>(); + var memoryTarget = new MemoryTarget("memory") + { + Layout = "rid=${requestId}|msg=${message}", + }; + + var configuration = new LoggingConfiguration(); + configuration.AddTarget(memoryTarget); + configuration.LoggingRules.Add(new LoggingRule("*", global::NLog.LogLevel.Info, memoryTarget)); + global::NLog.LogManager.Configuration = configuration; + global::NLog.LogManager.ReconfigExistingLoggers(); + + using (RequestLogScope.Push(new RequestLogContext + { + RequestId = "req-host", + })) + { + logger.LogInformation("hello host builder"); + } + + global::NLog.LogManager.Flush(); + + memoryTarget.Logs.Should().ContainSingle("rid=req-host|msg=hello host builder"); + } + public void Dispose() { global::NLog.LogManager.Configuration = null; global::NLog.LogManager.Shutdown(); } -} \ No newline at end of file +} diff --git a/MS.Microservice.Logging/test/MS.Microservice.Logging.Serilog.Tests/MsSerilogProviderTests.cs b/MS.Microservice.Logging/test/MS.Microservice.Logging.Serilog.Tests/MsSerilogProviderTests.cs index 6f7e23a..7f03cba 100644 --- a/MS.Microservice.Logging/test/MS.Microservice.Logging.Serilog.Tests/MsSerilogProviderTests.cs +++ b/MS.Microservice.Logging/test/MS.Microservice.Logging.Serilog.Tests/MsSerilogProviderTests.cs @@ -109,6 +109,32 @@ public void ConfigureMsSerilog_ShouldSuppressAllLogs_WhenMinimumLevelIsNone() sink.Events.Should().BeEmpty(); } + [Fact] + public void ConfigureMsSerilog_HostBuilderOverload_ShouldWriteWithConfiguredSink() + { + var sink = new CollectingSink(); + + using var host = Host.CreateDefaultBuilder() + .ConfigureMsSerilog(options => + { + options.ReadFromConfiguration = false; + options.UseConsoleSink = false; + options.ConfigureLogger = (_, loggerConfiguration) => + { + loggerConfiguration.WriteTo.Sink(sink); + }; + }) + .Build(); + + var logger = host.Services.GetRequiredService>(); + + logger.LogWarning("hello from host builder"); + + sink.Events.Should().ContainSingle(); + sink.Events[0].Level.Should().Be(LogEventLevel.Warning); + sink.Events[0].RenderMessage().Should().Be("hello from host builder"); + } + private sealed class CollectingSink : ILogEventSink { public List Events { get; } = []; @@ -118,4 +144,4 @@ public void Emit(LogEvent logEvent) Events.Add(logEvent); } } -} \ No newline at end of file +} diff --git a/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/EfCoreQueryableExtensionsTests.cs b/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/EfCoreQueryableExtensionsTests.cs new file mode 100644 index 0000000..fd8ede3 --- /dev/null +++ b/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/EfCoreQueryableExtensionsTests.cs @@ -0,0 +1,127 @@ +using FluentAssertions; +using MS.Microservice.Core.Specification; +using MS.Microservice.Persistence.EFCore.DbContext; + +namespace MS.Microservice.Persistence.EFCore.Tests; + +public sealed class EfCoreQueryableExtensionsTests +{ + [Fact] + public void ApplySpecification_WhenCriteriaOrderingAndPagingAreDefined_ShouldApplyAllQueryableOperations() + { + var result = CreateOrders() + .AsQueryable() + .ApplySpecification(new DescendingPagedOrderSpecification()) + .Select(order => order.Id) + .ToList(); + + result.Should().Equal(3, 1); + } + + [Fact] + public void ApplySpecification_WhenEvaluateCriteriaOnlyIsTrue_ShouldSkipOrderingAndPaging() + { + var result = CreateOrders() + .AsQueryable() + .ApplySpecification(new DescendingPagedOrderSpecification(), evaluateCriteriaOnly: true) + .Select(order => order.Id) + .ToList(); + + result.Should().Equal(1, 2, 3); + } + + [Fact] + public void ApplySpecification_WhenProjectionSelectorExists_ShouldProjectFromAppliedQuery() + { + var result = CreateOrders() + .AsQueryable() + .ApplySpecification(new ProjectedOrderSpecification()) + .ToList(); + + result.Should().Equal("order-3", "order-1"); + } + + [Fact] + public void ApplySpecification_WhenProjectionSelectorIsMissing_ShouldThrowInvalidOperationException() + { + Action action = () => CreateOrders() + .AsQueryable() + .ApplySpecification(new MissingProjectionSpecification()) + .ToList(); + + action.Should().Throw() + .WithMessage("*requires a Selector*"); + } + + [Fact] + public void ApplySpecification_WhenAscendingAndThenDescendingOrderAreDefined_ShouldApplyChainedOrdering() + { + var result = CreateOrders() + .AsQueryable() + .ApplySpecification(new AscendingThenDescendingOrderSpecification()) + .Select(order => order.Id) + .ToList(); + + result.Should().Equal(1, 2, 3, 4); + } + + private static List CreateOrders() + { + return + [ + new TestOrder { Id = 1, Category = "A", Total = 20m, Description = "order-1" }, + new TestOrder { Id = 2, Category = "A", Total = 10m, Description = "order-2" }, + new TestOrder { Id = 3, Category = "B", Total = 30m, Description = "order-3" }, + new TestOrder { Id = 4, Category = "B", Total = 5m, Description = "order-4" }, + ]; + } + + private sealed class DescendingPagedOrderSpecification : Specification + { + public DescendingPagedOrderSpecification() + { + Where(order => order.Total >= 10m); + OrderByDescending(order => order.Total); + ThenBy(order => order.Id); + ApplyPaging(0, 2); + } + } + + private sealed class AscendingThenDescendingOrderSpecification : Specification + { + public AscendingThenDescendingOrderSpecification() + { + Where(order => order.Total > 0m); + OrderBy(order => order.Category); + ThenByDescending(order => order.Total); + } + } + + private sealed class ProjectedOrderSpecification : Specification + { + public ProjectedOrderSpecification() + { + Where(order => order.Total >= 10m); + OrderByDescending(order => order.Total); + ThenBy(order => order.Id); + ApplyPaging(0, 2); + Select(order => order.Description); + } + } + + private sealed class MissingProjectionSpecification : Specification + { + public MissingProjectionSpecification() + { + Where(order => order.Total >= 10m); + } + } + + private sealed class TestOrder + { + public int Id { get; init; } + public string Category { get; init; } = string.Empty; + public decimal Total { get; init; } + public string Description { get; init; } = string.Empty; + } +} diff --git a/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/MsPlatformDbContextSettingsTests.cs b/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/MsPlatformDbContextSettingsTests.cs index 0a77dc0..87033f4 100644 --- a/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/MsPlatformDbContextSettingsTests.cs +++ b/MS.Microservice.Persistence/MS.Microservice.Persistence.EFCore/test/MS.Microservice.Persistence.EFCore.Tests/MsPlatformDbContextSettingsTests.cs @@ -2,22 +2,6 @@ namespace MS.Microservice.Persistence.EFCore.Tests; public class MsPlatformDbContextSettingsTests { - [Fact] - public void Defaults_ShouldHaveDisabledAutoTimeTracker() - { - var settings = new MsPlatformDbContextSettings(); - - settings.AutoTimeTracker.Should().Be("Disabled"); - } - - [Fact] - public void Defaults_ShouldHaveSoftDeleteEnabled() - { - var settings = new MsPlatformDbContextSettings(); - - settings.EnabledSoftDeleted.Should().BeTrue(); - } - [Fact] public void EnabledAutoTimeTracker_ShouldReturnTrue_WhenEnabled() { @@ -33,10 +17,4 @@ public void EnabledAutoTimeTracker_ShouldReturnFalse_WhenDisabled() settings.EnabledAutoTimeTracker().Should().BeFalse(); } - - [Fact] - public void SectionName_ShouldBeCorrect() - { - MsPlatformDbContextSettings.SectionName.Should().Be("FzPlatformDbContextSettings"); - } -} \ No newline at end of file +} diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..4449e93 --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,19 @@ + + + + + + + cobertura + [MS.Microservice*]* + [*.Tests*]* + CompilerGeneratedAttribute,GeneratedCodeAttribute,DebuggerNonUserCodeAttribute,ExcludeFromCodeCoverageAttribute + **/bin/**/*.cs,**/obj/**/*.cs,**/Migrations/**/*.cs,**/Generated/**/*.cs,**/*.g.cs,**/*.g.i.cs,**/*.designer.cs,**/src/MS.Microservice.Core/Dto/**/*.cs,**/src/MS.Microservice.Web/Application/Models/**/*Request.cs,**/src/MS.Microservice.Web/Application/Models/**/*Response.cs,**/MS.Microservice.AI/src/MS.Microservice.AI.Abstractions/**/*Request.cs,**/MS.Microservice.AI/src/MS.Microservice.AI.Abstractions/**/*Response.cs,**/MS.Microservice.Swagger/SwaggerOptions.cs,**/MS.Microservice.Logging/src/MS.Microservice.Logging.Serilog/MsSerilogOptions.cs,**/MS.Microservice.Logging/src/MS.Microservice.Logging.NLog/MsNLogOptions.cs,**/MS.Microservice.Logging/src/MS.Microservice.Logging.AspNetCore/AspNetCoreRequestLoggingOptions.cs,**/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIProductionOptions.cs,**/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIOptions.cs,**/MS.Microservice.Persistence/MS.Microservice.Persistence.SqlSugar/src/MS.Microservice.Persistence.SqlSugar/SqlSugarOptions.cs,**/MS.Microservice.Persistence/MS.Microservice.Persistence.SqlSugar/src/MS.Microservice.Persistence.SqlSugar/SqlSugarClientBuilderOptions.cs,**/src/MS.Microservice.Web/Infrastructure/Cors/CorsOptions.cs,**/src/MS.Microservice.Domain/Identity/IdentityOptions.cs,**/src/MS.Microservice.Infrastructure/Caching/CacheOperationLogOptions.cs,**/src/MS.Microservice.Infrastructure/Caching/Buffer/BufferQueueOptions.cs,**/src/MS.Microservice.Infrastructure/Common/NAudio/AudioOptions.cs + false + true + true + + + + + diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..e499263 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs index 6f61b6e..31d4ad8 100644 --- a/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs +++ b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateDecisionTests.cs @@ -80,6 +80,33 @@ public void Decide_WhenCancelWithoutReason_ShouldReturnValidation() decision.Left.Message.Should().Be("取消订单时必须提供原因。"); } + [Fact] + public void Decide_WhenCreateOrderAlreadyExists_ShouldReturnConflict() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([new OrderCreated(orderId, "cust-001", "CNY")]); + + var decision = OrderAggregate.Decide(state, new CreateOrder(orderId, "cust-002", "USD")); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("conflict"); + } + + [Fact] + public void Decide_WhenRemoveQuantityIsNonPositive_ShouldReturnValidation() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1) + ]); + + var decision = OrderAggregate.Decide(state, new RemoveOrderItem(orderId, "sku-1", 0)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("validation"); + } + [Fact] public void Decide_WhenOrderAlreadyConfirmed_ShouldReturnConflict() { @@ -95,4 +122,19 @@ public void Decide_WhenOrderAlreadyConfirmed_ShouldReturnConflict() decision.IsLeft.Should().BeTrue(); decision.Left.Code.Should().Be("conflict"); } + + [Fact] + public void Decide_WhenOrderAlreadyCancelled_ShouldReturnConflict() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderCancelled(orderId, "customer requested") + ]); + + var decision = OrderAggregate.Decide(state, new AddOrderItem(orderId, "sku-2", 20m, 1)); + + decision.IsLeft.Should().BeTrue(); + decision.Left.Code.Should().Be("conflict"); + } } diff --git a/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateTests.cs b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateTests.cs index 9a4a6d2..5d11ad1 100644 --- a/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateTests.cs +++ b/test/MS.Microservice.Infrastructure.Tests/EventSourcing/OrderAggregateTests.cs @@ -32,6 +32,33 @@ public void Decide_WhenConfirmWithoutItems_ShouldReturnValidationError() decision.Left.Code.Should().Be("validation"); } + [Fact] + public void Decide_WhenConfirmHasItems_ShouldProduceOrderConfirmedEvent() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1) + ]); + + var decision = OrderAggregate.Decide(state, new ConfirmOrder(orderId)); + + decision.IsRight.Should().BeTrue(); + decision.Right.Should().ContainSingle().Which.Should().BeOfType(); + } + + [Fact] + public void Decide_WhenCancelHasReason_ShouldProduceOrderCancelledEvent() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([new OrderCreated(orderId, "cust-001", "CNY")]); + + var decision = OrderAggregate.Decide(state, new CancelOrder(orderId, "customer requested")); + + decision.IsRight.Should().BeTrue(); + decision.Right.Should().ContainSingle().Which.Should().BeOfType(); + } + [Fact] public void Fold_WhenReplayLifecycle_ShouldBuildCurrentState() { @@ -50,5 +77,37 @@ public void Fold_WhenReplayLifecycle_ShouldBuildCurrentState() state.Lines["sku-1"].Quantity.Should().Be(1); state.Version.Should().Be(5); } + + [Fact] + public void Fold_WhenSameItemIsAddedTwice_ShouldAccumulateQuantityAndUseLatestUnitPrice() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1), + new OrderItemAdded(orderId, "sku-1", 12m, 2) + ]); + + state.Lines["sku-1"].Quantity.Should().Be(3); + state.Lines["sku-1"].UnitPrice.Should().Be(12m); + state.TotalAmount.Should().Be(36m); + state.Version.Should().Be(3); + } + + [Fact] + public void Evolve_WhenRemovingUnknownItem_ShouldLeaveLinesUntouchedAndAdvanceVersion() + { + var orderId = Guid.NewGuid(); + var state = OrderAggregate.Fold([ + new OrderCreated(orderId, "cust-001", "CNY"), + new OrderItemAdded(orderId, "sku-1", 10m, 1) + ]); + + var next = OrderAggregate.Evolve(state, new OrderItemRemoved(orderId, "sku-2", 10m, 1)); + + next.Lines.Should().BeEquivalentTo(state.Lines); + next.TotalAmount.Should().Be(state.TotalAmount); + next.Version.Should().Be(state.Version + 1); + } } } diff --git a/test/coverlet.runsettings b/test/coverlet.runsettings deleted file mode 100644 index 5ef1b56..0000000 --- a/test/coverlet.runsettings +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - .*MS\.Microservice\..*\.dll$ - - - .*\.Tests\.dll$ - - - - - - - -