diff --git a/GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarFallbackFailureTests.cs b/GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarFallbackFailureTests.cs new file mode 100644 index 00000000..213f3870 --- /dev/null +++ b/GFramework.Cqrs.Tests/Cqrs/CqrsHandlerRegistrarFallbackFailureTests.cs @@ -0,0 +1,254 @@ +using GFramework.Core.Abstractions.Logging; +using GFramework.Core.Ioc; +using GFramework.Core.Logging; +using GFramework.Cqrs.Abstractions.Cqrs; +using GFramework.Cqrs.Tests.Logging; + +namespace GFramework.Cqrs.Tests.Cqrs; + +/// +/// 验证 CQRS handler registrar 在 reflection fallback 元数据失效时的可观察告警行为。 +/// +[TestFixture] +internal sealed class CqrsHandlerRegistrarFallbackFailureTests +{ + private ILoggerFactoryProvider? _originalLoggerFactoryProvider; + private CapturingLoggerFactoryProvider? _capturingLoggerFactoryProvider; + + /// + /// 切换为捕获型日志工厂,并清空 registrar 进程级缓存,避免跨用例共享状态污染断言。 + /// + [SetUp] + public void SetUp() + { + _originalLoggerFactoryProvider = LoggerFactoryResolver.Provider; + _capturingLoggerFactoryProvider = new CapturingLoggerFactoryProvider(LogLevel.Warning); + LoggerFactoryResolver.Provider = _capturingLoggerFactoryProvider; + ClearRegistrarCaches(); + } + + /// + /// 恢复测试前的日志工厂,并清理 registrar 缓存。 + /// + [TearDown] + public void TearDown() + { + LoggerFactoryResolver.Provider = _originalLoggerFactoryProvider!; + _capturingLoggerFactoryProvider = null; + _originalLoggerFactoryProvider = null; + ClearRegistrarCaches(); + } + + /// + /// 验证当 fallback 类型名无法解析时,registrar 会跳过该条目并记录告警。 + /// + [Test] + public void RegisterHandlers_Should_Skip_Unresolvable_Named_Fallback_And_Log_Warning() + { + const string missingTypeName = + "GFramework.Cqrs.Tests.Cqrs.MissingGeneratedRegistryNotificationHandler"; + var generatedAssembly = CreateGeneratedFallbackAssembly( + "GFramework.Cqrs.Tests.Cqrs.NamedFallbackMissingAssembly, Version=1.0.0.0", + new CqrsReflectionFallbackAttribute(missingTypeName)); + generatedAssembly + .Setup(static assembly => assembly.GetType(missingTypeName, false, false)) + .Returns((Type?)null); + + var container = new MicrosoftDiContainer(); + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + + Assert.Multiple(() => + { + Assert.That( + GetGeneratedRegistryNotificationHandlerTypes(container), + Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)])); + Assert.That( + GetWarningLogs().Any(log => + log.Message.Contains( + $"Generated CQRS reflection fallback type {missingTypeName} could not be resolved", + StringComparison.Ordinal)), + Is.True); + }); + } + + /// + /// 验证当 fallback 类型名解析抛出异常时,registrar 会记录该加载失败告警并继续跳过条目。 + /// + [Test] + public void RegisterHandlers_Should_Log_Warning_When_Named_Fallback_Resolution_Throws() + { + const string failingTypeName = + "GFramework.Cqrs.Tests.Cqrs.ThrowingGeneratedRegistryNotificationHandler"; + const string exceptionMessage = "Fallback resolution exploded."; + var generatedAssembly = CreateGeneratedFallbackAssembly( + "GFramework.Cqrs.Tests.Cqrs.NamedFallbackThrowingAssembly, Version=1.0.0.0", + new CqrsReflectionFallbackAttribute(failingTypeName)); + generatedAssembly + .Setup(static assembly => assembly.GetType(failingTypeName, false, false)) + .Throws(new TypeLoadException(exceptionMessage)); + + var container = new MicrosoftDiContainer(); + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + + Assert.Multiple(() => + { + Assert.That( + GetGeneratedRegistryNotificationHandlerTypes(container), + Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)])); + Assert.That( + GetWarningLogs().Any(log => + log.Message.Contains( + $"Generated CQRS reflection fallback type {failingTypeName} failed to load", + StringComparison.Ordinal) && + log.Message.Contains(exceptionMessage, StringComparison.Ordinal)), + Is.True); + }); + } + + /// + /// 验证当 direct fallback 类型属于其他程序集时,registrar 会跳过该条目并记录跨程序集告警。 + /// + [Test] + public void RegisterHandlers_Should_Skip_Cross_Assembly_Direct_Fallback_Type_And_Log_Warning() + { + var crossAssemblyFallbackType = ReflectionFallbackNotificationContainer.ReflectionOnlyHandlerType; + var generatedAssembly = CreateGeneratedFallbackAssembly( + "GFramework.Cqrs.Tests.Cqrs.DirectFallbackMismatchAssembly, Version=1.0.0.0", + new CqrsReflectionFallbackAttribute(crossAssemblyFallbackType)); + + var container = new MicrosoftDiContainer(); + CqrsTestRuntime.RegisterHandlers(container, generatedAssembly.Object); + + Assert.Multiple(() => + { + Assert.That( + GetGeneratedRegistryNotificationHandlerTypes(container), + Is.EqualTo([typeof(GeneratedRegistryNotificationHandler)])); + Assert.That( + GetWarningLogs().Any(log => + log.Message.Contains( + $"Generated CQRS reflection fallback type {crossAssemblyFallbackType.FullName} was declared on assembly", + StringComparison.Ordinal) && + log.Message.Contains("Skipping mismatched fallback entry.", StringComparison.Ordinal)), + Is.True); + }); + } + + /// + /// 创建一个仅通过 generated registry 注册主 handler、并附带指定 fallback 元数据的程序集替身。 + /// + /// 用于日志与缓存键的程序集名。 + /// 要暴露给 registrar 的 fallback attribute。 + /// 已完成基础接线的程序集 mock。 + private static Mock CreateGeneratedFallbackAssembly( + string assemblyName, + CqrsReflectionFallbackAttribute fallbackAttribute) + { + var generatedAssembly = new Mock(); + generatedAssembly + .SetupGet(static assembly => assembly.FullName) + .Returns(assemblyName); + generatedAssembly + .Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsHandlerRegistryAttribute), false)) + .Returns([new CqrsHandlerRegistryAttribute(typeof(PartialGeneratedNotificationHandlerRegistry))]); + generatedAssembly + .Setup(static assembly => assembly.GetCustomAttributes(typeof(CqrsReflectionFallbackAttribute), false)) + .Returns([fallbackAttribute]); + return generatedAssembly; + } + + /// + /// 提取容器中针对 generated notification 注册的处理器实现类型。 + /// + /// 已执行注册的测试容器。 + /// 按注册顺序返回的处理器类型数组。 + private static Type[] GetGeneratedRegistryNotificationHandlerTypes(MicrosoftDiContainer container) + { + return container.GetServicesUnsafe + .Where(static descriptor => + descriptor.ServiceType == typeof(INotificationHandler) && + descriptor.ImplementationType is not null) + .Select(static descriptor => descriptor.ImplementationType!) + .ToArray(); + } + + /// + /// 清空本测试依赖的 registrar 静态缓存,确保每个用例都会重新执行 fallback 元数据解析。 + /// + private static void ClearRegistrarCaches() + { + ClearCache(GetRegistrarCacheField("AssemblyMetadataCache")); + ClearCache(GetRegistrarCacheField("RegistryActivationMetadataCache")); + ClearCache(GetRegistrarCacheField("LoadableTypesCache")); + ClearCache(GetRegistrarCacheField("SupportedHandlerInterfacesCache")); + } + + /// + /// 通过反射读取 registrar 的静态缓存字段。 + /// + /// 缓存字段名。 + /// 缓存实例。 + private static object GetRegistrarCacheField(string fieldName) + { + var field = GetRegistrarType().GetField( + fieldName, + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.That(field, Is.Not.Null, $"Missing registrar cache field {fieldName}."); + + return field!.GetValue(null) + ?? throw new InvalidOperationException( + $"Registrar cache field {fieldName} returned null."); + } + + /// + /// 清空缓存对象中的已保存条目。 + /// + /// 目标缓存实例。 + private static void ClearCache(object cache) + { + _ = InvokeInstanceMethod(cache, "Clear"); + } + + /// + /// 调用缓存对象上的实例方法。 + /// + /// 目标对象。 + /// 方法名。 + /// 方法参数。 + /// 方法返回值。 + private static object? InvokeInstanceMethod(object target, string methodName, params object[] arguments) + { + var method = target.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null, $"Missing cache method {target.GetType().FullName}.{methodName}."); + + return method!.Invoke(target, arguments); + } + + /// + /// 获取 CQRS handler registrar 的运行时类型。 + /// + /// registrar 实现类型。 + private static Type GetRegistrarType() + { + return typeof(CqrsReflectionFallbackAttribute).Assembly + .GetType("GFramework.Cqrs.Internal.CqrsHandlerRegistrar", throwOnError: true)!; + } + + /// + /// 汇总当前测试期间捕获到的 warning 日志。 + /// + /// 所有 warning 级别日志条目。 + private IReadOnlyList GetWarningLogs() + { + Assert.That(_capturingLoggerFactoryProvider, Is.Not.Null); + + return _capturingLoggerFactoryProvider!.Loggers + .SelectMany(static logger => logger.Logs) + .Where(static log => log.Level == LogLevel.Warning) + .ToArray(); + } +}