From 9ea6c5280eb7e965a86e804cf91e0878e2ae2a51 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 13:41:15 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=93=9A=20refresh=20docs=20publishing?= =?UTF-8?q?=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the DocFX container build with reliable restore and error handling, switch serving to dhi.io nginx with dedicated config and dockerignore, and refresh site rendering with separate member pages, ms-style template, mobile TOC action bar, corrected GitHub link, and sitemap for docs.savvyio.net. --- .docfx/.dockerignore | 5 + .docfx/BuildDocfxImage.ps1 | 140 ++++++++++++++++++- .docfx/Dockerfile.docfx | 11 +- .docfx/docfx.json | 9 +- .docfx/nginx.conf | 31 ++++ .docfx/templates/savvyio/layout/_master.tmpl | 14 ++ .docfx/templates/savvyio/public/main.js | 2 +- 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 .docfx/.dockerignore create mode 100644 .docfx/nginx.conf diff --git a/.docfx/.dockerignore b/.docfx/.dockerignore new file mode 100644 index 00000000..c063843c --- /dev/null +++ b/.docfx/.dockerignore @@ -0,0 +1,5 @@ +# Local outputs and bookkeeping are not inputs to the container's DocFX build. +wwwroot/ +obj/ +bin/ +*.ps1 diff --git a/.docfx/BuildDocfxImage.ps1 b/.docfx/BuildDocfxImage.ps1 index 99e1c6ff..5d9f365a 100644 --- a/.docfx/BuildDocfxImage.ps1 +++ b/.docfx/BuildDocfxImage.ps1 @@ -1,4 +1,136 @@ -$version = minver -i -t v -v w -docfx metadata docfx.json -docker buildx build -t savvyio-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . -get-childItem -recurse -path api -include *.yml, .manifest | remove-item +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +function Assert-CommandSucceeded([string] $Command) { + if ($LASTEXITCODE -ne 0) { throw "$Command failed with exit code $LASTEXITCODE." } +} + +Push-Location -LiteralPath $PSScriptRoot +try { + $docfxRoot = $PSScriptRoot + $sourceRoot = [System.IO.Path]::GetFullPath((Join-Path $docfxRoot '..\src')) + $docfxConfig = Get-Content -LiteralPath (Join-Path $docfxRoot 'docfx.json') -Raw | ConvertFrom-Json + $version = minver -i -t v -v w + Assert-CommandSucceeded 'minver' + + $metadataProjectPatterns = @( + foreach ($metadata in $docfxConfig.metadata) { + foreach ($source in $metadata.src) { + $metadataSourceRoot = if ($source.src) { + [System.IO.Path]::GetFullPath((Join-Path $docfxRoot $source.src)) + } else { + $docfxRoot + } + + foreach ($file in $source.files) { + (Join-Path $metadataSourceRoot $file).Replace('/', [System.IO.Path]::DirectorySeparatorChar) + } + } + } + ) + $sourceProjects = @(Get-ChildItem -LiteralPath $sourceRoot -Recurse -File -Filter '*.csproj') + $metadataProjects = @( + $sourceProjects | + Where-Object { + $projectPath = $_.FullName + foreach ($pattern in $metadataProjectPatterns) { + if ($projectPath -like $pattern) { + return $true + } + } + + return $false + } + ) + $sourceProjectsHaveRestoreAssets = $sourceProjects.Count -gt 0 + foreach ($project in $sourceProjects) { + $restoreAssetsPath = Join-Path $project.DirectoryName 'obj\project.assets.json' + if (-not (Test-Path -LiteralPath $restoreAssetsPath -PathType Leaf)) { + $sourceProjectsHaveRestoreAssets = $false + break + } + } + + $useNoRestore = $metadataProjects.Count -gt 0 -and $sourceProjectsHaveRestoreAssets + $restoreInputNames = @('Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.Config', 'nuget.config', 'global.json') + + foreach ($project in $sourceProjects) { + $restoreAssetsPath = Join-Path $project.DirectoryName 'obj\project.assets.json' + if (-not (Test-Path -LiteralPath $restoreAssetsPath -PathType Leaf)) { + $useNoRestore = $false + break + } + + $restoreAssetsLastWriteTime = (Get-Item -LiteralPath $restoreAssetsPath).LastWriteTimeUtc + $restoreInputPaths = [System.Collections.Generic.List[string]]::new() + $restoreInputPaths.Add($project.FullName) + + $inputDirectory = $project.DirectoryName + while ($inputDirectory) { + foreach ($name in $restoreInputNames) { + $restoreInputPath = Join-Path $inputDirectory $name + if (Test-Path -LiteralPath $restoreInputPath -PathType Leaf) { + $restoreInputPaths.Add($restoreInputPath) + } + } + + $parentDirectory = Split-Path -Parent $inputDirectory + if (-not $parentDirectory -or $parentDirectory -eq $inputDirectory) { + break + } + + $inputDirectory = $parentDirectory + } + + $lockFilePath = Join-Path $project.DirectoryName 'packages.lock.json' + if (Test-Path -LiteralPath $lockFilePath -PathType Leaf) { + $restoreInputPaths.Add($lockFilePath) + } + + if ($env:APPDATA) { + $userNuGetConfigPath = Join-Path $env:APPDATA 'NuGet\NuGet.Config' + if (Test-Path -LiteralPath $userNuGetConfigPath -PathType Leaf) { + $restoreInputPaths.Add($userNuGetConfigPath) + } + } + + foreach ($restoreInputPath in $restoreInputPaths | Sort-Object -Unique) { + if ((Get-Item -LiteralPath $restoreInputPath).LastWriteTimeUtc -gt $restoreAssetsLastWriteTime) { + $useNoRestore = $false + break + } + } + + if (-not $useNoRestore) { + break + } + } + + if (-not $useNoRestore) { + # Restore the source graph once so DocFX does not restore every project separately. + $restoreSolution = Join-Path ([System.IO.Path]::GetTempPath()) ("savvyio-docfx-{0}.slnx" -f [Guid]::NewGuid()) + try { + $projectsXml = foreach ($project in $sourceProjects) { + ' ' -f [Security.SecurityElement]::Escape($project.FullName) + } + @('') + $projectsXml + @('') | Set-Content -LiteralPath $restoreSolution -Encoding utf8 + dotnet restore $restoreSolution --verbosity quiet + Assert-CommandSucceeded 'dotnet restore' + } finally { + if (Test-Path -LiteralPath $restoreSolution) { Remove-Item -LiteralPath $restoreSolution } + } + } + + # Keep all metadata groups in one process; DocFX carries resolver state across groups. + docfx metadata docfx.json --noRestore + Assert-CommandSucceeded 'docfx metadata' + + docker buildx build -t savvyio-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . + Assert-CommandSucceeded 'docker buildx build' + + Get-ChildItem -Recurse -Path api -Include *.yml, .manifest | Remove-Item +} finally { + Pop-Location +} diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index 7d97dde5..e29eda1f 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -1,17 +1,16 @@ -ARG NGINX_VERSION=1.31-alpine +ARG NGINX_VERSION=1.31-alpine -FROM --platform=$BUILDPLATFORM nginx:${NGINX_VERSION} AS base +FROM --platform=$BUILDPLATFORM dhi.io/nginx:${NGINX_VERSION} AS base RUN rm -rf /usr/share/nginx/html/* -FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2.78.5 AS build +FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2 AS build ADD [".", "docfx"] RUN cd docfx; \ docfx build -FROM nginx:${NGINX_VERSION} AS final +FROM dhi.io/nginx:${NGINX_VERSION} AS final WORKDIR /usr/share/nginx/html COPY --from=build /build/docfx/wwwroot /usr/share/nginx/html - -ENTRYPOINT ["nginx", "-g", "daemon off;"] +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.docfx/docfx.json b/.docfx/docfx.json index eb90b556..603d313e 100644 --- a/.docfx/docfx.json +++ b/.docfx/docfx.json @@ -18,6 +18,7 @@ } ], "dest": "api/dotnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -57,6 +58,7 @@ } ], "dest": "api/extensions/dotnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -111,7 +113,8 @@ "template": [ "default", "modern", - "templates/savvyio" + "templates/savvyio", + "templates/ms-style" ], "overwrite": [ { @@ -139,6 +142,10 @@ "keepFileLink": false, "cleanupCacheHistory": false, "disableGitFeatures": false, + "sitemap": { + "baseUrl": "https://docs.savvyio.net/", + "changefreq": "monthly" + }, "xrefService": [ "https://xref.docs.microsoft.com/query?uid={uid}" ] } diff --git a/.docfx/nginx.conf b/.docfx/nginx.conf new file mode 100644 index 00000000..c95d1b1b --- /dev/null +++ b/.docfx/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 8080; + server_name _; + # The ingress terminates TLS; keep redirects from exposing this container port. + absolute_redirect off; + + root /usr/share/nginx/html; + index index.html; + + # DocFX fingerprints these generated dependencies in their filenames. They can be + # cached permanently because changed content is published under a different URL. + location ~ "^/public/.+-[A-Z0-9]{8}(?:-[A-Z0-9]{8})?(?:\.min)?\.(?:(?:css|js)(?:\.map)?|woff2?)$" { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header Vary "Accept-Encoding" always; + } + + # Pages, navigation data, images, and DocFX entry assets keep stable URLs. Do not + # retain them across deployments, and ignore validators cached under an old policy. + location / { + # Nginx derives ETags from modification time and file size. Those values can be + # reused by files copied from different image layers, producing a false 304. + etag off; + if_modified_since off; + try_files $uri $uri/ =404; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; + add_header Expires "0" always; + add_header Pragma "no-cache" always; + add_header Vary "Accept-Encoding" always; + } +} diff --git a/.docfx/templates/savvyio/layout/_master.tmpl b/.docfx/templates/savvyio/layout/_master.tmpl index 428ce0af..bea28ce5 100644 --- a/.docfx/templates/savvyio/layout/_master.tmpl +++ b/.docfx/templates/savvyio/layout/_master.tmpl @@ -97,6 +97,20 @@
+
+ {{^_disableToc}} + + {{/_disableToc}} + + {{^_disableBreadcrumb}} + + {{/_disableBreadcrumb}} +
+
{{!body}}
diff --git a/.docfx/templates/savvyio/public/main.js b/.docfx/templates/savvyio/public/main.js index d1b8b2f9..801e9b2d 100644 --- a/.docfx/templates/savvyio/public/main.js +++ b/.docfx/templates/savvyio/public/main.js @@ -2,7 +2,7 @@ export default { iconLinks: [ { icon: 'github', - href: 'https://github.com/codebeltnet/classlib-savvyio', + href: 'https://github.com/codebeltnet/savvyio', title: 'GitHub' } ] From ae2b9fb3d59a8939aa9b834e05f7e8f70509c4fe Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 13:41:22 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=92=AC=20clarify=20contributing=20tes?= =?UTF-8?q?t=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the dotnet test invocation to use --project syntax and clarify that excluded test projects are integration-dependent so contributors run the correct local test slice. --- .github/CONTRIBUTING.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 78b39f0f..a4506aca 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -36,7 +36,7 @@ Run tests one project at a time so a failing or hanging project is attributable. $testProjects = Get-ChildItem test -Filter *.csproj -Recurse $testProjects = $testProjects | Where-Object { $_.BaseName -notin @('Savvyio.Assets.Dapper.Tests', 'Savvyio.Assets.EfCore.Tests', 'Savvyio.Assets.Tests', 'Savvyio.Extensions.NATS.FunctionalTests', 'Savvyio.Extensions.QueueStorage.FunctionalTests', 'Savvyio.Extensions.RabbitMQ.FunctionalTests', 'Savvyio.Extensions.SimpleQueueService.FunctionalTests', 'Savvyio.FunctionalTests') } foreach ($project in $testProjects) { - dotnet test $project.FullName --configuration Release --no-restore + dotnet test --project $project.FullName --configuration Release --no-restore } ``` @@ -56,8 +56,7 @@ docker compose up -d docker compose down ``` -The normal CI test matrix excludes these eight projects: - +The normal CI test matrix excludes these integration-dependent projects: - `Savvyio.Assets.Dapper.Tests` - `Savvyio.Assets.EfCore.Tests` - `Savvyio.Assets.Tests` From 7dd110c9aae7a2eb59ac7eff17854f88cc275f8f Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 14:22:38 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=85=20add=20configured=20options=20ve?= =?UTF-8?q?rification=20across=20DI=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Characterize the current separate eager options instance before the Cuemon migration, ensuring direct, Microsoft Options, and callback forms stay consistent across data source and messaging extensions. --- ...ns.DependencyInjection.Dapper.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 2 + .../ConfiguredOptionsTest.cs | 61 +++++ ...ndencyInjection.EFCore.Domain.Tests.csproj | 4 + ...ns.DependencyInjection.EFCore.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 6 + ...ions.DependencyInjection.NATS.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 4 + ...encyInjection.Newtonsoft.Json.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 2 + ...endencyInjection.QueueStorage.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 6 + ....DependencyInjection.RabbitMQ.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 4 + ...yInjection.SimpleQueueService.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 4 + .../ConfiguredOptionsTest.cs | 208 ++++++++++++++++++ ...DependencyInjection.Text.Json.Tests.csproj | 4 + .../ServiceCollectionExtensionsTest.cs | 2 + test/Shared/ConfiguredOptionsAssertions.cs | 28 +++ 20 files changed, 363 insertions(+) create mode 100644 test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/ConfiguredOptionsTest.cs create mode 100644 test/Savvyio.Extensions.DependencyInjection.Tests/ConfiguredOptionsTest.cs create mode 100644 test/Shared/ConfiguredOptionsAssertions.cs diff --git a/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/Savvyio.Extensions.DependencyInjection.Dapper.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/Savvyio.Extensions.DependencyInjection.Dapper.Tests.csproj index 2f1331e8..9fcb7449 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/Savvyio.Extensions.DependencyInjection.Dapper.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/Savvyio.Extensions.DependencyInjection.Dapper.Tests.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionExtensionsTest.cs index 11fff67a..e7647157 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionExtensionsTest.cs @@ -19,6 +19,7 @@ public void AddDapperDataSource_ShouldAddDefaultImplementation() var sut1 = new ServiceCollection(); sut1.AddDapperDataSource(o => o.ConnectionFactory = () => new SqliteConnection("Data Source=:memory:")); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService()); Assert.IsType(sut2.GetRequiredService()); @@ -31,6 +32,7 @@ public void AddDapperDataSource_ShouldAddDefaultImplementationWithMarker() var sut1 = new ServiceCollection(); sut1.AddDapperDataSource(o => o.ConnectionFactory = () => new SqliteConnection("Data Source=:memory:")); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/ConfiguredOptionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/ConfiguredOptionsTest.cs new file mode 100644 index 00000000..83e68085 --- /dev/null +++ b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/ConfiguredOptionsTest.cs @@ -0,0 +1,61 @@ +#nullable enable +using System; +using Codebelt.Extensions.Xunit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Savvyio.Assets; +using Savvyio.Domain; +using Savvyio.Extensions.EFCore; +using Savvyio.Extensions.EFCore.Domain; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection.EFCore.Domain; + +public class ConfiguredOptionsTest : Test +{ + public ConfiguredOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddEfCoreAggregateDataSource_ShouldRegisterAllOptionsConsumptionForms() + { + var services = new ServiceCollection(); + services.AddSavvyIO(options => options.AddDomainEventDispatcher()); + + Assert.Same(services, services.AddEfCoreAggregateDataSource(options => + options.ContextConfigurator = builder => builder.UseInMemoryDatabase("aggregate-options"))); + using var provider = services.BuildServiceProvider(); + + ConfiguredOptionsAssertions.Verify(provider); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void AddEfCoreAggregateDataSource_ShouldRegisterAllOptionsConsumptionFormsWithMarker() + { + var services = new ServiceCollection(); + services.AddSavvyIO(options => options.AddDomainEventDispatcher()); + + Assert.Same(services, services.AddEfCoreAggregateDataSource(options => + options.ContextConfigurator = builder => builder.UseInMemoryDatabase("aggregate-marker-options"))); + using var provider = services.BuildServiceProvider(); + + ConfiguredOptionsAssertions.Verify>(provider); + Assert.IsType>(provider.GetRequiredService>()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddEfCoreAggregateDataSource_ShouldRejectNullSetupDuringRegistration(bool useMarker) + { + var services = new ServiceCollection(); + + Assert.Throws(() => + { + if (useMarker) { services.AddEfCoreAggregateDataSource(null!); } + else { services.AddEfCoreAggregateDataSource(null!); } + }); + } +} diff --git a/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests.csproj index f3fe12ad..8652837e 100644 --- a/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Tests.csproj index 1456c72a..ee485ec4 100644 --- a/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/Savvyio.Extensions.DependencyInjection.EFCore.Tests.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/ServiceCollectionExtensionsTest.cs index 47d0f928..5013f750 100644 --- a/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.EFCore.Tests/ServiceCollectionExtensionsTest.cs @@ -25,6 +25,7 @@ public void AddEfCoreDataSource_ShouldAddDefaultImplementation() var sut1 = new ServiceCollection(); sut1.AddEfCoreDataSource(o => o.ContextConfigurator = b => b.UseInMemoryDatabase(nameof(AnotherDbMarker))); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService()); Assert.IsType(sut2.GetRequiredService()); @@ -38,6 +39,7 @@ public void AddEfCoreDataSource_ShouldAddDefaultImplementationWithMarker() var sut1 = new ServiceCollection(); sut1.AddEfCoreDataSource(o => o.ContextConfigurator = b => b.UseInMemoryDatabase("fake")); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); @@ -53,6 +55,7 @@ public void AddEfCoreRepository_ShouldAddManyImplementations() sut1.AddEfCoreRepository(); sut1.AddEfCoreRepository(); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); @@ -69,6 +72,7 @@ public void AddEfCoreRepository_ShouldAddManyImplementationsWithMarkers() sut1.AddEfCoreRepository(); sut1.AddEfCoreRepository(); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); @@ -86,6 +90,7 @@ public void AddEfCoreDataAccessObject_ShouldAddManyImplementations() sut1.AddEfCoreDataStore(); sut1.AddEfCoreDataStore(); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); TestOutput.WriteLine(sut2.GetRequiredService>>().GetType().FullName); @@ -104,6 +109,7 @@ public void AddEfCoreDataAccessObject_ShouldAddManyImplementationsWithMarkers() sut1.AddEfCoreDataStore(); sut1.AddEfCoreDataStore(); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService, DbMarker>>()); Assert.IsType>(sut2.GetRequiredService, DbMarker>>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/Savvyio.Extensions.DependencyInjection.NATS.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/Savvyio.Extensions.DependencyInjection.NATS.Tests.csproj index 7ad77ebe..4895c6c8 100644 --- a/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/Savvyio.Extensions.DependencyInjection.NATS.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/Savvyio.Extensions.DependencyInjection.NATS.Tests.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/ServiceCollectionExtensionsTest.cs index 4342938f..5884406f 100644 --- a/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.NATS.Tests/ServiceCollectionExtensionsTest.cs @@ -40,6 +40,7 @@ public void AddNatsCommandQueue_ShouldRegisterNatsCommandQueueAndConfigureOption // Assert Assert.True(natsSetupCalled); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var queue = provider.GetService(); @@ -70,6 +71,7 @@ public void AddNatsCommandQueue_Generic_ShouldRegisterNatsCommandQueueAndConfigu // Assert Assert.True(natsSetupCalled); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(provider); var queue = provider.GetService>(); Assert.IsType>(provider.GetRequiredService>()); @@ -97,6 +99,7 @@ public void AddNatsEventBus_ShouldRegisterNatsEventBusAndConfigureOptions() // Assert Assert.True(natsSetupCalled); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var bus = provider.GetService(); Assert.IsType(provider.GetRequiredService>()); @@ -124,6 +127,7 @@ public void AddNatsEventBus_Generic_ShouldRegisterNatsEventBusAndConfigureOption // Assert Assert.True(natsSetupCalled); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(provider); var bus = provider.GetService>(); Assert.IsType>(provider.GetRequiredService>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests.csproj index c5eafcae..09b15123 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/ServiceCollectionExtensionsTest.cs index a6a7ed65..a21c5a10 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json.Tests/ServiceCollectionExtensionsTest.cs @@ -34,6 +34,7 @@ public void AddNewtonsoftJsonMarshaller_ShouldRegisterNewtonsoftJsonMarshallerWi // Assert var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var marshaller = provider.GetService(); Assert.NotNull(marshaller); @@ -59,6 +60,7 @@ public void AddNewtonsoftJsonMarshaller_ShouldApplyCustomJsonSetup() // Assert var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var options = provider.GetService(); Assert.NotNull(options); Assert.True(called); diff --git a/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests.csproj index b8a1c777..4158d57b 100644 --- a/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/ServiceCollectionExtensionsTest.cs index 9b2dccdf..cc3fea59 100644 --- a/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.QueueStorage.Tests/ServiceCollectionExtensionsTest.cs @@ -40,6 +40,7 @@ public void AddAzureCommandQueue_ShouldAddDefaultImplementation() services.AddMarshaller(); services.AddAzureCommandQueue(azureSetup); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); Assert.IsType(provider.GetRequiredService>()); Assert.IsType(provider.GetRequiredService>()); @@ -73,6 +74,7 @@ public void AddAzureCommandQueue_ShouldAddDefaultImplementationWithMarker() services.AddMarshaller(); services.AddAzureCommandQueue(azureSetup); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(provider); Assert.IsType>(provider.GetRequiredService>()); Assert.IsType>(provider.GetRequiredService>()); @@ -111,6 +113,8 @@ public void AddAzureEventBus_ShouldAddDefaultImplementation() services.AddMarshaller(); services.AddAzureEventBus(azureQueueSetup, azureEventBusSetup); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); + ConfiguredOptionsAssertions.Verify(provider); Assert.IsType(provider.GetRequiredService>()); Assert.IsType(provider.GetRequiredService>()); @@ -158,6 +162,8 @@ public void AddAzureEventBus_ShouldAddDefaultImplementationWithMarker() services.AddMarshaller(); services.AddAzureEventBus(azureQueueSetup, azureEventBusSetup); var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(provider); + ConfiguredOptionsAssertions.Verify>(provider); Assert.IsType>(provider.GetRequiredService>()); Assert.IsType>(provider.GetRequiredService>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests.csproj index 81b7756e..f286ad51 100644 --- a/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/ServiceCollectionExtensionsTest.cs index b4d2ec8c..586fd5fd 100644 --- a/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.RabbitMQ.Tests/ServiceCollectionExtensionsTest.cs @@ -35,6 +35,7 @@ public void AddRabbitMqCommandQueue_ShouldAddDefaultImplementation() sut1.AddMarshaller(); sut1.AddRabbitMqCommandQueue(rabbitMqSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService>()); Assert.IsType(sut2.GetRequiredService>()); @@ -66,6 +67,7 @@ public void AddRabbitMqCommandQueue_ShouldAddDefaultImplementationWithMarker() sut1.AddMarshaller(); sut1.AddRabbitMqCommandQueue(rabbitMqSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); @@ -97,6 +99,7 @@ public void AddRabbitMqEventBus_ShouldAddDefaultImplementation() sut1.AddMarshaller(); sut1.AddRabbitMqEventBus(rabbitMqSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService>()); Assert.IsType(sut2.GetRequiredService>()); @@ -128,6 +131,7 @@ public void AddRabbitMqEventBus_ShouldAddDefaultImplementationWithMarker() sut1.AddMarshaller(); sut1.AddRabbitMqEventBus(rabbitMqSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests.csproj index c3d21430..f960e39f 100644 --- a/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/ServiceCollectionExtensionsTest.cs index be41c8bd..9c0ce9fd 100644 --- a/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.SimpleQueueService.Tests/ServiceCollectionExtensionsTest.cs @@ -39,6 +39,7 @@ public void AddAmazonCommandQueue_ShouldAddDefaultImplementation() sut1.AddMarshaller(); sut1.AddAmazonCommandQueue(awsSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService>()); Assert.IsType(sut2.GetRequiredService>()); @@ -72,6 +73,7 @@ public void AddAmazonCommandQueue_ShouldAddDefaultImplementationWithMarker() sut1.AddMarshaller(); sut1.AddAmazonCommandQueue(awsSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); @@ -105,6 +107,7 @@ public void AddAmazonEventBus_ShouldAddDefaultImplementation() sut1.AddMarshaller(); sut1.AddAmazonEventBus(awsSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(sut2); Assert.IsType(sut2.GetRequiredService>()); Assert.IsType(sut2.GetRequiredService>()); @@ -138,6 +141,7 @@ public void AddAmazonEventBus_ShouldAddDefaultImplementationWithMarker() sut1.AddMarshaller(); sut1.AddAmazonEventBus(awsSetup); var sut2 = sut1.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify>(sut2); Assert.IsType>(sut2.GetRequiredService>()); Assert.IsType>(sut2.GetRequiredService>()); diff --git a/test/Savvyio.Extensions.DependencyInjection.Tests/ConfiguredOptionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.Tests/ConfiguredOptionsTest.cs new file mode 100644 index 00000000..7c39d445 --- /dev/null +++ b/test/Savvyio.Extensions.DependencyInjection.Tests/ConfiguredOptionsTest.cs @@ -0,0 +1,208 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Cuemon.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection; + +public class ConfiguredOptionsTest : Test +{ + public ConfiguredOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddConfiguredOptions_ShouldRejectNullServicesBeforeInvokingSetup() + { + var called = false; + + var error = Assert.Throws(() => ServiceCollectionExtensions.AddConfiguredOptions(null!, _ => called = true)); + + Assert.Equal("services", error.ParamName); + Assert.False(called); + } + + [Fact] + public void AddConfiguredOptions_ShouldRejectNullSetupWithoutRegisteringServices() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddConfiguredOptions(null!)); + + Assert.Empty(services); + } + + [Fact] + public void AddConfiguredOptions_ShouldReturnSameCollectionAndRegisterThreeConsumptionForms() + { + var services = new ServiceCollection(); + Action setup = options => options.Value = "configured"; + + Assert.Same(services, services.AddConfiguredOptions(setup)); + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var direct = provider.GetRequiredService(); + var microsoft = provider.GetRequiredService>(); + var callback = provider.GetRequiredService>(); + var manual = new Options(); + callback(manual); + + Assert.Equal("configured", direct.Value); + Assert.Equal(direct.Value, microsoft.Value.Value); + Assert.Equal(direct.Value, manual.Value); + Assert.Same(setup, callback); + Assert.Same(direct, scope.ServiceProvider.GetRequiredService()); + Assert.Same(callback, scope.ServiceProvider.GetRequiredService>()); + Assert.Same(microsoft, scope.ServiceProvider.GetRequiredService>()); + Assert.NotSame(direct, microsoft.Value); + Assert.Equal(ServiceLifetime.Singleton, Assert.Single(services, descriptor => descriptor.ServiceType == typeof(Options)).Lifetime); + Assert.Equal(ServiceLifetime.Singleton, Assert.Single(services, descriptor => descriptor.ServiceType == typeof(Action)).Lifetime); + } + + [Fact] + public void AddConfiguredOptions_ShouldConfigureEagerlyAndAgainWhenMicrosoftOptionsAreMaterialized() + { + var services = new ServiceCollection(); + var invocations = 0; + services.AddConfiguredOptions(options => options.Value = (++invocations).ToString()); + + Assert.Equal(1, invocations); + using var provider = services.BuildServiceProvider(); + Assert.Equal("1", provider.GetRequiredService().Value); + Assert.Equal(1, invocations); + var options = provider.GetRequiredService>(); + Assert.Equal(1, invocations); + Assert.Equal("2", options.Value.Value); + Assert.Same(options.Value, options.Value); + Assert.Equal(2, invocations); + } + + [Fact] + public void AddConfiguredOptions_ShouldValidateEagerlyAndWrapInvalidStateWithoutRegisteringServices() + { + var services = new ServiceCollection(); + + var error = Assert.Throws(() => services.AddConfiguredOptions(options => options.Value = "invalid")); + + Assert.Equal("setup", error.ParamName); + Assert.IsType(error.InnerException); + Assert.Empty(services); + } + + [Fact] + public void AddConfiguredOptions_ShouldPropagateSetupFailureWithoutRegisteringServices() + { + var services = new ServiceCollection(); + var expected = new InvalidOperationException("Configuration failed."); + + var actual = Record.Exception(() => services.AddConfiguredOptions(_ => throw expected)); + + Assert.Same(expected, actual); + Assert.Empty(services); + } + + [Fact] + public void AddConfiguredOptions_ShouldPostConfigureThenValidateOnlyTheEagerInstance() + { + var services = new ServiceCollection(); + services.AddConfiguredOptions(options => + { + options.Value = "valid"; + options.Stages.Add("configure"); + }); + using var provider = services.BuildServiceProvider(); + + var direct = provider.GetRequiredService(); + var microsoft = provider.GetRequiredService>().Value; + var manual = new ValidatedOptions(); + provider.GetRequiredService>()(manual); + + Assert.Equal(new[] { "configure", "postconfigure", "validate" }, direct.Stages); + Assert.Equal(new[] { "configure" }, microsoft.Stages); + Assert.Equal(new[] { "configure" }, manual.Stages); + } + + [Fact] + public void AddConfiguredOptions_ShouldAppendRepeatedRegistrationsAndComposeMicrosoftConfigurations() + { + var services = new ServiceCollection(); + Action first = options => options.Value += "first"; + Action second = options => options.Value += "second"; + services.AddConfiguredOptions(first); + services.AddConfiguredOptions(second); + using var provider = services.BuildServiceProvider(); + + Assert.Equal(new[] { "first", "second" }, provider.GetServices().Select(options => options.Value)); + Assert.Equal("second", provider.GetRequiredService().Value); + Assert.Same(second, provider.GetRequiredService>()); + Assert.Equal(new[] { first, second }, provider.GetServices>()); + Assert.Equal("firstsecond", provider.GetRequiredService>().Value.Value); + } + + [Fact] + public void AddConfiguredOptions_ShouldComposeMicrosoftConfigureAndPostConfigureWithoutChangingDirectOptions() + { + var services = new ServiceCollection(); + services.Configure(options => options.Value += "before;"); + services.AddConfiguredOptions(options => options.Value += "primary;"); + services.Configure(options => options.Value += "after;"); + services.PostConfigure(options => options.Value += "post;"); + using var provider = services.BuildServiceProvider(); + + Assert.Equal("primary;", provider.GetRequiredService().Value); + Assert.Equal("before;primary;after;post;", provider.GetRequiredService>().Value.Value); + var manual = new Options(); + provider.GetRequiredService>()(manual); + Assert.Equal("primary;", manual.Value); + } + + [Fact] + public void AddConfiguredOptions_ShouldPreserveSnapshotAndMonitorLifetimesAndNamedOptions() + { + var services = new ServiceCollection(); + services.AddConfiguredOptions(options => options.Value = "default"); + services.Configure("named", options => options.Value = "named"); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + using var firstScope = provider.CreateScope(); + using var secondScope = provider.CreateScope(); + var first = firstScope.ServiceProvider.GetRequiredService>(); + var second = secondScope.ServiceProvider.GetRequiredService>(); + var monitor = provider.GetRequiredService>(); + + Assert.Same(first, firstScope.ServiceProvider.GetRequiredService>()); + Assert.NotSame(first, second); + Assert.Same(first.Value, first.Value); + Assert.NotSame(first.Value, second.Value); + Assert.Equal("default", first.Value.Value); + Assert.Equal("named", first.Get("named").Value); + Assert.Equal("default", monitor.CurrentValue.Value); + Assert.Equal("named", monitor.Get("named").Value); + Assert.Same(monitor, secondScope.ServiceProvider.GetRequiredService>()); + Assert.NotSame(provider.GetRequiredService(), monitor.CurrentValue); + } + + private sealed class Options : IParameterObject + { + public string Value { get; set; } = string.Empty; + } + + private sealed class ValidatedOptions : IPostConfigurableParameterObject, IValidatableParameterObject + { + public string Value { get; set; } = string.Empty; + + public List Stages { get; } = []; + + public void PostConfigureOptions() => Stages.Add("postconfigure"); + + public void ValidateOptions() + { + Stages.Add("validate"); + if (Value != "valid") { throw new InvalidOperationException("Value must be valid."); } + } + } +} diff --git a/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/Savvyio.Extensions.DependencyInjection.Text.Json.Tests.csproj b/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/Savvyio.Extensions.DependencyInjection.Text.Json.Tests.csproj index ed72582b..29412360 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/Savvyio.Extensions.DependencyInjection.Text.Json.Tests.csproj +++ b/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/Savvyio.Extensions.DependencyInjection.Text.Json.Tests.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/ServiceCollectionExtensionsTest.cs b/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/ServiceCollectionExtensionsTest.cs index 2112ea3d..549e9a5a 100644 --- a/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Savvyio.Extensions.DependencyInjection.Text.Json.Tests/ServiceCollectionExtensionsTest.cs @@ -32,6 +32,7 @@ public void AddJsonMarshaller_ShouldRegisterJsonMarshallerWithDefaultOptions() // Assert var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var marshaller = provider.GetService(); Assert.NotNull(marshaller); @@ -57,6 +58,7 @@ public void AddJsonMarshaller_ShouldApplyCustomJsonSetup() // Assert var provider = services.BuildServiceProvider(); + ConfiguredOptionsAssertions.Verify(provider); var options = provider.GetService(); Assert.NotNull(options); Assert.True(called); diff --git a/test/Shared/ConfiguredOptionsAssertions.cs b/test/Shared/ConfiguredOptionsAssertions.cs new file mode 100644 index 00000000..a3a8f0e3 --- /dev/null +++ b/test/Shared/ConfiguredOptionsAssertions.cs @@ -0,0 +1,28 @@ +#nullable enable +using System; +using Cuemon.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection; + +internal static class ConfiguredOptionsAssertions +{ + internal static void Verify(IServiceProvider provider) where TOptions : class, IParameterObject, new() + { + var direct = provider.GetRequiredService(); + var microsoft = provider.GetRequiredService>().Value; + var callback = provider.GetRequiredService>(); + var manual = new TOptions(); + callback(manual); + + Assert.Equivalent(direct, microsoft, strict: true); + Assert.Equivalent(direct, manual, strict: true); + // Characterize the current separate eager instance before the Cuemon migration. + Assert.NotSame(direct, microsoft); + using var scope = provider.CreateScope(); + Assert.Same(direct, scope.ServiceProvider.GetRequiredService()); + Assert.Same(callback, scope.ServiceProvider.GetRequiredService>()); + } +} From d778519f9d160226258b9c95bbe1b665689d1db6 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 14:22:59 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=85=20cover=20automatic=20dispatcher?= =?UTF-8?q?=20and=20handler=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in CurrentDomain brute-force scanning for dispatchers and handlers, including duplicate-free handler results. --- .../SavvyioOptionsExtensionsTest.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs b/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs index 0c5e603d..b8e9c899 100644 --- a/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs +++ b/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs @@ -14,6 +14,42 @@ public SavvyioOptionsExtensionsTest(ITestOutputHelper output) : base(output) { } + [Fact] + public void UseAutomaticDispatcherDiscovery_ShouldDiscoverAcrossCurrentDomain() + { + var options = new SavvyioOptions(); + + Assert.Same(options, options.UseAutomaticDispatcherDiscovery(true)); + + Assert.Contains(typeof(ICommandDispatcher), options.DispatcherServiceTypes); + Assert.Contains(typeof(IDomainEventDispatcher), options.DispatcherServiceTypes); + Assert.Contains(typeof(IIntegrationEventDispatcher), options.DispatcherServiceTypes); + Assert.Contains(typeof(IQueryDispatcher), options.DispatcherServiceTypes); + Assert.Contains(typeof(CommandDispatcher), options.DispatcherImplementationTypes); + Assert.Contains(typeof(DomainEventDispatcher), options.DispatcherImplementationTypes); + Assert.Contains(typeof(IntegrationEventDispatcher), options.DispatcherImplementationTypes); + Assert.Contains(typeof(QueryDispatcher), options.DispatcherImplementationTypes); + } + + [Fact] + public void UseAutomaticHandlerDiscovery_ShouldDiscoverAcrossCurrentDomainWithoutDuplicates() + { + var options = new SavvyioOptions(); + + Assert.Same(options, options.UseAutomaticHandlerDiscovery(true)); + options.UseAutomaticHandlerDiscovery(true); + + Assert.Contains(typeof(ICommandHandler), options.HandlerServiceTypes); + Assert.Contains(typeof(IDomainEventHandler), options.HandlerServiceTypes); + Assert.Contains(typeof(IIntegrationEventHandler), options.HandlerServiceTypes); + Assert.Contains(typeof(IQueryHandler), options.HandlerServiceTypes); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorCommandHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorDomainEventHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorIntegrationEventHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorQueryHandler"); + Assert.Equal(options.HandlerImplementationTypes.Count, options.HandlerImplementationTypes.Distinct().Count()); + } + [Fact] public void UseAutomaticDispatcherDiscovery_ShouldUseCallingAssembly_WhenBruteAssemblyScanningIsFalse() { From e388f2c3a5677106901bf21ebc98253c73cb7f5b Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 14:23:02 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=85=20cover=20assembly=20context=20fi?= =?UTF-8?q?ltering=20and=20traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in ordinal System and Microsoft prefix filtering, transitive dependency traversal, and CurrentDomain caching semantics. --- .../Reflection/AssemblyContextBehaviorTest.cs | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/Savvyio.Core.Tests/Reflection/AssemblyContextBehaviorTest.cs diff --git a/test/Savvyio.Core.Tests/Reflection/AssemblyContextBehaviorTest.cs b/test/Savvyio.Core.Tests/Reflection/AssemblyContextBehaviorTest.cs new file mode 100644 index 00000000..a586f8e1 --- /dev/null +++ b/test/Savvyio.Core.Tests/Reflection/AssemblyContextBehaviorTest.cs @@ -0,0 +1,231 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Codebelt.Extensions.Xunit; +using Cuemon; +using Savvyio.Commands; +using Xunit; + +namespace Savvyio.Reflection; + +public class AssemblyContextBehaviorTest : Test +{ + public AssemblyContextBehaviorTest(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(null, false)] + [InlineData("System.Application", false)] + [InlineData("Microsoft.Application", false)] + [InlineData("Application.System", true)] + [InlineData("system.Application", true)] + [InlineData("microsoft.Application", true)] + public void AssemblyFilterCallback_ShouldUseOrdinalPrefixFiltering(string? fullName, bool expected) + { + using var context = new IsolatedAssemblyContext(); + + Assert.Equal(expected, context.AssemblyFilter(new ReferencedAssembly(fullName))); + } + + [Theory] + [InlineData("System.Application", false)] + [InlineData("Microsoft.Application", false)] + [InlineData("Application.System", true)] + [InlineData("system.Application", true)] + [InlineData("microsoft.Application", true)] + public void AssemblyDependenciesFilterCallback_ShouldUseOrdinalPrefixFiltering(string name, bool expected) + { + using var context = new IsolatedAssemblyContext(); + + Assert.Equal(expected, context.ReferenceFilter(new AssemblyName(name))); + } + + [Theory] + [InlineData(nameof(AssemblyContext.AssemblyFilterCallback))] + [InlineData(nameof(AssemblyContext.AssemblyDependenciesCallback))] + [InlineData(nameof(AssemblyContext.AssemblyDependenciesFilterCallback))] + public void Callback_ShouldRejectNullAndPreservePreviousDelegate(string propertyName) + { + using var context = new IsolatedAssemblyContext(); + var property = context.Type.GetProperty(propertyName)!; + var original = property.GetValue(null); + + var error = Assert.Throws(() => property.SetValue(null, null)); + + Assert.Equal("value", Assert.IsType(error.InnerException).ParamName); + Assert.Same(original, property.GetValue(null)); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldTraverseTransitiveReferences() + { + using var context = new IsolatedAssemblyContext(); + var root = typeof(Command).Assembly; + var core = typeof(AssemblyContext).Assembly; + var transitive = typeof(Cuemon.Reflection.AssemblyContext).Assembly; + // Commands references Savvyio.Core, which in turn references Cuemon.Core. + Assert.DoesNotContain(root.GetReferencedAssemblies(), name => name.FullName == transitive.FullName); + + var assemblies = context.Dependencies(root).ToList(); + + Assert.Equal(root, assemblies[0]); + Assert.Contains(assemblies, assembly => assembly.FullName == core.FullName); + Assert.Contains(assemblies, assembly => assembly.FullName == transitive.FullName); + Assert.Equal(assemblies.Count, assemblies.Distinct().Count()); + Assert.DoesNotContain(assemblies, assembly => assembly.FullName!.StartsWith("System", StringComparison.Ordinal)); + Assert.DoesNotContain(assemblies, assembly => assembly.FullName!.StartsWith("Microsoft", StringComparison.Ordinal)); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldSkipSelfReferencesAndDuplicates() + { + using var context = new IsolatedAssemblyContext(); + var dependency = typeof(Validator).Assembly; + var root = new ReferencedAssembly("Application.Root", new AssemblyName("Application.Root"), dependency.GetName(), dependency.GetName()); + context.ReferenceFilter = name => name.Name is "Application.Root" or "Cuemon.Kernel"; + + Assert.Equal(new Assembly[] { root, dependency }, context.Dependencies(root)); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldContinueAfterUnresolvableReference() + { + using var context = new IsolatedAssemblyContext(); + var dependency = typeof(Validator).Assembly; + var root = new ReferencedAssembly("Application.Root", new AssemblyName("Savvyio.Tests.NonexistentDependency.8d9b24a2"), dependency.GetName()); + context.ReferenceFilter = name => name.Name == "Savvyio.Tests.NonexistentDependency.8d9b24a2" || name.FullName == dependency.FullName; + + Assert.Equal(new Assembly[] { root, dependency }, context.Dependencies(root)); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldApplyCustomFilterWithoutFilteringRoot() + { + using var context = new IsolatedAssemblyContext(); + var root = typeof(Command).Assembly; + var inspected = new List(); + context.ReferenceFilter = name => + { + inspected.Add(name); + return false; + }; + + Assert.Same(root, Assert.Single(context.Dependencies(root))); + Assert.Equal(root.GetReferencedAssemblies().Select(name => name.FullName), inspected.Select(name => name.FullName)); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldFilterExpandDeduplicateAndExcludeOwnAssembly() + { + using var context = new IsolatedAssemblyContext(); + var root = typeof(AssemblyContextBehaviorTest).Assembly; + var dependency = typeof(Command).Assembly; + var expanded = new List(); + context.AssemblyFilter = assembly => assembly == root; + context.Dependencies = assembly => + { + expanded.Add(assembly); + return [assembly, dependency, dependency, context.Type.Assembly]; + }; + + var assemblies = context.CurrentDomainAssemblies; + + Assert.Same(root, Assert.Single(expanded)); + Assert.Equal(new[] { root, dependency }, assemblies); + Assert.Throws(() => ((IList)assemblies).Add(root)); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldCacheFirstResultDespiteCallbackChanges() + { + using var context = new IsolatedAssemblyContext(); + var root = typeof(AssemblyContextBehaviorTest).Assembly; + context.AssemblyFilter = assembly => assembly == root; + context.Dependencies = assembly => [assembly]; + var first = context.CurrentDomainAssemblies; + + context.AssemblyFilter = _ => throw new InvalidOperationException("Must not rescan."); + context.Dependencies = _ => throw new InvalidOperationException("Must not traverse again."); + context.ReferenceFilter = _ => throw new InvalidOperationException("Must not refilter."); + + Assert.Same(first, context.CurrentDomainAssemblies); + Assert.Same(root, Assert.Single(first)); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldAllowEmptyResult() + { + using var context = new IsolatedAssemblyContext(); + context.AssemblyFilter = _ => false; + context.Dependencies = _ => throw new InvalidOperationException("No assembly passed the filter."); + + Assert.Empty(context.CurrentDomainAssemblies); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldCacheCallbackFailure() + { + using var context = new IsolatedAssemblyContext(); + var expected = new InvalidOperationException("Assembly filter failed."); + context.AssemblyFilter = _ => throw expected; + + var first = Assert.Throws(() => context.CurrentDomainAssemblies); + context.AssemblyFilter = _ => false; + var second = Assert.Throws(() => context.CurrentDomainAssemblies); + + Assert.Same(expected, first.InnerException); + Assert.Same(expected, second.InnerException); + } + + private sealed class ReferencedAssembly(string? fullName, params AssemblyName[] references) : Assembly + { + public override string? FullName => fullName; + + public override AssemblyName[] GetReferencedAssemblies() => references; + } + + // A separate load context gives each test fresh static callbacks and Lazy state. + // This avoids resetting private fields or changing the application's global scan. + private sealed class IsolatedAssemblyContext : IDisposable + { + private readonly AssemblyLoadContext _loadContext = new(null, isCollectible: true); + + public IsolatedAssemblyContext() + { + Type = _loadContext.LoadFromAssemblyPath(typeof(AssemblyContext).Assembly.Location) + .GetType(typeof(AssemblyContext).FullName!, throwOnError: true)!; + } + + public Type Type { get; } + + public Func AssemblyFilter + { + get => Get>(nameof(AssemblyContext.AssemblyFilterCallback)); + set => Set(nameof(AssemblyContext.AssemblyFilterCallback), value); + } + + public Func> Dependencies + { + get => Get>>(nameof(AssemblyContext.AssemblyDependenciesCallback)); + set => Set(nameof(AssemblyContext.AssemblyDependenciesCallback), value); + } + + public Func ReferenceFilter + { + get => Get>(nameof(AssemblyContext.AssemblyDependenciesFilterCallback)); + set => Set(nameof(AssemblyContext.AssemblyDependenciesFilterCallback), value); + } + + public IReadOnlyList CurrentDomainAssemblies => Get>(nameof(AssemblyContext.CurrentDomainAssemblies)); + + private T Get(string name) => (T)Type.GetProperty(name)!.GetValue(null)!; + + private void Set(string name, object value) => Type.GetProperty(name)!.SetValue(null, value); + + public void Dispose() => _loadContext.Unload(); + } +} From 5cb8a31bc6f3e26b62c2fce46f0dd50a40059d52 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 14:23:11 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=85=20cover=20JSON=20converter=20guar?= =?UTF-8?q?ds=20and=20cloud=20event=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard null writer behavior and ensure concrete CloudEvent types are discovered while abstract candidates are ignored across both JSON marshallers. --- .../Converters/CloudEventDiscoveryTest.cs | 75 ++++++++++++ .../Converters/MessageConverterGuardTest.cs | 27 +++++ .../Converters/CloudEventDiscoveryTest.cs | 75 ++++++++++++ .../Converters/MessageConverterGuardTest.cs | 108 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/CloudEventDiscoveryTest.cs create mode 100644 test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/MessageConverterGuardTest.cs create mode 100644 test/Savvyio.Extensions.Text.Json.Tests/Converters/CloudEventDiscoveryTest.cs create mode 100644 test/Savvyio.Extensions.Text.Json.Tests/Converters/MessageConverterGuardTest.cs diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/CloudEventDiscoveryTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/CloudEventDiscoveryTest.cs new file mode 100644 index 00000000..da6be2c7 --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/CloudEventDiscoveryTest.cs @@ -0,0 +1,75 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.EventDriven; +using Savvyio.EventDriven.Messaging.CloudEvents; +using Savvyio.EventDriven.Messaging.CloudEvents.Cryptography; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json.Converters; + +public class CloudEventDiscoveryTest : Test +{ + public CloudEventDiscoveryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CloudEventDiscovery_ShouldIncludeConcreteTypesAndExcludeAbstractCandidates() + { + var discovered = ((Lazy>)typeof(MessageConverter) + .GetField("CloudEventTypes", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!).Value; + + Assert.Contains(discovered, type => type.AsType() == typeof(CloudEvent<>)); + Assert.Contains(discovered, type => type.AsType() == typeof(SignedCloudEvent<>)); + Assert.DoesNotContain(discovered, type => type.AsType() == typeof(AbstractCloudEvent)); + Assert.DoesNotContain(discovered, type => type.IsAbstract || type.IsInterface); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Deserialize_ShouldDiscoverConcreteCloudEventFromCloudEventInterface(bool signed) + { + var time = new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc); + var payload = new MemberCreated("Jane Doe", "jd@office.com"); + var message = new Message("message-id", new Uri("https://example.test/members"), nameof(MemberCreated), payload, time); + var cloudEvent = new CloudEvent(message); + IMessage original = signed ? new SignedCloudEvent(cloudEvent, "signature") : cloudEvent; + var marshaller = new NewtonsoftJsonMarshaller(); + using var json = marshaller.Serialize(original); + + IMessage result = signed + ? marshaller.Deserialize>(json) + : marshaller.Deserialize>(json); + + if (signed) + { + var discovered = Assert.IsType>(result); + Assert.Equal("signature", discovered.Signature); + Assert.Equal(cloudEvent.Specversion, discovered.Specversion); + } + else + { + var discovered = Assert.IsType>(result); + Assert.Equal(cloudEvent.Specversion, discovered.Specversion); + } + Assert.Equal(original.Id, result.Id); + Assert.Equal(original.Source, result.Source); + Assert.Equal(original.Type, result.Type); + Assert.Equal(original.Time, result.Time); + Assert.Equal("Jane Doe", result.Data.Name); + Assert.Equal("jd@office.com", result.Data.EmailAddress); + } + + // Assembly discovery must ignore abstract candidates even when they implement ICloudEvent. + private abstract record AbstractCloudEvent : CloudEvent + { + protected AbstractCloudEvent(IMessage message) : base(message) + { + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/MessageConverterGuardTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/MessageConverterGuardTest.cs new file mode 100644 index 00000000..70a7f43d --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/MessageConverterGuardTest.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Globalization; +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json.Converters; + +public class MessageConverterGuardTest : Test +{ + public MessageConverterGuardTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void WriteJson_ShouldLeaveWriterUntouchedWhenValueIsNull() + { + using var text = new StringWriter(CultureInfo.InvariantCulture); + using var writer = new JsonTextWriter(text); + + new MessageConverter().WriteJson(writer, null!, new JsonSerializer()); + + Assert.Equal(string.Empty, text.ToString()); + Assert.Equal(WriteState.Start, writer.WriteState); + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/Converters/CloudEventDiscoveryTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/Converters/CloudEventDiscoveryTest.cs new file mode 100644 index 00000000..4ef5da1f --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/Converters/CloudEventDiscoveryTest.cs @@ -0,0 +1,75 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.EventDriven; +using Savvyio.EventDriven.Messaging.CloudEvents; +using Savvyio.EventDriven.Messaging.CloudEvents.Cryptography; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.Text.Json.Converters; + +public class CloudEventDiscoveryTest : Test +{ + public CloudEventDiscoveryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CloudEventDiscovery_ShouldIncludeConcreteTypesAndExcludeAbstractCandidates() + { + var discovered = ((Lazy>)typeof(MessageConverter) + .GetField("CloudEventTypes", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!).Value; + + Assert.Contains(discovered, type => type.AsType() == typeof(CloudEvent<>)); + Assert.Contains(discovered, type => type.AsType() == typeof(SignedCloudEvent<>)); + Assert.DoesNotContain(discovered, type => type.AsType() == typeof(AbstractCloudEvent)); + Assert.DoesNotContain(discovered, type => type.IsAbstract || type.IsInterface); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Deserialize_ShouldDiscoverConcreteCloudEventFromCloudEventInterface(bool signed) + { + var time = new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc); + var payload = new MemberCreated("Jane Doe", "jd@office.com"); + var message = new Message("message-id", new Uri("https://example.test/members"), nameof(MemberCreated), payload, time); + var cloudEvent = new CloudEvent(message); + IMessage original = signed ? new SignedCloudEvent(cloudEvent, "signature") : cloudEvent; + var marshaller = new JsonMarshaller(); + using var json = marshaller.Serialize(original); + + IMessage result = signed + ? marshaller.Deserialize>(json) + : marshaller.Deserialize>(json); + + if (signed) + { + var discovered = Assert.IsType>(result); + Assert.Equal("signature", discovered.Signature); + Assert.Equal(cloudEvent.Specversion, discovered.Specversion); + } + else + { + var discovered = Assert.IsType>(result); + Assert.Equal(cloudEvent.Specversion, discovered.Specversion); + } + Assert.Equal(original.Id, result.Id); + Assert.Equal(original.Source, result.Source); + Assert.Equal(original.Type, result.Type); + Assert.Equal(original.Time, result.Time); + Assert.Equal("Jane Doe", result.Data.Name); + Assert.Equal("jd@office.com", result.Data.EmailAddress); + } + + // Assembly discovery must ignore abstract candidates even when they implement ICloudEvent. + private abstract record AbstractCloudEvent : CloudEvent + { + protected AbstractCloudEvent(IMessage message) : base(message) + { + } + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/Converters/MessageConverterGuardTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/Converters/MessageConverterGuardTest.cs new file mode 100644 index 00000000..bde21d66 --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/Converters/MessageConverterGuardTest.cs @@ -0,0 +1,108 @@ +#nullable enable +using System; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Codebelt.Extensions.Xunit; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.Text.Json.Converters; + +public class MessageConverterGuardTest : Test +{ + public MessageConverterGuardTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Read_ShouldSupportRequestsWithoutMetadata() + { + var result = Roundtrip(new PlainRequest { Name = "Jane Doe" }); + + Assert.Equal("Jane Doe", result.Data.Name); + } + + [Fact] + public void Read_ShouldPreserveExplicitMetadataUsingJsonPropertyName() + { + var request = new ExplicitMetadataRequest { Name = "Jane Doe" }; + request.Values.Add("custom", "value"); + + var result = Roundtrip(request); + + Assert.Equal("Jane Doe", result.Data.Name); + Assert.Equal("value", result.Data.Values["custom"].ToString()); + } + + [Fact] + public void Read_ShouldAllowComputedMetadataWithoutWritablePropertyOrBackingField() + { + var result = Roundtrip(new ComputedMetadataRequest { Name = "Jane Doe" }); + + Assert.Equal("Jane Doe", result.Data.Name); + Assert.Equal("computed", result.Data.Metadata["source"]); + } + + [Fact] + public void AddExtensionAttributes_ShouldIgnoreMessagesWithoutDictionarySupport() + { + var options = CreateOptions(); + var converter = new MessageConverter().CreateConverter(typeof(Message), options); + var message = CreateMessage(new PlainRequest { Name = "Jane Doe" }); + // The public path constructs CloudEvent, which always implements IDictionary. + // Exercise the internal helper's defensive no-op contract with a plain message. + var helper = converter.GetType().GetMethod("AddExtensionAttributes", BindingFlags.Static | BindingFlags.NonPublic)!; + + var error = Record.Exception(() => helper.Invoke(null, [message, default(JsonElement), Array.Empty(), options])); + + Assert.Null(error); + Assert.Equal("Jane Doe", message.Data.Name); + } + + private static Message Roundtrip(T request) where T : IRequest + { + var options = CreateOptions(); + var message = CreateMessage(request); + var json = JsonSerializer.Serialize(message, options); + var result = JsonSerializer.Deserialize>(json, options)!; + Assert.Equal(message.Id, result.Id); + Assert.Equal(message.Source, result.Source); + Assert.Equal(message.Time, result.Time); + return result; + } + + private static Message CreateMessage(T request) where T : IRequest => new( + "message-id", new Uri("https://example.test/requests"), typeof(T).Name, request, + new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc)); + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + options.Converters.Add(new MessageConverter()); + options.Converters.Add(new MetadataDictionaryConverter()); + return options; + } + + private sealed class PlainRequest : IRequest + { + public string Name { get; set; } = string.Empty; + } + + private sealed class ExplicitMetadataRequest : IRequest, IMetadata + { + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("metadata")] + public IMetadataDictionary Values { get; set; } = new MetadataDictionary(); + + IMetadataDictionary IMetadata.Metadata => Values; + } + + private sealed class ComputedMetadataRequest : IRequest, IMetadata + { + public string Name { get; set; } = string.Empty; + + public IMetadataDictionary Metadata => new MetadataDictionary { { "source", "computed" } }; + } +} From ed22f3b0dbc75e06994b56c22219bc2c7c366062 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 14:38:39 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=92=AC=20update=20changelog=20for=20v?= =?UTF-8?q?5.0.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalize the v5.0.13 patch entry from branch evidence covering DocFX pipeline hardening, contributor guidance, and expanded test coverage. --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbd6996..5a0141ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. +## [5.0.13] - TBD + +This is a patch release focused on DocFX publishing pipeline hardening, contributor test guidance corrections, and expanded test coverage for configured options, discovery, and JSON converters. + +### Added + +- Configured options verification across dependency injection extensions with shared assertions covering direct, Microsoft Options, and callback consumption forms, +- Automatic dispatcher, handler, and assembly context discovery coverage including duplicate-free results, ordinal filtering, transitive traversal, and caching semantics, +- JSON message converter coverage across Newtonsoft.Json and Text.Json including null writer guards and concrete CloudEvent resolution excluding abstract candidates. + +### Changed + +- DocFX publishing pipeline hardened with reliable restore and error handling, dhi.io nginx serving with dedicated config and lean build context, separate member pages, ms-style template, mobile navigation, corrected GitHub link, and sitemap for docs.savvyio.net, +- Contributor test guidance corrected to dotnet test --project syntax with integration-dependent exclusion wording. + ## [5.0.12] - 2026-09-13 This is a patch release focused on test infrastructure enhancements for NATS and RabbitMQ messaging extensions, dependency updates to latest stable versions, and improved contributor documentation. @@ -1120,6 +1135,7 @@ Noticeable highlights: - QueryHandler class in the Savvyio.Queries namespace that defines a generic and consistent way of handling Query objects that implements the IQuery interface - SavvyioOptionsExtensions class in the Savvyio.Queries namespace that consist of extension methods for the SavvyioOptions class: AddQueryHandler, AddQueryDispatcher +[5.0.13]: https://github.com/codebeltnet/savvyio/compare/v5.0.12...v5.0.13 [5.0.12]: https://github.com/codebeltnet/savvyio/compare/v5.0.11...v5.0.12 [5.0.11]: https://github.com/codebeltnet/savvyio/compare/v5.0.10...v5.0.11 [5.0.10]: https://github.com/codebeltnet/savvyio/compare/v5.0.9...v5.0.10 From 6cea8c8fcbaf02f06567f8b859fa8fa2360d66ea Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Mon, 5 Oct 2026 18:08:51 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=94=A7=20swap=20test=20coverage=20to?= =?UTF-8?q?=20coverlet=20with=20hang=20dump=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Microsoft.Testing.Extensions.CodeCoverage with Codebelt.Coverlet.MTP for test coverage and add Microsoft.Testing.Extensions.HangDump to diagnose hung test runs. --- Directory.Build.props | 3 ++- Directory.Packages.props | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 01739576..8ce7f192 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -74,13 +74,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Directory.Packages.props b/Directory.Packages.props index f6d1dd57..30cf1f90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,8 @@ - + +