From 81ec2c6d6522265358f2a7070b747d84fb5e0a9b Mon Sep 17 00:00:00 2001 From: marsonshine Date: Mon, 29 Jun 2026 09:57:41 +0800 Subject: [PATCH] =?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$ - - - - - - - -