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()); + Assert.Same(callback, scope.ServiceProvider.GetRequiredService>()); + } +}