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'
}
]
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`
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
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 @@
-
+
+
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();
+ }
+}
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/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()
{
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" } };
+ }
+}
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