diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 55274b771..e7d9596ff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -183,6 +183,28 @@ Internal classes and methods must be validated by exercising the public API that - Public entry points provide sufficient coverage of internal code paths. - The internal implementation exists solely as a helper or utility for public-facing functionality. +## 10. ExcludeFromCodeCoverage Prohibition + +**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes: + +- Test classes or test methods +- Production code +- Configuration code +- Any other code path + +### Rationale + +- Excluding code from coverage hides gaps and creates false confidence in test completeness. +- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics. +- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed). + +### Alternative Approaches + +- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path. +- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage. +- **Configuration-only code**: Move to configuration files or extract into testable methods. +- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary. + --- description: 'Writing Performance Tests in Cuemon' applyTo: "tuning/**, **/*Benchmark*.cs" diff --git a/AGENTS.md b/AGENTS.md index 1f1598f73..6ecfaa11c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,15 @@ Example: `✨ Add DateSpan.TryParse overload` 4. **Clear messages** — the subject line should be understandable without a body. 5. **Atomic commits** — each commit should be independently buildable and testable. +## Git Operations Safeguards + +Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: + +- **Commits**: Always request confirmation from the user before staging and committing code. Present a clear summary of the changes and wait for approval before executing the commit. +- **Remote Operations**: Do not push, pull, fetch, or interact with `origin` or any remote repository without explicit user instruction. These operations modify repository history and can cause data loss if performed unexpectedly. + +**Rationale:** Automatic commits can clutter history with incomplete work, temporary debugging code, or unintended changes. Unexpected remote operations risk overwriting or losing commits on shared branches. Always require explicit user approval before performing these actions. + ## Agent Workflow 1. Identify the correct project area (`src/`, `test/`, `tuning/`, `tooling/`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dfc9edd..01433d9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,36 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba ## [10.5.3] - 2026-06-03 -This is a patch release focused on fixing request service provider resolution for wrapped providers and improving test coverage for dependency injection scenarios. +This is a patch release focused on fixing request service provider resolution for wrapped providers and significantly expanding test coverage across 15 assemblies to achieve >=95% coverage. The release consolidates ad-hoc coverage tests into integrated, maintainable test suites. ### Fixed - `ServiceProviderExtensions.GetServiceDescriptors()` method to properly traverse and handle wrapped service providers, including detection and rejection of cyclic provider graphs; the method now correctly resolves descriptors when the provider is wrapped by third-party components such as AspVersioning's InjectApiVersion and throws `NotSupportedException` with descriptive messages when ambiguous or cyclic provider structures are encountered, -- `UseFaultDescriptorExceptionHandler()` middleware to work correctly when request services are wrapped by external decorators or proxies. +- `UseFaultDescriptorExceptionHandler()` middleware to work correctly when request services are wrapped by external decorators or proxies, +- `CollectionExtensions` class in the Cuemon.Extensions.Collections.Generic namespace to add null guard validation in the `Concat` method. ### Added -- `ServiceProviderExtensionsTest` unit test class with comprehensive test coverage for `GetServiceDescriptors()` method, including scenarios for delegating providers, ambiguous multi-provider cases, and cyclic provider graph detection, -- Functional test in `ApplicationBuilderExtensionsTest` to verify fault descriptor exception handling works correctly with wrapped request services. +- Comprehensive unit test coverage for Cuemon.Core: `EradicateTest`, `ExceptionInsightsTest`, `MutableTupleFactoryTest`, `StringFactoryTest`, `WatcherTest`, `GenerateTest`, `StringReplacePairTest`, +- Comprehensive unit test coverage for Cuemon.IO: `StreamFactoryTest`, `StreamOptionsTest`, `TextReaderDecoratorExtensionsTest`, +- Comprehensive unit test coverage for Cuemon.Xml: `XmlDocumentFactoryTest`, `XPathDocumentFactoryTest`, and extension tests for Stream, String, XmlReader, XmlWriter, Linq.String decorators, +- Comprehensive unit test coverage for Cuemon.Diagnostics: `FaultResolverTest`, `TimeMeasureTest`, +- Comprehensive unit test coverage for Cuemon.Runtime.Caching: `CacheEntryTest`, `CacheEntryEventArgsTest`, `CacheInvalidationTest`, `SlimMemoryCacheTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.Collections.Generic: `QueueExtensionsTest` and collection extension test updates, +- Comprehensive unit test coverage for Cuemon.Extensions.Runtime.Caching: `CacheEnumerableExtensionsTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.Text.Json: `StringEnumConverterTest`, `StringFlagsEnumConverterTest`, `DynamicJsonConverterTest`, `JsonNamingPolicyExtensionsTest`, `JsonSerializerOptionsExtensionsTest`, and net48 TFM support, +- Comprehensive unit test coverage for Cuemon.AspNetCore: `MiddlewareTest`, `HttpStatusCodeExceptionTest`, `InternalServerErrorExceptionTest`, `HttpRequestDecoratorExtensionsTest`, `HttpResponseDecoratorExtensionsTest`, `HeaderDictionaryDecoratorExtensionsTest`, `Int32DecoratorExtensionsTest`, `HttpRequestEvidenceTest`, `HttpExceptionDescriptorDecoratorExtensionsTest`, `HttpExceptionDescriptorResponseFormatterTest`, `DynamicCacheBustingTest`, +- Comprehensive unit test coverage for Cuemon.AspNetCore.Mvc: `BreadcrumbTest`, `ResultClassesTest`, `ConfigurableFilterBaseTest`, `MvcFaultDescriptorOptionsTest`, `HttpCacheHeaderOptionsTest`, `HttpEntityTagHeaderFilterTest`, `DisableModelBindingAttributeTest`, `FormatterBaseTest`, +- Comprehensive unit test coverage for Cuemon.AspNetCore.Authentication: `AuthenticatorTest`, `AuthenticationHandlerFeatureTest`, `MemoryNonceTrackerTest`, `NonceTrackerEntryTest`, `DigestHashFactoryTest`, `MiddlewareConstructorTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.AspNetCore: `ApplicationBuilderExtensionsTest`, `ServiceCollectionExtensionsCoverageTest`, Headers and Throttling integration tests, `HttpExceptionDescriptorResponseFormatterExtensionsTest`, `XmlConverterExtensionsTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.AspNetCore.Mvc: `FilterCollectionExtensionsTest`, `MvcBuilderExtensionsTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.AspNetCore.Authentication: `AuthorizationResponseHandlerOptionsTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.Xml: `HierarchyExtensionsTest`, `XElementExtensionsTest`, `XmlConverterExtensionsTest`, `XmlSerializerOptionsExtensionsTest`, `XmlExtensionsTest`, +- Comprehensive unit test coverage for Cuemon.Extensions.Net: `HttpMethodExtensionsTest`, `SlimHttpClientFactoryTest`, `HttpStatusCodeExtensionsTest`, `StringExtensionsTest`, `ByteArrayDecoratorExtensionsTest`. + +### Changed + +- Enhanced code documentation and test guidelines in `AGENTS.md` with clarifications on testing best practices. ## [10.5.2] - 2026-05-18 diff --git a/src/Cuemon.Core/Reflection/AssemblyContext.cs b/src/Cuemon.Core/Reflection/AssemblyContext.cs index af4588311..a39de2a62 100644 --- a/src/Cuemon.Core/Reflection/AssemblyContext.cs +++ b/src/Cuemon.Core/Reflection/AssemblyContext.cs @@ -19,6 +19,11 @@ public static class AssemblyContext /// /// failed to configure an instance of in a valid state. /// + /// + /// When is true, + /// referenced assemblies may be loaded into the current application domain during traversal. + /// This can change subsequent results returned by . + /// public static IReadOnlyList GetCurrentDomainAssemblies(Action setup = null) { Validator.ThrowIfInvalidConfigurator(setup, out var options); diff --git a/src/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/MvcCoreBuilderExtensions.cs b/src/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/MvcCoreBuilderExtensions.cs index 6ba8b413f..9cf5ccc4f 100644 --- a/src/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/MvcCoreBuilderExtensions.cs +++ b/src/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/MvcCoreBuilderExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using Cuemon.Extensions.AspNetCore.Text.Json.Formatters; using Cuemon.Extensions.Text.Json.Formatters; using Microsoft.AspNetCore.Mvc; @@ -20,10 +20,9 @@ public static class MvcCoreBuilderExtensions /// The which may be configured. /// A reference to after the operation has completed. /// - /// cannot be null -or- - /// cannot be null. + /// cannot be null. /// - public static IMvcCoreBuilder AddJsonFormatters(this IMvcCoreBuilder builder, Action setup) + public static IMvcCoreBuilder AddJsonFormatters(this IMvcCoreBuilder builder, Action setup = null) { Validator.ThrowIfNull(builder); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, JsonSerializationMvcOptionsSetup>()); diff --git a/src/Cuemon.Extensions.Collections.Generic/CollectionExtensions.cs b/src/Cuemon.Extensions.Collections.Generic/CollectionExtensions.cs index 58c31d2ea..e7372de86 100644 --- a/src/Cuemon.Extensions.Collections.Generic/CollectionExtensions.cs +++ b/src/Cuemon.Extensions.Collections.Generic/CollectionExtensions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Cuemon.Collections.Generic; namespace Cuemon.Extensions.Collections.Generic @@ -17,6 +17,7 @@ public static class CollectionExtensions /// An instance of . public static PartitionerCollection ToPartitioner(this ICollection collection, int partitionSize = 128) { + Validator.ThrowIfNull(collection); return new PartitionerCollection(collection, partitionSize); } @@ -39,6 +40,8 @@ public static void AddRange(this ICollection collection, params T[] source /// The sequence of elements that should be added to . public static void AddRange(this ICollection collection, IEnumerable source) { + Validator.ThrowIfNull(collection); + Validator.ThrowIfNull(source); if (collection is List list) { list.AddRange(source); diff --git a/src/Cuemon.Extensions.Core/Wrapper.cs b/src/Cuemon.Extensions.Core/Wrapper.cs index ad13b6fb8..e0fad5bc7 100644 --- a/src/Cuemon.Extensions.Core/Wrapper.cs +++ b/src/Cuemon.Extensions.Core/Wrapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Reflection; @@ -96,8 +96,12 @@ protected Wrapper() /// /// The instance that this wrapper object represents. /// The member from where was referenced. + /// + /// is null. + /// public Wrapper(T instance, MemberInfo memberReference = null) { + Validator.ThrowIfNull(instance); _instance = instance; _instanceType = instance.GetType(); _memberReference = memberReference; diff --git a/src/Cuemon.Net/Http/HttpMethodConverter.cs b/src/Cuemon.Net/Http/HttpMethodConverter.cs index fab71a577..1e239c6f1 100644 --- a/src/Cuemon.Net/Http/HttpMethodConverter.cs +++ b/src/Cuemon.Net/Http/HttpMethodConverter.cs @@ -16,7 +16,7 @@ public static class HttpMethodConverter private static IDictionary InitStringToHttpMethodLookupTable() { - var result = new Dictionary(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in new EnumReadOnlyDictionary().Select(pair => pair.Value)) { result.Add(pair, ParserFactory.FromEnum().Parse(pair)); diff --git a/src/Cuemon.Net/Http/HttpWatcher.cs b/src/Cuemon.Net/Http/HttpWatcher.cs index d21ea7385..797cac30c 100644 --- a/src/Cuemon.Net/Http/HttpWatcher.cs +++ b/src/Cuemon.Net/Http/HttpWatcher.cs @@ -22,6 +22,7 @@ public class HttpWatcher : Watcher /// The which may be configured. public HttpWatcher(Uri location, Action setup = null) : base(Patterns.ConfigureExchange(setup)) { + Validator.ThrowIfNull(location); var options = Patterns.Configure(setup); Location = location; ClientFactory = options.ClientFactory; diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticationHandlerFeatureTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticationHandlerFeatureTest.cs new file mode 100644 index 000000000..076309c1e --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticationHandlerFeatureTest.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features.Authentication; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication +{ + public class AuthenticationHandlerFeatureTest : Test + { + public AuthenticationHandlerFeatureTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Set_ShouldPropagateAuthenticateResultAndUserToHttpFeatures() + { + var context = new DefaultHttpContext(); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "Agent") }, "scheme")); + var result = AuthenticateResult.Success(new AuthenticationTicket(principal, "scheme")); + + AuthenticationHandlerFeature.Set(result, context); + + var authenticateFeature = Assert.IsType(context.Features.Get()); + var httpAuthenticationFeature = Assert.IsType(context.Features.Get()); + + Assert.Same(authenticateFeature, httpAuthenticationFeature); + Assert.Same(result, authenticateFeature.AuthenticateResult); + Assert.Same(principal, authenticateFeature.User); + } + + [Fact] + public void UserSetter_ShouldClearAuthenticateResult() + { + var result = AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), "scheme")); + var sut = new AuthenticationHandlerFeature(result); + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + + sut.User = principal; + + Assert.Same(principal, sut.User); + Assert.Null(sut.AuthenticateResult); + } + } +} diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticatorTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticatorTest.cs new file mode 100644 index 000000000..6fd0ddbbf --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/AuthenticatorTest.cs @@ -0,0 +1,91 @@ +using System; +using System.Security; +using System.Security.Claims; +using Cuemon.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication +{ + public class AuthenticatorTest : Test + { + public AuthenticatorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Authenticate_ShouldFail_WhenSecureConnectionIsRequired() + { + var context = new DefaultHttpContext(); + + var result = Authenticator.Authenticate(context, true, (_, authorization) => authorization, PrincipalParserSuccess); + + Assert.False(result.Succeeded); + Assert.Equal("An SSL connection is required for the request.", result.Failure.Message); + } + + [Fact] + public void Authenticate_ShouldFail_WhenAuthorizationHeaderIsMissing() + { + var context = new DefaultHttpContext(); + context.Request.IsHttps = true; + + var result = Authenticator.Authenticate(context, true, (_, authorization) => authorization, PrincipalParserSuccess); + + Assert.False(result.Succeeded); + Assert.Equal("Authorization header missing.", result.Failure.Message); + } + + [Fact] + public void Authenticate_ShouldFail_WhenAuthorizationParserReturnsNull() + { + var context = new DefaultHttpContext(); + context.Request.Headers.Append(HeaderNames.Authorization, "ignored"); + + var result = Authenticator.Authenticate(context, false, (_, _) => null, PrincipalParserSuccess); + + Assert.False(result.Succeeded); + Assert.Equal("Invalid credentials.", result.Failure.Message); + } + + [Fact] + public void Authenticate_ShouldReturnPrincipal_WhenPrincipalParserSucceeds() + { + var context = new DefaultHttpContext(); + context.Request.Headers.Append(HeaderNames.Authorization, "token"); + + var result = Authenticator.Authenticate(context, false, (_, authorization) => authorization, PrincipalParserSuccess); + + Assert.True(result.Succeeded); + Assert.Equal("Agent", result.Result.Identity.Name); + } + + [Fact] + public void TryAuthenticate_ShouldCaptureThrownExceptions() + { + var context = new DefaultHttpContext(); + context.Request.Headers.Append(HeaderNames.Authorization, "token"); + + var succeeded = Authenticator.TryAuthenticate(context, false, (_, authorization) => authorization, PrincipalParserThrowing, out var principal); + + Assert.False(succeeded); + var failure = Assert.IsType(principal.Failure); + Assert.Equal("outer", failure.Message); + } + + private static bool PrincipalParserSuccess(HttpContext context, string credentials, out ConditionalValue principal) + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "Agent") }, "scheme"); + principal = new SuccessfulValue(new ClaimsPrincipal(identity)); + return true; + } + + private static bool PrincipalParserThrowing(HttpContext context, string credentials, out ConditionalValue principal) + { + principal = new UnsuccessfulValue(new SecurityException("inner")); + throw new InvalidOperationException("outer"); + } + } +} diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/Digest/DigestHashFactoryTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/Digest/DigestHashFactoryTest.cs new file mode 100644 index 000000000..57a6c9036 --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/Digest/DigestHashFactoryTest.cs @@ -0,0 +1,44 @@ +using System; +using Cuemon.Security.Cryptography; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication.Digest +{ + public class DigestHashFactoryTest : Test + { + public DigestHashFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CreateCrypto_ShouldDefaultToSha256() + { + var sut = DigestHashFactory.CreateCrypto(); + var expected = UnkeyedHashFactory.CreateCrypto(UnkeyedCryptoAlgorithm.Sha256); + + Assert.Equal(expected.GetType(), sut.GetType()); + } + + [Theory] + [InlineData(DigestCryptoAlgorithm.Md5, UnkeyedCryptoAlgorithm.Md5)] + [InlineData(DigestCryptoAlgorithm.Md5Session, UnkeyedCryptoAlgorithm.Md5)] + [InlineData(DigestCryptoAlgorithm.Sha256, UnkeyedCryptoAlgorithm.Sha256)] + [InlineData(DigestCryptoAlgorithm.Sha256Session, UnkeyedCryptoAlgorithm.Sha256)] + [InlineData(DigestCryptoAlgorithm.Sha512Slash256, UnkeyedCryptoAlgorithm.Sha512Slash256)] + [InlineData(DigestCryptoAlgorithm.Sha512Slash256Session, UnkeyedCryptoAlgorithm.Sha512Slash256)] + public void CreateCrypto_ShouldMapDigestAlgorithmsToExpectedHashImplementations(DigestCryptoAlgorithm algorithm, UnkeyedCryptoAlgorithm expectedAlgorithm) + { + var sut = DigestHashFactory.CreateCrypto(algorithm); + var expected = UnkeyedHashFactory.CreateCrypto(expectedAlgorithm); + + Assert.Equal(expected.GetType(), sut.GetType()); + } + + [Fact] + public void CreateCrypto_ShouldThrowArgumentOutOfRangeException_WhenAlgorithmIsUnsupported() + { + Assert.Throws(() => DigestHashFactory.CreateCrypto((DigestCryptoAlgorithm)42)); + } + } +} diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/MemoryNonceTrackerTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/MemoryNonceTrackerTest.cs new file mode 100644 index 000000000..bebebc48b --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/MemoryNonceTrackerTest.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Concurrent; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication +{ + public class MemoryNonceTrackerTest : Test + { + public MemoryNonceTrackerTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void MemoryNonceTracker_ShouldAddGetAndRemoveEntries() + { + using (var sut = new MemoryNonceTracker()) + { + Assert.True(sut.TryAddEntry("nonce-1", 7)); + Assert.False(sut.TryAddEntry("nonce-1", 8)); + Assert.True(sut.TryGetEntry("nonce-1", out var entry)); + Assert.Equal(7, entry.Count); + Assert.True(entry.Created <= DateTime.UtcNow); + Assert.True(sut.TryRemoveEntry("nonce-1")); + Assert.False(sut.TryRemoveEntry("nonce-1")); + Assert.False(sut.TryGetEntry("nonce-1", out _)); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MemoryNonceTracker_ShouldRejectInvalidNonces(string nonce) + { + using (var sut = new MemoryNonceTracker()) + { + Assert.ThrowsAny(() => sut.TryAddEntry(nonce, 1)); + Assert.ThrowsAny(() => sut.TryGetEntry(nonce, out _)); + Assert.ThrowsAny(() => sut.TryRemoveEntry(nonce)); + } + } + + [Fact] + public void MemoryNonceTracker_ShouldRemoveStaleEntries_WhenCleanupRuns() + { + using (var sut = new MemoryNonceTracker()) + { + var entriesField = typeof(MemoryNonceTracker).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic); + var cleanupMethod = typeof(MemoryNonceTracker).GetMethod("OnAutomatedSweepCleanup", BindingFlags.Instance | BindingFlags.NonPublic); + var entries = Assert.IsType>(entriesField.GetValue(sut)); + + entries["stale"] = new NonceTrackerEntry(1, DateTime.UtcNow.AddMinutes(-6)); + entries["fresh"] = new NonceTrackerEntry(2, DateTime.UtcNow); + + cleanupMethod.Invoke(sut, null); + + Assert.False(sut.TryGetEntry("stale", out _)); + Assert.True(sut.TryGetEntry("fresh", out var fresh)); + Assert.Equal(2, fresh.Count); + } + } + } +} diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/MiddlewareConstructorTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/MiddlewareConstructorTest.cs new file mode 100644 index 000000000..dc1f41dfe --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/MiddlewareConstructorTest.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication.Basic +{ + public class BasicAuthenticationMiddlewareConstructorTest : Test + { + public BasicAuthenticationMiddlewareConstructorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldSupportActionSetup() + { + var sut = new BasicAuthenticationMiddleware(_ => Task.CompletedTask, o => + { + o.Realm = "basic-realm"; + o.RequireSecureConnection = false; + }); + + Assert.Equal("basic-realm", sut.Options.Realm); + Assert.False(sut.Options.RequireSecureConnection); + } + } +} + +namespace Cuemon.AspNetCore.Authentication.Digest +{ + public class DigestAuthenticationMiddlewareConstructorTest : Test + { + public DigestAuthenticationMiddlewareConstructorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldSupportActionSetup() + { + var sut = new DigestAuthenticationMiddleware(_ => Task.CompletedTask, o => + { + o.Realm = "digest-realm"; + o.RequireSecureConnection = false; + }); + + Assert.Equal("digest-realm", sut.Options.Realm); + Assert.False(sut.Options.RequireSecureConnection); + } + } +} + +namespace Cuemon.AspNetCore.Authentication.Hmac +{ + public class HmacAuthenticationMiddlewareConstructorTest : Test + { + public HmacAuthenticationMiddlewareConstructorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldSupportActionSetup() + { + var sut = new HmacAuthenticationMiddleware(_ => Task.CompletedTask, o => + { + o.AuthenticationScheme = "hmac-test"; + o.RequireSecureConnection = false; + }); + + Assert.Equal("hmac-test", sut.Options.AuthenticationScheme); + Assert.False(sut.Options.RequireSecureConnection); + } + } +} diff --git a/test/Cuemon.AspNetCore.Authentication.Tests/NonceTrackerEntryTest.cs b/test/Cuemon.AspNetCore.Authentication.Tests/NonceTrackerEntryTest.cs new file mode 100644 index 000000000..3c1f5c00e --- /dev/null +++ b/test/Cuemon.AspNetCore.Authentication.Tests/NonceTrackerEntryTest.cs @@ -0,0 +1,24 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Authentication +{ + public class NonceTrackerEntryTest : Test + { + public NonceTrackerEntryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldStoreCountAndCreatedTimestamp() + { + var created = DateTime.Parse("2024-01-01T00:00:00Z").ToUniversalTime(); + + var sut = new NonceTrackerEntry(17, created); + + Assert.Equal(17, sut.Count); + Assert.Equal(created, sut.Created); + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/BreadcrumbTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/BreadcrumbTest.cs new file mode 100644 index 000000000..c60732ee8 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/BreadcrumbTest.cs @@ -0,0 +1,27 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc +{ + public class BreadcrumbTest : Test + { + public BreadcrumbTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Breadcrumb_ShouldExposeAssignedPropertyValues() + { + var sut = new Breadcrumb + { + Label = "Products", + ActionName = "Details", + ControllerName = "Catalog" + }; + + Assert.Equal("Products", sut.Label); + Assert.Equal("Details", sut.ActionName); + Assert.Equal("Catalog", sut.ControllerName); + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpCacheHeaderOptionsTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpCacheHeaderOptionsTest.cs new file mode 100644 index 000000000..3cc024391 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpCacheHeaderOptionsTest.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using Cuemon.Data.Integrity; +using Cuemon.Security; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Filters.Cacheable +{ + public class HttpEntityTagHeaderOptionsTest : Test + { + public HttpEntityTagHeaderOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void HttpEntityTagHeaderOptions_ShouldHaveDefaultValues() + { + var sut = new HttpEntityTagHeaderOptions(); + + Assert.False(sut.UseEntityTagResponseParser); + Assert.True(sut.HasEntityTagProvider); + Assert.True(sut.HasEntityTagResponseParser); + } + + [Fact] + public void EntityTagProvider_ShouldAddEntityTagHeader() + { + var sut = new HttpEntityTagHeaderOptions(); + var context = new DefaultHttpContext(); + var integrity = new FakeEntityDataIntegrity(new byte[] { 1, 2, 3 }, EntityDataIntegrityValidation.Strong); + + context.Request.Method = HttpMethods.Get; + + sut.EntityTagProvider(integrity, context); + + Assert.True(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.False(string.IsNullOrWhiteSpace(context.Response.Headers[HeaderNames.ETag].ToString())); + } + + [Fact] + public void EntityTagResponseParser_ShouldAddEntityTagHeader() + { + var sut = new HttpEntityTagHeaderOptions(); + var context = new DefaultHttpContext(); + using (var body = new MemoryStream(Encoding.UTF8.GetBytes("payload"))) + { + context.Request.Method = HttpMethods.Get; + + sut.EntityTagResponseParser(body, context.Request, context.Response); + + Assert.True(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.False(string.IsNullOrWhiteSpace(context.Response.Headers[HeaderNames.ETag].ToString())); + } + } + } + + public class HttpLastModifiedHeaderOptionsTest : Test + { + public HttpLastModifiedHeaderOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void HttpLastModifiedHeaderOptions_ShouldHaveDefaultValues() + { + var sut = new HttpLastModifiedHeaderOptions(); + + Assert.True(sut.HasLastModifiedProvider); + } + + [Fact] + public void LastModifiedProvider_ShouldAddLastModifiedHeader() + { + var sut = new HttpLastModifiedHeaderOptions(); + var context = new DefaultHttpContext(); + var timestamp = new FakeEntityDataTimestamp(DateTime.Parse("2024-01-01T00:00:00Z"), DateTime.Parse("2024-01-02T00:00:00Z")); + + context.Request.Method = HttpMethods.Get; + + sut.LastModifiedProvider(timestamp, context); + + Assert.True(context.Response.Headers.ContainsKey(HeaderNames.LastModified)); + Assert.Contains("Tue, 02 Jan 2024", context.Response.Headers[HeaderNames.LastModified].ToString()); + } + } + + internal sealed class FakeEntityDataIntegrity : IEntityDataIntegrity + { + public FakeEntityDataIntegrity(byte[] checksum, EntityDataIntegrityValidation validation) + { + Checksum = new HashResult(checksum); + Validation = validation; + } + + public EntityDataIntegrityValidation Validation { get; } + + public HashResult Checksum { get; } + } + + internal sealed class FakeEntityDataTimestamp : IEntityDataTimestamp + { + public FakeEntityDataTimestamp(DateTime created, DateTime? modified) + { + Created = created; + Modified = modified; + } + + public DateTime Created { get; } + + public DateTime? Modified { get; } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpEntityTagHeaderFilterTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpEntityTagHeaderFilterTest.cs new file mode 100644 index 000000000..6ec2c81d6 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Cacheable/HttpEntityTagHeaderFilterTest.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Filters.Cacheable +{ + public class HttpEntityTagHeaderFilterTest : Test + { + public HttpEntityTagHeaderFilterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task OnResultExecutionAsync_ShouldUseResponseParserAndRestoreCacheableObjectValue() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var payload = CacheableFactory.Create("payload", o => + { + o.TimestampProvider = _ => DateTime.UtcNow; + o.ChecksumProvider = _ => Encoding.UTF8.GetBytes("payload"); + }); + var result = new ObjectResult(payload); + var originalValue = result.Value; + var sut = new HttpEntityTagHeaderFilter(o => + { + o.EntityTagProvider = null; + o.UseEntityTagResponseParser = true; + }); + var context = new ResultExecutingContext(actionContext, new List(), result, null); + var responseBody = new MemoryStream(); + + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Body = responseBody; + + await sut.OnResultExecutionAsync(context, async () => + { + Assert.Equal("payload", result.Value); + await using (var writer = new StreamWriter(context.HttpContext.Response.Body, Encoding.UTF8, 1024, true)) + { + await writer.WriteAsync("body"); + await writer.FlushAsync(); + } + return new ResultExecutedContext(actionContext, new List(), result, null); + }); + + Assert.Same(originalValue, result.Value); + Assert.True(context.HttpContext.Response.Headers.ContainsKey(HeaderNames.ETag)); + responseBody.Position = 0; + using (var reader = new StreamReader(responseBody, Encoding.UTF8, true, 1024, true)) + { + Assert.Equal("body", reader.ReadToEnd()); + } + } + + [Fact] + public async Task OnResultExecutionAsync_ShouldPreserveStatusCode304WhenUsingResponseParser() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var result = new ObjectResult("payload"); + var sut = new HttpEntityTagHeaderFilter(o => + { + o.EntityTagProvider = null; + o.UseEntityTagResponseParser = true; + }); + var context = new ResultExecutingContext(actionContext, new List(), result, null); + var responseBody = new MemoryStream(); + + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Response.StatusCode = StatusCodes.Status304NotModified; + context.HttpContext.Response.Body = responseBody; + + await sut.OnResultExecutionAsync(context, async () => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + await using (var writer = new StreamWriter(context.HttpContext.Response.Body, Encoding.UTF8, 1024, true)) + { + await writer.WriteAsync("ignored"); + await writer.FlushAsync(); + } + return new ResultExecutedContext(actionContext, new List(), result, null); + }); + + Assert.Equal(StatusCodes.Status304NotModified, context.HttpContext.Response.StatusCode); + Assert.True(context.HttpContext.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.Equal(0, responseBody.Length); + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ConfigurableFilterBaseTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ConfigurableFilterBaseTest.cs new file mode 100644 index 000000000..f96e57634 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ConfigurableFilterBaseTest.cs @@ -0,0 +1,151 @@ +using System; +using System.Threading.Tasks; +using Cuemon.Configuration; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Filters +{ + public class ConfigurableFilterBaseTest : Test + { + public ConfigurableFilterBaseTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ConfigurableActionFilter_ShouldPopulateOptionsFromDelegateAndOptions() + { + var fromDelegate = new FakeActionFilter(o => o.Number = 42); + var fromOptions = new FakeActionFilter(Options.Create(new FakeFilterOptions() { Number = 84 })); + + Assert.Equal(42, fromDelegate.Options.Number); + Assert.Equal(84, fromOptions.Options.Number); + } + + [Fact] + public void ConfigurableAsyncActionFilter_ShouldPopulateOptionsFromDelegateAndOptions() + { + var fromDelegate = new FakeAsyncActionFilter(o => o.Number = 42); + var fromOptions = new FakeAsyncActionFilter(Options.Create(new FakeFilterOptions() { Number = 84 })); + + Assert.Equal(42, fromDelegate.Options.Number); + Assert.Equal(84, fromOptions.Options.Number); + } + + [Fact] + public void ConfigurableAsyncAuthorizationFilter_ShouldPopulateOptionsFromOptions() + { + var sut = new FakeAsyncAuthorizationFilter(Options.Create(new FakeFilterOptions() { Number = 42 })); + + Assert.Equal(42, sut.Options.Number); + } + + [Fact] + public void ConfigurableAsyncResultFilter_ShouldPopulateOptionsFromDelegateAndOptions() + { + var fromDelegate = new FakeAsyncResultFilter(o => o.Number = 42); + var fromOptions = new FakeAsyncResultFilter(Options.Create(new FakeFilterOptions() { Number = 84 })); + + Assert.Equal(42, fromDelegate.Options.Number); + Assert.Equal(84, fromOptions.Options.Number); + } + + [Fact] + public void ConfigurableFactoryFilter_ShouldPopulateOptionsAndBeNonReusableByDefault() + { + var fromDelegate = new FakeFactoryFilter(o => o.Number = 42); + var fromOptions = new FakeFactoryFilter(Options.Create(new FakeFilterOptions() { Number = 84 })); + + Assert.Equal(42, fromDelegate.Options.Number); + Assert.Equal(84, fromOptions.Options.Number); + Assert.False(fromDelegate.IsReusable); + Assert.Same(fromDelegate, fromDelegate.CreateInstance(null)); + } + + private sealed class FakeFilterOptions : IParameterObject + { + public int Number { get; set; } + } + + private sealed class FakeActionFilter : ConfigurableActionFilter + { + public FakeActionFilter(Action setup) : base(setup) + { + } + + public FakeActionFilter(IOptions setup) : base(setup) + { + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + } + } + + private sealed class FakeAsyncActionFilter : ConfigurableAsyncActionFilter + { + public FakeAsyncActionFilter(Action setup) : base(setup) + { + } + + public FakeAsyncActionFilter(IOptions setup) : base(setup) + { + } + + public override Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + return Task.CompletedTask; + } + } + + private sealed class FakeAsyncAuthorizationFilter : ConfigurableAsyncAuthorizationFilter + { + public FakeAsyncAuthorizationFilter(IOptions setup) : base(setup) + { + } + + public override Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + return Task.CompletedTask; + } + } + + private sealed class FakeAsyncResultFilter : ConfigurableAsyncResultFilter + { + public FakeAsyncResultFilter(Action setup) : base(setup) + { + } + + public FakeAsyncResultFilter(IOptions setup) : base(setup) + { + } + + public override Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + return Task.CompletedTask; + } + } + + private sealed class FakeFactoryFilter : ConfigurableFactoryFilter + { + public FakeFactoryFilter(Action setup) : base(setup) + { + } + + public FakeFactoryFilter(IOptions setup) : base(setup) + { + } + + public override IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return this; + } + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Diagnostics/MvcFaultDescriptorOptionsTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Diagnostics/MvcFaultDescriptorOptionsTest.cs new file mode 100644 index 000000000..9efe2375c --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/Diagnostics/MvcFaultDescriptorOptionsTest.cs @@ -0,0 +1,33 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Filters.Diagnostics +{ + public class MvcFaultDescriptorOptionsTest : Test + { + public MvcFaultDescriptorOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void MvcFaultDescriptorOptions_ShouldHaveDefaultValues() + { + var sut = new MvcFaultDescriptorOptions(); + + Assert.False(sut.MarkExceptionHandled); + Assert.NotNull(sut.HttpFaultResolvers); + Assert.NotNull(sut.ExceptionDescriptorResolver); + } + + [Fact] + public void MvcFaultDescriptorOptions_ShouldAllowMarkExceptionHandledToBeChanged() + { + var sut = new MvcFaultDescriptorOptions() + { + MarkExceptionHandled = true + }; + + Assert.True(sut.MarkExceptionHandled); + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ModelBinding/DisableModelBindingAttributeTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ModelBinding/DisableModelBindingAttributeTest.cs new file mode 100644 index 000000000..6b4ec13cd --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Filters/ModelBinding/DisableModelBindingAttributeTest.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Filters.ModelBinding +{ + public class DisableModelBindingAttributeTest : Test + { + public DisableModelBindingAttributeTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenValueProviderFactoryTypeIsNull() + { + Assert.Throws(() => new DisableModelBindingAttribute(null)); + } + + [Fact] + public void Constructor_ShouldThrowNotSupportedException_WhenValueProviderFactoryTypeIsUnsupported() + { + var ex = Assert.Throws(() => new DisableModelBindingAttribute(typeof(DisableModelBindingAttributeTest))); + + Assert.Equal("Only a type that implements the IValueProviderFactory interface is supported.", ex.Message); + } + + [Fact] + public async Task OnResourceExecutionAsync_ShouldRemoveMatchingValueProviderFactoryType() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var factories = new List() + { + new FormValueProviderFactory(), + new QueryStringValueProviderFactory(), + new FakeValueProviderFactory() + }; + var context = new ResourceExecutingContext(actionContext, new List(), factories); + var sut = new DisableModelBindingAttribute(typeof(FakeValueProviderFactory)); + var wasNextCalled = false; + + await sut.OnResourceExecutionAsync(context, () => + { + wasNextCalled = true; + return Task.FromResult(new ResourceExecutedContext(actionContext, new List())); + }); + + Assert.True(wasNextCalled); + Assert.DoesNotContain(context.ValueProviderFactories, factory => factory is FakeValueProviderFactory); + Assert.Contains(context.ValueProviderFactories, factory => factory is FormValueProviderFactory); + Assert.Contains(context.ValueProviderFactories, factory => factory is QueryStringValueProviderFactory); + } + + private sealed class FakeValueProviderFactory : IValueProviderFactory + { + public Task CreateValueProviderAsync(ValueProviderFactoryContext context) + { + return Task.CompletedTask; + } + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/Formatters/FormatterBaseTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/Formatters/FormatterBaseTest.cs new file mode 100644 index 000000000..07fa32568 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/Formatters/FormatterBaseTest.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.Configuration; +using Cuemon.Runtime.Serialization.Formatters; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc.Formatters +{ + public class FormatterBaseTest : Test + { + public FormatterBaseTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ConfigurableFormatter_ShouldExposeConfiguredOptions() + { + var inputOptions = new FakeFormatterOptions() { Prefix = "in:" }; + var outputOptions = new FakeFormatterOptions() { Prefix = "out:" }; + var input = new FakeConfigurableInputFormatter(inputOptions); + var output = new FakeConfigurableOutputFormatter(outputOptions); + + Assert.Same(inputOptions, input.Options); + Assert.Same(outputOptions, output.Options); + } + + [Fact] + public async Task StreamInputFormatter_ShouldReadRequestBodyAndCaptureBodyStream() + { + var context = new DefaultHttpContext(); + var formatter = new FakeStreamInputFormatter(new FakeFormatterOptions() { Prefix = "in:" }); + var metadataProvider = new EmptyModelMetadataProvider(); + var input = "hello world"; + + context.Request.ContentType = "text/plain"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input)); + + var formatterContext = new InputFormatterContext( + context, + string.Empty, + new ModelStateDictionary(), + metadataProvider.GetMetadataForType(typeof(string)), + (stream, encoding) => new StreamReader(stream, encoding)); + + Assert.True(formatter.CanRead(formatterContext)); + + var result = await formatter.ReadRequestBodyAsync(formatterContext, Encoding.UTF8); + + Assert.True(result.IsModelSet); + Assert.Equal("in:" + input, result.Model); + Assert.True(context.Items.ContainsKey(HttpRequestEvidence.HttpContextItemsKeyForCapturedRequestBody)); + Assert.IsType(context.Items[HttpRequestEvidence.HttpContextItemsKeyForCapturedRequestBody]); + } + + [Fact] + public async Task StreamOutputFormatter_ShouldWriteResponseBody() + { + var context = new DefaultHttpContext(); + var formatter = new FakeStreamOutputFormatter(new FakeFormatterOptions() { Prefix = "out:" }); + + context.Response.Body = new MemoryStream(); + + var formatterContext = new OutputFormatterWriteContext( + context, + (stream, encoding) => new StreamWriter(stream, encoding, 1024, true), + typeof(string), + "payload"); + + formatterContext.ContentType = new StringSegment("text/plain"); + + Assert.True(formatter.CanWriteResult(formatterContext)); + + await formatter.WriteResponseBodyAsync(formatterContext, Encoding.UTF8); + + context.Response.Body.Position = 0; + using (var reader = new StreamReader(context.Response.Body, Encoding.UTF8, true, 1024, true)) + { + Assert.Equal("out:payload", reader.ReadToEnd()); + } + } + + private sealed class FakeFormatterOptions : IParameterObject + { + public string Prefix { get; set; } + } + + private sealed class FakeConfigurableInputFormatter : ConfigurableInputFormatter + { + public FakeConfigurableInputFormatter(FakeFormatterOptions options) : base(options) + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + } + + protected override bool CanReadType(Type type) + { + return type == typeof(string); + } + + public override Task ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding) + { + return InputFormatterResult.SuccessAsync(context.ModelType.Name); + } + } + + private sealed class FakeConfigurableOutputFormatter : ConfigurableOutputFormatter + { + public FakeConfigurableOutputFormatter(FakeFormatterOptions options) : base(options) + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + } + + protected override bool CanWriteType(Type type) + { + return type == typeof(string); + } + + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + return Task.CompletedTask; + } + } + + private sealed class FakeStreamInputFormatter : StreamInputFormatter + { + public FakeStreamInputFormatter(FakeFormatterOptions options) : base(options) + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + } + + protected override bool CanReadType(Type type) + { + return type == typeof(string); + } + } + + private sealed class FakeStreamOutputFormatter : StreamOutputFormatter + { + public FakeStreamOutputFormatter(FakeFormatterOptions options) : base(options) + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + } + + protected override bool CanWriteType(Type type) + { + return type == typeof(string); + } + } + + private sealed class FakeStreamFormatter : StreamFormatter + { + public FakeStreamFormatter(FakeFormatterOptions options) : base(options) + { + } + + public override object Deserialize(Stream value, Type objectType) + { + value.Position = 0; + using (var reader = new StreamReader(value, Encoding.UTF8, true, 1024, true)) + { + return Options.Prefix + reader.ReadToEnd(); + } + } + + public override Stream Serialize(object source, Type objectType) + { + return new MemoryStream(Encoding.UTF8.GetBytes(Options.Prefix + source)); + } + } + } +} diff --git a/test/Cuemon.AspNetCore.Mvc.Tests/ResultClassesTest.cs b/test/Cuemon.AspNetCore.Mvc.Tests/ResultClassesTest.cs new file mode 100644 index 000000000..5f6ce0e11 --- /dev/null +++ b/test/Cuemon.AspNetCore.Mvc.Tests/ResultClassesTest.cs @@ -0,0 +1,140 @@ +using System; +using Cuemon.AspNetCore.Diagnostics; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace Cuemon.AspNetCore.Mvc +{ + public class ExceptionDescriptorResultTest : Test + { + public ExceptionDescriptorResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldWrapProblemDetails() + { + var problemDetails = new ProblemDetails() { Title = "Broken" }; + + var sut = new ExceptionDescriptorResult(problemDetails); + + var wrapper = Assert.IsAssignableFrom>(sut.Value); + Assert.Same(problemDetails, wrapper.Inner); + } + + [Fact] + public void Constructor_ShouldStoreHttpExceptionDescriptor() + { + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("fail")); + + var sut = new ExceptionDescriptorResult(descriptor); + + Assert.Same(descriptor, sut.Value); + } + } + + public class ForbiddenResultTest : Test + { + public ForbiddenResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldDefaultToStatusCode403() + { + var sut = new ForbiddenResult(); + + Assert.Equal(StatusCodes.Status403Forbidden, sut.StatusCode); + } + + [Theory] + [InlineData(StatusCodes.Status400BadRequest)] + [InlineData(StatusCodes.Status404NotFound)] + public void Constructor_ShouldAcceptClientErrorStatusCodes(int statusCode) + { + var sut = new ForbiddenResult(statusCode); + + Assert.Equal(statusCode, sut.StatusCode); + } + + [Theory] + [InlineData(StatusCodes.Status200OK)] + [InlineData(StatusCodes.Status500InternalServerError)] + public void Constructor_ShouldThrowArgumentException_WhenStatusCodeIsNotClientError(int statusCode) + { + Assert.ThrowsAny(() => new ForbiddenResult(statusCode)); + } + } + + public class ForbiddenObjectResultTest : Test + { + public ForbiddenObjectResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldDefaultToStatusCode403() + { + var payload = new { Message = "denied" }; + + var sut = new ForbiddenObjectResult(payload); + + Assert.Equal(StatusCodes.Status403Forbidden, sut.StatusCode); + Assert.Same(payload, sut.Value); + } + + [Theory] + [InlineData(StatusCodes.Status401Unauthorized)] + [InlineData(StatusCodes.Status429TooManyRequests)] + public void Constructor_ShouldAcceptClientErrorStatusCodes(int statusCode) + { + var sut = new ForbiddenObjectResult("payload", statusCode); + + Assert.Equal(statusCode, sut.StatusCode); + Assert.Equal("payload", sut.Value); + } + + [Theory] + [InlineData(StatusCodes.Status200OK)] + [InlineData(StatusCodes.Status500InternalServerError)] + public void Constructor_ShouldThrowArgumentException_WhenStatusCodeIsNotClientError(int statusCode) + { + Assert.ThrowsAny(() => new ForbiddenObjectResult("payload", statusCode)); + } + } + + public class TooManyRequestsResultTest : Test + { + public TooManyRequestsResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldDefaultToStatusCode429() + { + var sut = new TooManyRequestsResult(); + + Assert.Equal(StatusCodes.Status429TooManyRequests, sut.StatusCode); + } + } + + public class TooManyRequestsObjectResultTest : Test + { + public TooManyRequestsObjectResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldDefaultToStatusCode429() + { + var payload = new { Message = "slow down" }; + + var sut = new TooManyRequestsObjectResult(payload); + + Assert.Equal(StatusCodes.Status429TooManyRequests, sut.StatusCode); + Assert.Same(payload, sut.Value); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Configuration/DynamicCacheBustingTest.cs b/test/Cuemon.AspNetCore.Tests/Configuration/DynamicCacheBustingTest.cs new file mode 100644 index 000000000..1fa433cdc --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Configuration/DynamicCacheBustingTest.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using Codebelt.Extensions.Xunit; +using Cuemon.Configuration; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.AspNetCore.Configuration +{ + public class DynamicCacheBustingTest : Test + { + public DynamicCacheBustingTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void DynamicCacheBustingOptions_ShouldHaveDefaultValues() + { + var sut = new DynamicCacheBustingOptions(); + + Assert.Equal(CasingMethod.LowerCase, sut.PreferredCasing); + Assert.Equal(8, sut.PreferredLength); + Assert.Equal(Alphanumeric.LettersAndNumbers, sut.PreferredCharacters); + Assert.Equal(TimeSpan.FromHours(12), sut.TimeToLive); + } + + [Fact] + public void Version_ShouldReuseGeneratedValue_WhenTimeToLiveHasNotExpired() + { + var sut = new DynamicCacheBusting(Options.Create(new DynamicCacheBustingOptions + { + PreferredCasing = CasingMethod.UpperCase, + PreferredCharacters = Alphanumeric.Letters, + PreferredLength = 4, + TimeToLive = TimeSpan.FromMinutes(5) + })); + + var first = sut.Version; + var changed = sut.UtcChanged; + var second = sut.Version; + + Assert.Equal(6, first.Length); + Assert.Equal(first, second); + Assert.Equal(changed, sut.UtcChanged); + Assert.Equal(first.ToUpperInvariant(), first); + } + + [Fact] + public void Version_ShouldRefreshGeneratedValue_WhenTimeToLiveHasExpired() + { + var sut = new DynamicCacheBusting(Options.Create(new DynamicCacheBustingOptions + { + PreferredLength = 6, + TimeToLive = TimeSpan.Zero + })); + + var first = sut.Version; + var firstChanged = sut.UtcChanged; + var second = first; + for (var i = 0; i < 5 && second == first; i++) + { + Thread.Sleep(20); + second = sut.Version; + } + + Assert.NotEqual(first, second); + Assert.True(sut.UtcChanged > firstChanged); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorDecoratorExtensionsTest.cs b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorDecoratorExtensionsTest.cs new file mode 100644 index 000000000..6b35c2c9a --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorDecoratorExtensionsTest.cs @@ -0,0 +1,55 @@ +using System; +using Codebelt.Extensions.Xunit; +using Cuemon.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace Cuemon.AspNetCore.Diagnostics +{ + public class HttpExceptionDescriptorDecoratorExtensionsTest : Test + { + public HttpExceptionDescriptorDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ToProblemDetails_ShouldIncludeFailureEvidenceAndIdentifiers_WhenSensitivityIsAll() + { + var helpLink = new Uri("https://docs.cuemon.net/errors/teapot"); + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("boom"), 418, "Teapot", "Short and stout", helpLink) + { + CorrelationId = "cid-123", + RequestId = "rid-123", + TraceId = "tid-123", + Instance = new Uri("urn:request:42") + }; + descriptor.AddEvidence("request", new { Path = "/tea" }, evidence => evidence); + + var sut = Decorator.Enclose(descriptor).ToProblemDetails(FaultSensitivityDetails.All); + + Assert.Equal("Short and stout", sut.Detail); + Assert.Equal(418, sut.Status); + Assert.Equal("Teapot", sut.Title); + Assert.Equal(helpLink.ToString(), sut.Type); + Assert.Equal("urn:request:42", sut.Instance); + Assert.Equal("cid-123", Assert.IsType(sut.Extensions[nameof(HttpExceptionDescriptor.CorrelationId)])); + Assert.Equal("rid-123", Assert.IsType(sut.Extensions[nameof(HttpExceptionDescriptor.RequestId)])); + Assert.Equal("tid-123", Assert.IsType(sut.Extensions[nameof(HttpExceptionDescriptor.TraceId)])); + Assert.True(sut.Extensions.ContainsKey(nameof(FaultSensitivityDetails.Failure))); + Assert.True(sut.Extensions.ContainsKey("request")); + } + + [Fact] + public void ToProblemDetails_ShouldExcludeFailureAndEvidence_WhenSensitivityIsNone() + { + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("boom"), 500, "InternalServerError", "Unexpected failure"); + descriptor.AddEvidence("request", new { Path = "/tea" }, evidence => evidence); + + var sut = Decorator.Enclose(descriptor).ToProblemDetails(FaultSensitivityDetails.None); + + Assert.Equal("Unexpected failure", sut.Detail); + Assert.False(sut.Extensions.ContainsKey(nameof(FaultSensitivityDetails.Failure))); + Assert.False(sut.Extensions.ContainsKey("request")); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorResponseFormatterTest.cs b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorResponseFormatterTest.cs new file mode 100644 index 000000000..2e1e88a86 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpExceptionDescriptorResponseFormatterTest.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.Text.Json.Formatters; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.AspNetCore.Diagnostics +{ + public class HttpExceptionDescriptorResponseFormatterTest : Test + { + public HttpExceptionDescriptorResponseFormatterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldInitializeFromActionAndOptionsWrapper() + { + var actionFormatter = new HttpExceptionDescriptorResponseFormatter(_ => + { + }); + var options = new JsonFormatterOptions(); + var optionsFormatter = new HttpExceptionDescriptorResponseFormatter(Options.Create(options)); + + Assert.NotEmpty(actionFormatter.Options.SupportedMediaTypes); + Assert.Same(options, optionsFormatter.Options); + } + + [Fact] + public void AdjustAndPopulate_ShouldAddResponseHandlersForEverySupportedMediaType() + { + var sut = new HttpExceptionDescriptorResponseFormatter(_ => + { + }); + var handlers = new List(); + + var returned = sut + .Adjust(_ => + { + }) + .Populate((descriptor, mediaType) => new StringContent(mediaType.MediaType), handlers); + + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("boom"), 418, "Teapot", "Short and stout"); + using var response = handlers.Last().ToHttpResponseMessage(descriptor); + + Assert.Same(sut, returned); + Assert.Same(handlers, sut.ExceptionDescriptorHandlers); + Assert.Equal(sut.Options.SupportedMediaTypes.Count, handlers.Count); + Assert.Equal((HttpStatusCode)418, response.StatusCode); + Assert.Equal(handlers.Last().ContentType.MediaType, response.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpRequestEvidenceTest.cs b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpRequestEvidenceTest.cs new file mode 100644 index 000000000..54679bac8 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Diagnostics/HttpRequestEvidenceTest.cs @@ -0,0 +1,66 @@ +using System.IO; +using System.Text; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Cuemon.AspNetCore.Diagnostics +{ + public class HttpRequestEvidenceTest : Test + { + public HttpRequestEvidenceTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldCaptureRequestDetails_ForRegularFormRequests() + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("example.test"); + context.Request.Path = "/submit"; + context.Request.QueryString = new QueryString("?page=1"); + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "application/x-www-form-urlencoded"; + context.Request.Headers["X-Test"] = "true"; + context.Request.Headers["Cookie"] = "session=abc"; + context.Request.Form = new FormCollection(new System.Collections.Generic.Dictionary + { + { "name", "cuemon" } + }); + context.Items[HttpRequestEvidence.HttpContextItemsKeyForCapturedRequestBody] = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + var sut = new HttpRequestEvidence(context.Request); + + Assert.Equal("https://example.test/submit?page=1", sut.Location); + Assert.Equal(HttpMethods.Post, sut.Method); + Assert.Equal("true", sut.Headers["X-Test"]); + Assert.Equal("1", sut.Query["page"]); + Assert.Equal("cuemon", sut.Form["name"]); + Assert.Equal("abc", sut.Cookies["session"]); + Assert.Equal("payload", sut.Body); + } + + [Fact] + public void Ctor_ShouldSuppressFormAndBody_ForMultipartRequests() + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("example.test"); + context.Request.Path = "/upload"; + context.Request.Method = HttpMethods.Post; + context.Request.ContentType = "multipart/form-data; boundary=abc123"; + context.Request.Form = new FormCollection(new System.Collections.Generic.Dictionary + { + { "name", "cuemon" } + }); + context.Items[HttpRequestEvidence.HttpContextItemsKeyForCapturedRequestBody] = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + var sut = new HttpRequestEvidence(context.Request); + + Assert.Equal("https://example.test/upload", sut.Location); + Assert.Null(sut.Form); + Assert.Null(sut.Body); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/HeaderDictionaryDecoratorExtensionsTest.cs b/test/Cuemon.AspNetCore.Tests/Http/HeaderDictionaryDecoratorExtensionsTest.cs new file mode 100644 index 000000000..ce578db36 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/HeaderDictionaryDecoratorExtensionsTest.cs @@ -0,0 +1,56 @@ +using System.Net.Http; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class HeaderDictionaryDecoratorExtensionsTest : Test + { + public HeaderDictionaryDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddRange_ShouldAddOnlyMissingHeaders_WhenPredicateIsNotSpecified() + { + var target = new HeaderDictionary { { "X-Existing", "1" } }; + var source = new HeaderDictionary + { + { "X-New", "3" } + }; + + var sut = Decorator.Enclose(target).AddRange(source); + + Assert.Same(target, sut); + Assert.Equal("1", target["X-Existing"]); + Assert.Equal("3", target["X-New"]); + } + + [Fact] + public void AddOrUpdateHeader_ShouldSanitizeControlCharacters() + { + var sut = new HeaderDictionary(); + var decorator = Decorator.Enclose(sut); + + decorator.AddOrUpdateHeader("X-Test", new StringValues("value\r\n"), useAsciiEncodingConversion: false); + + Assert.Equal("value", sut["X-Test"]); + } + + [Fact] + public void AddOrUpdateHeaders_ShouldIgnoreNullArguments_AndCopyResponseHeaders() + { + var sut = new HeaderDictionary(); + var response = new HttpResponseMessage(); + response.Headers.Add("X-Test", new[] { "one", "two" }); + + HeaderDictionaryDecoratorExtensions.AddOrUpdateHeaders(null, response.Headers); + Decorator.Enclose(sut).AddOrUpdateHeaders(null); + Decorator.Enclose(sut).AddOrUpdateHeaders(response.Headers); + + Assert.Equal("one,two", sut["X-Test"]); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/HttpRequestDecoratorExtensionsTest.cs b/test/Cuemon.AspNetCore.Tests/Http/HttpRequestDecoratorExtensionsTest.cs new file mode 100644 index 000000000..3a96bc27c --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/HttpRequestDecoratorExtensionsTest.cs @@ -0,0 +1,66 @@ +using System; +using Codebelt.Extensions.Xunit; +using Cuemon.Data.Integrity; +using Cuemon.Extensions.AspNetCore.Data.Integrity; +using Cuemon.Security; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class HttpRequestDecoratorExtensionsTest : Test + { + public HttpRequestDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void IsGetOrHeadMethod_ShouldRecognizeSupportedMethods() + { + var getContext = new DefaultHttpContext(); + getContext.Request.Method = HttpMethods.Get; + var headContext = new DefaultHttpContext(); + headContext.Request.Method = HttpMethods.Head; + var postContext = new DefaultHttpContext(); + postContext.Request.Method = HttpMethods.Post; + + Assert.True(Decorator.Enclose(getContext.Request).IsGetOrHeadMethod()); + Assert.True(Decorator.Enclose(headContext.Request).IsGetOrHeadMethod()); + Assert.False(Decorator.Enclose(postContext.Request).IsGetOrHeadMethod()); + } + + [Fact] + public void IsClientSideResourceCached_ShouldRecognizeMatchingEntityTag() + { + var context = new DefaultHttpContext(); + var builder = new ChecksumBuilder(() => HashFactory.CreateFnv128()); + var entityTag = string.Concat("\"", builder.Checksum.ToHexadecimalString(), "\""); + + context.Request.Headers[HeaderNames.IfNoneMatch] = entityTag; + + Assert.True(Decorator.Enclose(context.Request).IsClientSideResourceCached(builder)); + } + + [Fact] + public void IsClientSideResourceCached_ShouldReturnFalse_WhenEntityTagHeaderIsMissing() + { + var context = new DefaultHttpContext(); + var builder = new ChecksumBuilder(() => HashFactory.CreateFnv128()); + + Assert.False(Decorator.Enclose(context.Request).IsClientSideResourceCached(builder)); + } + + [Fact] + public void IsClientSideResourceCached_ShouldRecognizeIfModifiedSinceHeader() + { + var context = new DefaultHttpContext(); + var lastModified = new DateTime(2024, 12, 24, 10, 11, 12, DateTimeKind.Utc); + + context.Request.Headers[HeaderNames.IfModifiedSince] = lastModified.ToString("R"); + + Assert.True(Decorator.Enclose(context.Request).IsClientSideResourceCached(lastModified.AddMilliseconds(900))); + Assert.False(Decorator.Enclose(context.Request).IsClientSideResourceCached(lastModified.AddSeconds(1))); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/HttpResponseDecoratorExtensionsTest.cs b/test/Cuemon.AspNetCore.Tests/Http/HttpResponseDecoratorExtensionsTest.cs new file mode 100644 index 000000000..383b32ca7 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/HttpResponseDecoratorExtensionsTest.cs @@ -0,0 +1,80 @@ +using System; +using Codebelt.Extensions.Xunit; +using Cuemon.Data.Integrity; +using Cuemon.Security; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class HttpResponseDecoratorExtensionsTest : Test + { + public HttpResponseDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddOrUpdateEntityTagHeader_ShouldSetNotModified_WhenClientCacheMatches() + { + var warmupContext = new DefaultHttpContext(); + warmupContext.Response.StatusCode = StatusCodes.Status200OK; + + Decorator.Enclose(warmupContext.Response).AddOrUpdateEntityTagHeader(warmupContext.Request, new ChecksumBuilder(() => HashFactory.CreateFnv128())); + + var expectedEntityTag = warmupContext.Response.Headers[HeaderNames.ETag].ToString(); + var context = new DefaultHttpContext(); + context.Response.StatusCode = StatusCodes.Status200OK; + context.Request.Headers[HeaderNames.IfNoneMatch] = expectedEntityTag; + + Decorator.Enclose(context.Response).AddOrUpdateEntityTagHeader(context.Request, new ChecksumBuilder(() => HashFactory.CreateFnv128())); + + Assert.Equal(StatusCodes.Status304NotModified, context.Response.StatusCode); + Assert.Equal(expectedEntityTag, context.Response.Headers[HeaderNames.ETag]); + } + + [Fact] + public void AddOrUpdateEntityTagHeader_ShouldAddWeakEntityTag_WhenRequested() + { + var context = new DefaultHttpContext(); + var builder = new ChecksumBuilder(() => HashFactory.CreateFnv128()); + + context.Response.StatusCode = StatusCodes.Status200OK; + + Decorator.Enclose(context.Response).AddOrUpdateEntityTagHeader(context.Request, builder, true); + + Assert.StartsWith("W/\"", context.Response.Headers[HeaderNames.ETag].ToString(), StringComparison.Ordinal); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public void AddOrUpdateLastModifiedHeader_ShouldSetNotModified_WhenClientCacheMatches() + { + var context = new DefaultHttpContext(); + var lastModified = new DateTime(2024, 12, 24, 10, 11, 12, DateTimeKind.Utc); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Request.Headers[HeaderNames.IfModifiedSince] = lastModified.ToString("R"); + + Decorator.Enclose(context.Response).AddOrUpdateLastModifiedHeader(context.Request, lastModified); + + Assert.Equal(StatusCodes.Status304NotModified, context.Response.StatusCode); + Assert.Equal(lastModified.ToString("R"), context.Response.Headers[HeaderNames.LastModified]); + } + + [Fact] + public void AddOrUpdateLastModifiedHeader_ShouldWriteHeader_WhenCacheDoesNotMatch() + { + var context = new DefaultHttpContext(); + var lastModified = new DateTime(2024, 12, 24, 10, 11, 12, DateTimeKind.Utc); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Request.Headers[HeaderNames.IfModifiedSince] = lastModified.AddSeconds(-2).ToString("R"); + + Decorator.Enclose(context.Response).AddOrUpdateLastModifiedHeader(context.Request, lastModified); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal(lastModified.ToString("R"), context.Response.Headers[HeaderNames.LastModified]); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/HttpStatusCodeExceptionTest.cs b/test/Cuemon.AspNetCore.Tests/Http/HttpStatusCodeExceptionTest.cs new file mode 100644 index 000000000..3431e14ef --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/HttpStatusCodeExceptionTest.cs @@ -0,0 +1,75 @@ +using System; +using System.Net; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class HttpStatusCodeExceptionTest : Test + { + public HttpStatusCodeExceptionTest(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(StatusCodes.Status400BadRequest, typeof(BadRequestException))] + [InlineData(StatusCodes.Status401Unauthorized, typeof(UnauthorizedException))] + [InlineData(StatusCodes.Status403Forbidden, typeof(ForbiddenException))] + [InlineData(StatusCodes.Status404NotFound, typeof(NotFoundException))] + [InlineData(StatusCodes.Status405MethodNotAllowed, typeof(MethodNotAllowedException))] + [InlineData(StatusCodes.Status406NotAcceptable, typeof(NotAcceptableException))] + [InlineData(StatusCodes.Status409Conflict, typeof(ConflictException))] + [InlineData(StatusCodes.Status410Gone, typeof(GoneException))] + [InlineData(StatusCodes.Status412PreconditionFailed, typeof(PreconditionFailedException))] + [InlineData(StatusCodes.Status413PayloadTooLarge, typeof(PayloadTooLargeException))] + [InlineData(StatusCodes.Status415UnsupportedMediaType, typeof(UnsupportedMediaTypeException))] + [InlineData(StatusCodes.Status428PreconditionRequired, typeof(PreconditionRequiredException))] + [InlineData(StatusCodes.Status429TooManyRequests, typeof(TooManyRequestsException))] + public void TryParse_ShouldResolveKnownStatusCodes(int statusCode, Type expectedType) + { + var inner = new InvalidOperationException("inner"); + + var result = HttpStatusCodeException.TryParse(statusCode, "custom", inner, out var sut); + + Assert.True(result); + Assert.IsType(expectedType, sut); + Assert.Equal(statusCode, sut.StatusCode); + Assert.Equal("custom", sut.Message); + Assert.Same(inner, sut.InnerException); + } + + [Fact] + public void TryParse_ShouldReturnFalseForUnknownStatusCode() + { + var result = HttpStatusCodeException.TryParse(418, out var sut); + + Assert.False(result); + Assert.Null(sut); + } + + [Fact] + public void Ctor_ShouldValidateRange_AndIncludeAdditionalInformationInToString() + { + Assert.Throws(() => new FakeHttpStatusCodeException(99, "too-low", null)); + Assert.Throws(() => new FakeHttpStatusCodeException(512, "too-high", null)); + + var sut = new FakeHttpStatusCodeException((int)HttpStatusCode.InternalServerError, "boom", new InvalidOperationException("inner")); + sut.Headers["X-Test"] = "1"; + + var result = sut.ToString(); + + Assert.Contains("Additional Information:", result); + Assert.Contains("StatusCode: 500", result); + Assert.Contains("ReasonPhrase: Internal Server Error", result); + Assert.Contains("Headers:", result); + } + + private sealed class FakeHttpStatusCodeException : HttpStatusCodeException + { + public FakeHttpStatusCodeException(int statusCode, string message, Exception innerException) : base(statusCode, message, innerException) + { + } + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/Int32DecoratorExtensionsTest.cs b/test/Cuemon.AspNetCore.Tests/Http/Int32DecoratorExtensionsTest.cs new file mode 100644 index 000000000..674f164d6 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/Int32DecoratorExtensionsTest.cs @@ -0,0 +1,35 @@ +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class Int32DecoratorExtensionsTest : Test + { + public Int32DecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void StatusCodeChecks_ShouldIdentifyMatchingRanges() + { + Assert.True(Decorator.Enclose(StatusCodes.Status100Continue).IsInformationStatusCode()); + Assert.True(Decorator.Enclose(StatusCodes.Status200OK).IsSuccessStatusCode()); + Assert.True(Decorator.Enclose(StatusCodes.Status302Found).IsRedirectionStatusCode()); + Assert.True(Decorator.Enclose(StatusCodes.Status304NotModified).IsNotModifiedStatusCode()); + Assert.True(Decorator.Enclose(StatusCodes.Status404NotFound).IsClientErrorStatusCode()); + Assert.True(Decorator.Enclose(StatusCodes.Status500InternalServerError).IsServerErrorStatusCode()); + } + + [Fact] + public void StatusCodeChecks_ShouldReturnFalseOutsideMatchingRanges() + { + Assert.False(Decorator.Enclose(StatusCodes.Status200OK).IsInformationStatusCode()); + Assert.False(Decorator.Enclose(StatusCodes.Status302Found).IsSuccessStatusCode()); + Assert.False(Decorator.Enclose(StatusCodes.Status404NotFound).IsRedirectionStatusCode()); + Assert.False(Decorator.Enclose(StatusCodes.Status200OK).IsNotModifiedStatusCode()); + Assert.False(Decorator.Enclose(StatusCodes.Status500InternalServerError).IsClientErrorStatusCode()); + Assert.False(Decorator.Enclose(StatusCodes.Status404NotFound).IsServerErrorStatusCode()); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/Http/InternalServerErrorExceptionTest.cs b/test/Cuemon.AspNetCore.Tests/Http/InternalServerErrorExceptionTest.cs new file mode 100644 index 000000000..9fb444c89 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/Http/InternalServerErrorExceptionTest.cs @@ -0,0 +1,46 @@ +using System; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Cuemon.AspNetCore.Http +{ + public class InternalServerErrorExceptionTest : Test + { + public InternalServerErrorExceptionTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldUseDefaultMessageAndStatusCode() + { + var sut = new InternalServerErrorException(); + + Assert.Equal(StatusCodes.Status500InternalServerError, sut.StatusCode); + Assert.Equal("Internal Server Error", sut.ReasonPhrase); + Assert.Equal("The server has encountered a situation it does not know how to handle.", sut.Message); + } + + [Fact] + public void Ctor_ShouldUseDefaultMessage_WhenOnlyInnerExceptionIsProvided() + { + var inner = new InvalidOperationException("boom"); + var sut = new InternalServerErrorException(inner); + + Assert.Same(inner, sut.InnerException); + Assert.Equal(StatusCodes.Status500InternalServerError, sut.StatusCode); + Assert.Equal("The server has encountered a situation it does not know how to handle.", sut.Message); + } + + [Fact] + public void Ctor_ShouldUseProvidedMessageAndInnerException() + { + var inner = new InvalidOperationException("boom"); + var sut = new InternalServerErrorException("Something unexpected happened.", inner); + + Assert.Same(inner, sut.InnerException); + Assert.Equal(StatusCodes.Status500InternalServerError, sut.StatusCode); + Assert.Equal("Something unexpected happened.", sut.Message); + } + } +} diff --git a/test/Cuemon.AspNetCore.Tests/MiddlewareTest.cs b/test/Cuemon.AspNetCore.Tests/MiddlewareTest.cs new file mode 100644 index 000000000..c99b07718 --- /dev/null +++ b/test/Cuemon.AspNetCore.Tests/MiddlewareTest.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.AspNetCore +{ + public class MiddlewareTest : Test + { + public MiddlewareTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Middleware_ShouldInvokeNextDelegate_ForAllSupportedArityVariants() + { + var calls = new List(); + RequestDelegate next = _ => + { + calls.Add("next"); + return Task.CompletedTask; + }; + var context = new DefaultHttpContext(); + + await new FakeMiddleware(next, calls).InvokeAsync(context); + await new FakeMiddleware(next, calls).InvokeAsync(context, "one"); + await new FakeMiddleware(next, calls).InvokeAsync(context, "two", 2); + await new FakeMiddleware(next, calls).InvokeAsync(context, "three", 3, true); + await new FakeMiddleware(next, calls).InvokeAsync(context, "four", 4, false, 4.5m); + await new FakeMiddleware(next, calls).InvokeAsync(context, "five", 5, true, 5.5m, Guid.Empty); + + Assert.Equal(new[] + { + "0", "next", + "1:one", "next", + "2:two:2", "next", + "3:three:3:True", "next", + $"4:four:4:False:{4.5m}", "next", + $"5:five:5:True:{5.5m}:{Guid.Empty}", "next" + }, calls); + } + + [Fact] + public async Task ConfigurableMiddleware_ShouldExposeConfiguredOptions_ForAllSupportedArityVariants() + { + var calls = new List(); + RequestDelegate next = _ => + { + calls.Add("next"); + return Task.CompletedTask; + }; + var context = new DefaultHttpContext(); + + var zeroFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o0" }), calls); + var zeroFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a0", calls); + var oneFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o1" }), calls); + var oneFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a1", calls); + var twoFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o2" }), calls); + var twoFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a2", calls); + var threeFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o3" }), calls); + var threeFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a3", calls); + var fourFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o4" }), calls); + var fourFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a4", calls); + var fiveFromOptions = new FakeConfigurableMiddleware(next, Options.Create(new FakeOptions { Message = "o5" }), calls); + var fiveFromAction = new FakeConfigurableMiddleware(next, o => o.Message = "a5", calls); + + await zeroFromOptions.InvokeAsync(context); + await zeroFromAction.InvokeAsync(context); + await oneFromOptions.InvokeAsync(context, "one"); + await oneFromAction.InvokeAsync(context, "one"); + await twoFromOptions.InvokeAsync(context, "two", 2); + await twoFromAction.InvokeAsync(context, "two", 2); + await threeFromOptions.InvokeAsync(context, "three", 3, true); + await threeFromAction.InvokeAsync(context, "three", 3, true); + await fourFromOptions.InvokeAsync(context, "four", 4, false, 4.5m); + await fourFromAction.InvokeAsync(context, "four", 4, false, 4.5m); + await fiveFromOptions.InvokeAsync(context, "five", 5, true, 5.5m, Guid.Empty); + await fiveFromAction.InvokeAsync(context, "five", 5, true, 5.5m, Guid.Empty); + + Assert.Equal("o0", zeroFromOptions.Options.Message); + Assert.Equal("a0", zeroFromAction.Options.Message); + Assert.Equal("o1", oneFromOptions.Options.Message); + Assert.Equal("a1", oneFromAction.Options.Message); + Assert.Equal("o2", twoFromOptions.Options.Message); + Assert.Equal("a2", twoFromAction.Options.Message); + Assert.Equal("o3", threeFromOptions.Options.Message); + Assert.Equal("a3", threeFromAction.Options.Message); + Assert.Equal("o4", fourFromOptions.Options.Message); + Assert.Equal("a4", fourFromAction.Options.Message); + Assert.Equal("o5", fiveFromOptions.Options.Message); + Assert.Equal("a5", fiveFromAction.Options.Message); + Assert.Equal(24, calls.Count); + } + + [Fact] + public void MiddlewareCtor_ShouldThrow_WhenNextIsNull() + { + Assert.Throws(() => new FakeMiddleware(null, new List())); + Assert.Throws(() => new FakeConfigurableMiddleware(null, o => o.Message = "nope", new List())); + } + + private sealed class FakeOptions : IParameterObject + { + public string Message { get; set; } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context) + { + _calls.Add("0"); + await Next(context); + } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T di) + { + _calls.Add($"1:{di}"); + await Next(context); + } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2) + { + _calls.Add($"2:{di1}:{di2}"); + await Next(context); + } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3) + { + _calls.Add($"3:{di1}:{di2}:{di3}"); + await Next(context); + } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3, T4 di4) + { + _calls.Add($"4:{di1}:{di2}:{di3}:{di4}"); + await Next(context); + } + } + + private sealed class FakeMiddleware : Middleware + { + private readonly IList _calls; + + public FakeMiddleware(RequestDelegate next, IList calls) : base(next) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3, T4 di4, T5 di5) + { + _calls.Add($"5:{di1}:{di2}:{di3}:{di4}:{di5}"); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context) + { + _calls.Add(Options.Message); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T di) + { + _calls.Add(Options.Message); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2) + { + _calls.Add(Options.Message); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3) + { + _calls.Add(Options.Message); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3, T4 di4) + { + _calls.Add(Options.Message); + await Next(context); + } + } + + private sealed class FakeConfigurableMiddleware : ConfigurableMiddleware + { + private readonly IList _calls; + + public FakeConfigurableMiddleware(RequestDelegate next, IOptions setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public FakeConfigurableMiddleware(RequestDelegate next, Action setup, IList calls) : base(next, setup) + { + _calls = calls; + } + + public override async Task InvokeAsync(HttpContext context, T1 di1, T2 di2, T3 di3, T4 di4, T5 di5) + { + _calls.Add(Options.Message); + await Next(context); + } + } + } +} diff --git a/test/Cuemon.Core.Tests/EradicateTest.cs b/test/Cuemon.Core.Tests/EradicateTest.cs new file mode 100644 index 000000000..3be860aa1 --- /dev/null +++ b/test/Cuemon.Core.Tests/EradicateTest.cs @@ -0,0 +1,63 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon +{ + public class EradicateTest : Test + { + public EradicateTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TrailingZeros_WithNullBytes_ThrowsArgumentNullException() + { + Assert.Throws(() => Eradicate.TrailingZeros(null)); + } + + [Fact] + public void TrailingZeros_WithSingleByte_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => Eradicate.TrailingZeros(new byte[] { 0 })); + } + + [Fact] + public void TrailingZeros_WithTrailingZeros_RemovesAllTrailingZeros() + { + var sut = Eradicate.TrailingZeros(new byte[] { 1, 2, 3, 0, 0, 0 }); + + Assert.Equal(new byte[] { 1, 2, 3 }, sut); + } + + [Fact] + public void TrailingBytes_WithNullTrailingBytes_ThrowsArgumentNullException() + { + Assert.Throws(() => Eradicate.TrailingBytes(new byte[] { 1, 2 }, null)); + } + + [Fact] + public void TrailingBytes_WithSingleInputByte_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => Eradicate.TrailingBytes(new byte[] { 1 }, new byte[] { 1 })); + } + + [Fact] + public void TrailingBytes_WithRepeatedTrailingPattern_RemovesAllTrailingPatterns() + { + var sut = Eradicate.TrailingBytes(new byte[] { 1, 2, 13, 10, 13, 10 }, new byte[] { 13, 10 }); + + Assert.Equal(new byte[] { 1, 2 }, sut); + } + + [Fact] + public void TrailingBytes_WithoutTrailingPattern_ReturnsSameInstance() + { + var bytes = new byte[] { 1, 2, 3, 4 }; + + var sut = Eradicate.TrailingBytes(bytes, new byte[] { 5 }); + + Assert.Same(bytes, sut); + } + } +} diff --git a/test/Cuemon.Core.Tests/ExceptionInsightsTest.cs b/test/Cuemon.Core.Tests/ExceptionInsightsTest.cs new file mode 100644 index 000000000..b69c97549 --- /dev/null +++ b/test/Cuemon.Core.Tests/ExceptionInsightsTest.cs @@ -0,0 +1,54 @@ +using System; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Cuemon.Diagnostics; +using Xunit; + +namespace Cuemon +{ + public class ExceptionInsightsTest : Test + { + public ExceptionInsightsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Embed_WithNullException_ThrowsArgumentNullException() + { + Assert.Throws(() => ExceptionInsights.Embed(null)); + } + + [Fact] + public void Embed_WithoutThrowerOrSnapshots_AddsFiveInsightSegments() + { + var sut = ExceptionInsights.Embed(new InvalidOperationException("boom")); + + Assert.True(sut.Data.Contains(ExceptionInsights.Key)); + Assert.Equal(5, ((string)sut.Data[ExceptionInsights.Key]).Split('.').Length); + } + + [Fact] + public void Embed_WithThreadAndEnvironmentSnapshots_ExtractsOnlyRequestedEvidence() + { + var sut = ExceptionInsights.Embed(new InvalidOperationException("boom"), MethodBase.GetCurrentMethod(), null, SystemSnapshots.CaptureThreadInfo | SystemSnapshots.CaptureEnvironmentInfo); + var descriptor = ExceptionDescriptor.Extract(sut); + + Assert.Equal(3, descriptor.Evidence.Count); + Assert.Contains("Thrower", descriptor.Evidence.Keys); + Assert.Contains("Thread", descriptor.Evidence.Keys); + Assert.Contains("Environment", descriptor.Evidence.Keys); + Assert.DoesNotContain("Process", descriptor.Evidence.Keys); + } + + [Fact] + public void SystemSnapshots_CaptureAll_ShouldContainAllIndividualFlags() + { + var sut = SystemSnapshots.CaptureAll; + + Assert.Equal(SystemSnapshots.CaptureThreadInfo | SystemSnapshots.CaptureProcessInfo | SystemSnapshots.CaptureEnvironmentInfo, sut); + Assert.True(sut.HasFlag(SystemSnapshots.CaptureThreadInfo)); + Assert.True(sut.HasFlag(SystemSnapshots.CaptureProcessInfo)); + Assert.True(sut.HasFlag(SystemSnapshots.CaptureEnvironmentInfo)); + } + } +} diff --git a/test/Cuemon.Core.Tests/GenerateTest.cs b/test/Cuemon.Core.Tests/GenerateTest.cs index ca704a793..443616e3c 100644 --- a/test/Cuemon.Core.Tests/GenerateTest.cs +++ b/test/Cuemon.Core.Tests/GenerateTest.cs @@ -278,6 +278,36 @@ private class NoOverride public int WriteOnly { set { _write = value; } } } + [Fact] + public void RangeOf_WithNullGenerator_ThrowsArgumentNullException() + { + Assert.Throws(() => Generate.RangeOf(1, null).ToList()); + } + + [Fact] + public void RandomNumber_WithMaximumExclusiveZero_ReturnsZero() + { + Assert.Equal(0, Generate.RandomNumber(0)); + } + + [Fact] + public void RandomNumber_WithEqualBounds_ReturnsLowerBound() + { + Assert.Equal(42, Generate.RandomNumber(42, 42)); + } + + [Fact] + public void RandomNumber_WithMinimumGreaterThanMaximum_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => Generate.RandomNumber(2, 1)); + } + + [Fact] + public void FixedString_WithNegativeCount_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => Generate.FixedString('*', -1)); + } + private class CallsObjectPortrayalFromToString { public int Value { get; set; } diff --git a/test/Cuemon.Core.Tests/MutableTupleFactoryTest.cs b/test/Cuemon.Core.Tests/MutableTupleFactoryTest.cs new file mode 100644 index 000000000..01cbf993b --- /dev/null +++ b/test/Cuemon.Core.Tests/MutableTupleFactoryTest.cs @@ -0,0 +1,170 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon +{ + public class MutableTupleFactoryTest : Test + { + public MutableTupleFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ActionFactory_Ctor_WithNullTuple_ThrowsArgumentNullException() + { + Assert.Throws(() => new ActionFactory>(IncrementAction, null)); + } + + [Fact] + public void ActionFactory_ExecuteMethod_InvokesDelegateAndExposesDelegateInfo() + { + var tuple = new MutableTuple(41); + var sut = new ActionFactory>(IncrementAction, tuple); + + sut.ExecuteMethod(); + + Assert.True(sut.HasDelegate); + Assert.NotNull(sut.DelegateInfo); + Assert.Equal(42, tuple.Arg1); + Assert.Contains(nameof(IncrementAction), sut.ToString()); + } + + [Fact] + public void ActionFactory_Clone_CreatesIndependentTupleCopy() + { + var sut = new ActionFactory>(IncrementAction, new MutableTuple(1)); + + var clone = Assert.IsType>>(sut.Clone()); + clone.ExecuteMethod(); + + Assert.NotSame(sut, clone); + Assert.NotSame(sut.GenericArguments, clone.GenericArguments); + Assert.Equal(1, sut.GenericArguments.Arg1); + Assert.Equal(2, clone.GenericArguments.Arg1); + } + + [Fact] + public void ActionFactory_ExecuteMethod_WithoutDelegate_ThrowsInvalidOperationException() + { + var sut = new ActionFactory>(null, new MutableTuple(1), null); + + var ex = Assert.Throws(() => sut.ExecuteMethod()); + + Assert.False(sut.HasDelegate); + Assert.Null(sut.DelegateInfo); + Assert.Equal("There is no delegate specified on the factory.", ex.Message); + } + + [Fact] + public void ActionFactory_ExecuteMethod_WithNullOriginalDelegate_ThrowsInvalidOperationException() + { + var sut = new ActionFactory>(IncrementAction, new MutableTuple(1), null); + + var ex = Assert.Throws(() => sut.ExecuteMethod()); + + Assert.False(sut.HasDelegate); + Assert.Contains("null referenced delegate wrapper", ex.Message); + } + + [Fact] + public void FuncFactory_ExecuteMethod_ReturnsResultAndExposesDelegateInfo() + { + var sut = new FuncFactory, int>(SumValues, new MutableTuple(20, 22)); + + var result = sut.ExecuteMethod(); + + Assert.True(sut.HasDelegate); + Assert.NotNull(sut.DelegateInfo); + Assert.Equal(42, result); + Assert.Contains(nameof(SumValues), sut.ToString()); + } + + [Fact] + public void FuncFactory_Clone_CreatesIndependentTupleCopy() + { + var sut = new FuncFactory, int>(SumValues, new MutableTuple(7, 8)); + + var clone = Assert.IsType, int>>(sut.Clone()); + clone.GenericArguments.Arg1 = 30; + + Assert.NotSame(sut, clone); + Assert.NotSame(sut.GenericArguments, clone.GenericArguments); + Assert.Equal(15, sut.ExecuteMethod()); + Assert.Equal(38, clone.ExecuteMethod()); + } + + [Fact] + public void FuncFactory_ExecuteMethod_WithoutDelegate_ThrowsInvalidOperationException() + { + var sut = new FuncFactory, int>(null, new MutableTuple(1), null); + + var ex = Assert.Throws(() => sut.ExecuteMethod()); + + Assert.False(sut.HasDelegate); + Assert.Equal("There is no delegate specified on the factory.", ex.Message); + } + + [Fact] + public void TesterFuncFactory_ExecuteMethod_ReturnsOutValueAndSuccessFlag() + { + var sut = new TesterFuncFactory, string, bool>(TryDescribe, new MutableTuple(42)); + + var success = sut.ExecuteMethod(out var result); + + Assert.True(success); + Assert.Equal("42", result); + Assert.True(sut.HasDelegate); + Assert.NotNull(sut.DelegateInfo); + Assert.Contains(nameof(TryDescribe), sut.ToString()); + } + + [Fact] + public void TesterFuncFactory_Clone_CreatesIndependentTupleCopy() + { + var sut = new TesterFuncFactory, string, bool>(TryDescribe, new MutableTuple(5)); + + var clone = Assert.IsType, string, bool>>(sut.Clone()); + clone.GenericArguments.Arg1 = 0; + + var originalSuccess = sut.ExecuteMethod(out var originalResult); + var cloneSuccess = clone.ExecuteMethod(out var cloneResult); + + Assert.NotSame(sut, clone); + Assert.NotSame(sut.GenericArguments, clone.GenericArguments); + Assert.True(originalSuccess); + Assert.False(cloneSuccess); + Assert.Equal("5", originalResult); + Assert.Equal("0", cloneResult); + } + + [Fact] + public void TesterFuncFactory_WithNullOriginalDelegate_StillExecutesMethod() + { + var sut = new TesterFuncFactory, string, bool>(TryDescribe, new MutableTuple(3), null); + + var success = sut.ExecuteMethod(out var result); + + Assert.False(sut.HasDelegate); + Assert.NotNull(sut.DelegateInfo); + Assert.True(success); + Assert.Equal("3", result); + } + + private static void IncrementAction(MutableTuple tuple) + { + tuple.Arg1++; + } + + private static int SumValues(MutableTuple tuple) + { + return tuple.Arg1 + tuple.Arg2; + } + + private static bool TryDescribe(MutableTuple tuple, out string result) + { + result = tuple.Arg1.ToString(); + return tuple.Arg1 > 0; + } + } +} diff --git a/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs b/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs index 6cb1caec3..5a1d67f1c 100644 --- a/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs +++ b/test/Cuemon.Core.Tests/Reflection/AssemblyContextTest.cs @@ -106,12 +106,13 @@ public void GetCurrentDomainAssemblies_ShouldRespectCustomAssemblyFilter_WhenPer [Fact] public void GetCurrentDomainAssemblies_ShouldReturnAtLeastAsManyAssemblies_WhenReferencedAssembliesIncluded() { - var withRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = true); var withoutRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = false); + var withRefs = AssemblyContext.GetCurrentDomainAssemblies(o => o.IncludeReferencedAssemblies = true); + var missing = withoutRefs.Except(withRefs).ToList(); TestOutput.WriteLine($"With referenced: {withRefs.Count}, without referenced: {withoutRefs.Count}"); - Assert.True(withRefs.Count >= withoutRefs.Count); + Assert.Empty(missing); } [Fact] diff --git a/test/Cuemon.Core.Tests/Runtime/WatcherTest.cs b/test/Cuemon.Core.Tests/Runtime/WatcherTest.cs new file mode 100644 index 000000000..f459c065a --- /dev/null +++ b/test/Cuemon.Core.Tests/Runtime/WatcherTest.cs @@ -0,0 +1,192 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Runtime +{ + public class WatcherTest : Test + { + public WatcherTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void SetUtcLastModified_WithLocalTime_ThrowsArgumentException() + { + var sut = new FakeWatcher(); + + Assert.Throws(() => sut.MarkLastModified(DateTime.Now)); + } + + [Fact] + public void SetUtcLastModified_WithFutureUtcTime_UpdatesUtcLastModified() + { + var sut = new FakeWatcher(); + var expected = DateTime.UtcNow.AddMinutes(5); + + sut.MarkLastModified(expected); + + Assert.Equal(expected, sut.UtcLastModified); + } + + [Fact] + public void OnChangedRaised_WithNoDelay_RaisesChangedImmediately() + { + var sut = new FakeWatcher(); + var expected = DateTime.UtcNow; + WatcherEventArgs eventArgs = null; + sut.Changed += (sender, args) => eventArgs = args; + sut.MarkLastModified(expected); + + sut.RaiseChangedEvent(); + + Assert.NotNull(eventArgs); + Assert.Equal(expected, eventArgs.UtcLastModified); + Assert.Equal(TimeSpan.Zero, eventArgs.Delayed); + } + + [Fact] + public void OnChangedRaised_WithDelay_RaisesChangedOnceAfterPostponement() + { + var signal = new ManualResetEventSlim(false); + try + { + var delay = TimeSpan.FromMilliseconds(100); + var sut = new FakeWatcher(o => o.DueTimeOnChanged = delay); + var expected = DateTime.UtcNow; + var count = 0; + WatcherEventArgs eventArgs = null; + sut.Changed += (sender, args) => + { + Interlocked.Increment(ref count); + eventArgs = args; + signal.Set(); + }; + sut.MarkLastModified(expected); + + sut.RaiseChangedEvent(); + sut.RaiseChangedEvent(); + + Assert.True(signal.Wait(TimeSpan.FromSeconds(5))); + Thread.Sleep(150); + Assert.Equal(1, count); + Assert.NotNull(eventArgs); + Assert.Equal(expected, eventArgs.UtcLastModified); + Assert.Equal(delay, eventArgs.Delayed); + sut.Dispose(); + } + finally + { + signal.Dispose(); + } + } + + [Fact] + public void ChangeSignaling_WithDueTimeOnly_PreservesExistingPeriodAndSignalsWatcher() + { + var signal = new ManualResetEventSlim(false); + try + { + var expectedPeriod = TimeSpan.FromMinutes(1); + var sut = new FakeWatcher(o => + { + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = expectedPeriod; + }, watcher => signal.Set()); + + sut.StartMonitoring(); + sut.ChangeSignaling(TimeSpan.Zero); + + Assert.True(signal.Wait(TimeSpan.FromSeconds(5))); + Assert.Equal(TimeSpan.Zero, sut.CurrentDueTime); + Assert.Equal(expectedPeriod, sut.CurrentPeriod); + Assert.True(sut.UtcLastSignaled > DateTime.MinValue); + sut.Dispose(); + } + finally + { + signal.Dispose(); + } + } + + [Fact] + public void ChangeSignaling_WithDueTimeAndPeriod_UpdatesSettingsAndSignalsWatcher() + { + var signal = new ManualResetEventSlim(false); + try + { + var dueTime = TimeSpan.FromMilliseconds(10); + var period = TimeSpan.FromMilliseconds(50); + var sut = new FakeWatcher(o => + { + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }, watcher => signal.Set()); + + sut.StartMonitoring(); + sut.ChangeSignaling(dueTime, period); + + Assert.True(signal.Wait(TimeSpan.FromSeconds(5))); + Assert.Equal(dueTime, sut.CurrentDueTime); + Assert.Equal(period, sut.CurrentPeriod); + Assert.True(sut.SignalCount > 0); + sut.Dispose(); + } + finally + { + signal.Dispose(); + } + } + + [Fact] + public void Dispose_AfterMonitoring_CanBeCalledMultipleTimes() + { + var sut = new FakeWatcher(o => + { + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + + sut.StartMonitoring(); + sut.Dispose(); + sut.Dispose(); + + Assert.True(sut.Disposed); + } + + private sealed class FakeWatcher : Watcher + { + private readonly Action _onSignaled; + + public FakeWatcher(Action setup = null, Action onSignaled = null) : base(setup) + { + _onSignaled = onSignaled; + } + + public TimeSpan CurrentDueTime => DueTime; + + public TimeSpan CurrentPeriod => Period; + + public int SignalCount { get; private set; } + + public void MarkLastModified(DateTime value) + { + SetUtcLastModified(value); + } + + public void RaiseChangedEvent() + { + OnChangedRaised(); + } + + protected override Task HandleSignalingAsync() + { + SignalCount++; + _onSignaled?.Invoke(this); + return Task.CompletedTask; + } + } + } +} diff --git a/test/Cuemon.Core.Tests/StringFactoryTest.cs b/test/Cuemon.Core.Tests/StringFactoryTest.cs new file mode 100644 index 000000000..8890b3e88 --- /dev/null +++ b/test/Cuemon.Core.Tests/StringFactoryTest.cs @@ -0,0 +1,99 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon +{ + public class StringFactoryTest : Test + { + public StringFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CreateHexadecimal_WithByteArrayNull_ThrowsArgumentNullException() + { + Assert.Throws(() => StringFactory.CreateHexadecimal((byte[])null)); + } + + [Fact] + public void CreateHexadecimal_WithByteArray_ReturnsLowercaseHexadecimalString() + { + var sut = StringFactory.CreateHexadecimal(new byte[] { 0x0F, 0xA0, 0x01 }); + + Assert.Equal("0fa001", sut); + } + + [Fact] + public void CreateHexadecimal_WithStringNull_ThrowsArgumentNullException() + { + Assert.Throws(() => StringFactory.CreateHexadecimal((string)null)); + } + + [Fact] + public void CreateBinaryDigits_WithByteArrayNull_ThrowsArgumentNullException() + { + Assert.Throws(() => StringFactory.CreateBinaryDigits(null)); + } + + [Fact] + public void CreateBinaryDigits_WithByteArray_ReturnsBinaryDigitString() + { + var sut = StringFactory.CreateBinaryDigits(new byte[] { 0, 1, 255 }); + + Assert.Equal("000000000000000111111111", sut); + } + + [Fact] + public void CreateUrlEncodedBase64_WithByteArrayNull_ThrowsArgumentNullException() + { + Assert.Throws(() => StringFactory.CreateUrlEncodedBase64(null)); + } + + [Fact] + public void CreateUrlEncodedBase64_WithByteArray_ReturnsUrlSafeBase64WithoutPadding() + { + var sut = StringFactory.CreateUrlEncodedBase64(new byte[] { 251, 255 }); + + Assert.Equal("-_8", sut); + } + + [Fact] + public void CreateProtocolRelativeUrl_WithNullUri_ThrowsArgumentNullException() + { + Assert.Throws(() => StringFactory.CreateProtocolRelativeUrl(null)); + } + + [Fact] + public void CreateProtocolRelativeUrl_WithRelativeUri_ThrowsArgumentException() + { + var sut = new Uri("/about", UriKind.Relative); + + Assert.Throws(() => StringFactory.CreateProtocolRelativeUrl(sut)); + } + + [Fact] + public void CreateProtocolRelativeUrl_WithAbsoluteUri_ReturnsProtocolRelativeUrl() + { + var sut = StringFactory.CreateProtocolRelativeUrl(new Uri("https://www.cuemon.net/about")); + + Assert.Equal("//www.cuemon.net/about", sut); + } + + [Fact] + public void CreateUriScheme_WithKnownScheme_ReturnsUriSchemeName() + { + var sut = StringFactory.CreateUriScheme(UriScheme.Https); + + Assert.Equal("https", sut); + } + + [Fact] + public void CreateUriScheme_WithUnknownScheme_ReturnsUndefined() + { + var sut = StringFactory.CreateUriScheme((UriScheme)int.MaxValue); + + Assert.Equal(nameof(UriScheme.Undefined), sut); + } + } +} diff --git a/test/Cuemon.Core.Tests/StringReplacePairTest.cs b/test/Cuemon.Core.Tests/StringReplacePairTest.cs index 73bb4c31a..e84cd8e25 100644 --- a/test/Cuemon.Core.Tests/StringReplacePairTest.cs +++ b/test/Cuemon.Core.Tests/StringReplacePairTest.cs @@ -114,5 +114,45 @@ public void RemoveAll_ShouldRemoveAllOccurrencesOfFragments() TestOutput.WriteLine(sut); } + + [Fact] + public void ReplaceAll_WithNullValue_ThrowsArgumentNullException() + { + Assert.Throws(() => StringReplacePair.ReplaceAll(null, "old", "new")); + } + + [Fact] + public void ReplaceAll_WithNullOldValue_ThrowsArgumentNullException() + { + Assert.Throws(() => StringReplacePair.ReplaceAll("value", null, "new")); + } + + [Fact] + public void ReplaceAll_WithNullReplacePairs_ThrowsArgumentNullException() + { + Assert.Throws(() => StringReplacePair.ReplaceAll("value", (System.Collections.Generic.IEnumerable)null)); + } + + [Fact] + public void ReplaceAll_WithNoMatches_ReturnsOriginalValue() + { + var value = "Alpha Beta Gamma"; + + var sut = StringReplacePair.ReplaceAll(value, "Delta", "Omega", StringComparison.Ordinal); + + Assert.Same(value, sut); + } + + [Fact] + public void ReplaceAll_WithMultiplePairs_UsesCurrentCultureIgnoreCaseComparison() + { + var sut = StringReplacePair.ReplaceAll("Foo and BAR and baz", new[] + { + new StringReplacePair("foo", "1"), + new StringReplacePair("bar", "2") + }, StringComparison.CurrentCultureIgnoreCase); + + Assert.Equal("1 and 2 and baz", sut); + } } } \ No newline at end of file diff --git a/test/Cuemon.Data.Tests/DataManagerAndDependencyTest.cs b/test/Cuemon.Data.Tests/DataManagerAndDependencyTest.cs new file mode 100644 index 000000000..e55f711a7 --- /dev/null +++ b/test/Cuemon.Data.Tests/DataManagerAndDependencyTest.cs @@ -0,0 +1,122 @@ +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Runtime; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Cuemon.Data +{ + public class DataManagerAndDependencyTest : Test + { + public DataManagerAndDependencyTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task AsyncAndWatcherDependency_ShouldReactToChanges() + { + var manager = CreateManager(); + var affected = await manager.ExecuteAsync(new DataStatement("UPDATE Product SET DiscontinuedDate = @expired", o => + { + o.Parameters = new IDataParameter[] { new SqliteParameter("@expired", DateTime.UtcNow) }; + })); + var scalar = await manager.ExecuteScalarAsync(new DataStatement("SELECT Name FROM Product WHERE ProductID = 1")); + + Assert.Equal(504, affected); + Assert.Equal("Adjustable Race", scalar); + + var connectionString = $"Data Source=watcher-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + using var rootConnection = new SqliteConnection(connectionString); + rootConnection.Open(); + using (var command = rootConnection.CreateCommand()) + { + command.CommandText = "CREATE TABLE Item (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL); INSERT INTO Item VALUES (1, 'Alpha');"; + command.ExecuteNonQuery(); + } + + using var watcherConnection = new SqliteConnection(connectionString); + var watcher = new TestDatabaseWatcher(watcherConnection, CreateReader, o => + { + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + var changedSignals = 0; + watcher.Changed += (_, _) => changedSignals++; + + await watcher.SignalAsync(); + Assert.NotNull(watcher.Checksum); + Assert.Equal(0, changedSignals); + Assert.Equal(ConnectionState.Closed, watcherConnection.State); + + using (var command = rootConnection.CreateCommand()) + { + command.CommandText = "UPDATE Item SET Name = 'Beta' WHERE Id = 1;"; + command.ExecuteNonQuery(); + } + + await watcher.SignalAsync(); + Assert.Equal(1, changedSignals); + Assert.Equal(ConnectionState.Closed, watcherConnection.State); + + var dependencyChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dependency = new DatabaseDependency(new Lazy(() => watcher)); + dependency.DependencyChanged += (_, e) => dependencyChanged.TrySetResult(e.UtcLastModified); + await dependency.StartAsync(); + + using (var command = rootConnection.CreateCommand()) + { + command.CommandText = "INSERT INTO Item VALUES (2, 'Gamma');"; + command.ExecuteNonQuery(); + } + + watcher.ChangeSignaling(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + var modified = await WaitOrThrowAsync(dependencyChanged.Task, TimeSpan.FromSeconds(5)); + Assert.True(dependency.HasChanged); + Assert.Equal(modified, dependency.UtcLastModified); + Assert.Throws(() => new DatabaseWatcher(null, CreateReader)); + Assert.Throws(() => new DatabaseWatcher(watcherConnection, null)); + Assert.Throws(() => new DatabaseDependency((Lazy)null)); + + static IDataReader CreateReader(IDbConnection connection) + { + var command = connection.CreateCommand(); + command.CommandText = "SELECT Id, Name FROM Item ORDER BY Id"; + return command.ExecuteReader(); + } + } + + private static async Task WaitOrThrowAsync(Task task, TimeSpan timeout) + { + var timeoutTask = Task.Delay(timeout); + if (await Task.WhenAny(task, timeoutTask) != task) { throw new TimeoutException(); } + return await task; + } + + private static Assets.FakeDataManager CreateManager() + { + var manager = new Assets.FakeDataManager(o => + { + o.LeaveConnectionOpen = true; + o.LeaveCommandOpen = true; + o.ConnectionString = $"Data Source=coverage-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; + }); + Assets.SqliteDatabase.Create(manager, null); + return manager; + } + + private sealed class TestDatabaseWatcher : DatabaseWatcher + { + public TestDatabaseWatcher(IDbConnection connection, Func readerFactory, Action setup = null) : base(connection, readerFactory, setup) + { + } + + public Task SignalAsync() + { + return HandleSignalingAsync(); + } + } + } +} diff --git a/test/Cuemon.Data.Tests/DataReaderTest.cs b/test/Cuemon.Data.Tests/DataReaderTest.cs new file mode 100644 index 000000000..18e21453d --- /dev/null +++ b/test/Cuemon.Data.Tests/DataReaderTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Specialized; +using System.Data; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data +{ + public class DataReaderTest : Test + { + public DataReaderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldExposeIDataReaderMembers() + { + var sut = new TestDataReader(); + + Assert.True(sut.Read()); + Assert.Equal(1, sut.RowCount); + Assert.True(sut.Contains("Boolean")); + Assert.Equal(true, sut["Boolean"]); + Assert.Equal(true, sut[0]); + Assert.Equal(12, sut.FieldCount); + Assert.Equal("Boolean", sut.GetName(0)); + Assert.Equal(string.Empty, sut.GetName(99)); + Assert.Equal(0, sut.GetOrdinal("boolean")); + Assert.Throws(() => sut.GetOrdinal(null)); + Assert.Throws(() => sut.GetOrdinal("missing")); + Assert.True(sut.GetBoolean(0)); + Assert.Equal((byte)8, sut.GetByte(1)); + Assert.Equal('X', sut.GetChar(2)); + Assert.Equal(new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc), sut.GetDateTime(3)); + Assert.Equal(10.5m, sut.GetDecimal(4)); + Assert.Equal(12.5d, sut.GetDouble(5)); + Assert.Equal(typeof(Guid), sut.GetFieldType(6)); + Assert.Equal(14.5f, sut.GetFloat(7)); + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), sut.GetGuid(6)); + Assert.Equal((short)16, sut.GetInt16(8)); + Assert.Equal(32, sut.GetInt32(9)); + Assert.Equal(64L, sut.GetInt64(10)); + Assert.Equal("alpha", sut.GetString(11)); + Assert.Equal(true, sut.GetValue(0)); + Assert.False(sut.IsDBNull(0)); + Assert.Equal(0L, sut.GetBytes(0, 0, Array.Empty(), 0, 0)); + Assert.Equal(0L, ((IDataRecord)sut).GetChars(0, 0, Array.Empty(), 0, 0)); + Assert.Throws(() => ((IDataRecord)sut).GetData(0)); + Assert.Equal(typeof(string).ToString(), ((IDataRecord)sut).GetDataTypeName(0)); + Assert.Equal(0, sut.Depth); + Assert.Contains("Boolean=True", sut.ToString()); + Assert.Throws(() => sut.GetValues(null)); + + var values = new object[sut.FieldCount]; + Assert.Equal(sut.FieldCount, sut.GetValues(values)); + Assert.Equal("alpha", values[11]); + + var reader = (IDataReader)sut; + Assert.Equal(-1, reader.RecordsAffected); + Assert.Null(reader.GetSchemaTable()); + Assert.False(reader.NextResult()); + reader.Close(); + Assert.True(reader.IsClosed); + } + + private sealed class TestDataReader : DataReader + { + private readonly IOrderedDictionary[] _rows; + private int _position = -1; + + protected override void OnDisposeManagedResources() + { + } + + public TestDataReader() + { + _rows = new IOrderedDictionary[] + { + new OrderedDictionary(StringComparer.OrdinalIgnoreCase) + { + { "Boolean", true }, + { "Byte", (byte)8 }, + { "Char", 'X' }, + { "DateTime", new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Utc) }, + { "Decimal", 10.5m }, + { "Double", 12.5d }, + { "Guid", Guid.Parse("11111111-1111-1111-1111-111111111111") }, + { "Single", 14.5f }, + { "Int16", (short)16 }, + { "Int32", 32 }, + { "Int64", 64L }, + { "String", "alpha" } + } + }; + } + + public override int RowCount { get; protected set; } + + protected override IOrderedDictionary NullRead => null; + + protected override IOrderedDictionary ReadNext(IOrderedDictionary columns) + { + return columns; + } + + public override bool Read() + { + _position++; + if (_position >= _rows.Length) { return false; } + SetFields(_rows[_position]); + RowCount++; + return true; + } + } + } +} diff --git a/test/Cuemon.Data.Tests/DataReaderVariantsAndExceptionsTest.cs b/test/Cuemon.Data.Tests/DataReaderVariantsAndExceptionsTest.cs new file mode 100644 index 000000000..cbd16c137 --- /dev/null +++ b/test/Cuemon.Data.Tests/DataReaderVariantsAndExceptionsTest.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data +{ + public class DataReaderVariantsAndExceptionsTest : Test + { + public DataReaderVariantsAndExceptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldCoverPublicBehavior_DsvAndXmlReadersAndExceptions() + { + using (var dsv = new DsvDataReader(new StreamReader(new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Id;Id\n1;2"))), setup: o => o.Delimiter = ";")) + { + Assert.True(dsv.Read()); + Assert.Equal(1, dsv.FieldCount); + Assert.Equal(2, dsv.GetInt32(0)); + } + + using (var xml = new Xml.XmlDataReader(System.Xml.XmlReader.Create(new StringReader("12")))) + { + Assert.True(xml.Read()); + Assert.Equal(1, xml.Depth); + Assert.Equal(1, xml.GetInt32(0)); + Assert.True(xml.Read()); + Assert.Equal(2, xml.GetInt32(0)); + Assert.False(xml.Read()); + } + + var exception = new UniqueIndexViolationException("duplicate", new InvalidOperationException("inner")); + Assert.Equal("duplicate", exception.Message); + Assert.IsType(exception.InnerException); + } + } +} diff --git a/test/Cuemon.Data.Tests/DataStatementAndOptionsTest.cs b/test/Cuemon.Data.Tests/DataStatementAndOptionsTest.cs new file mode 100644 index 000000000..b434d630c --- /dev/null +++ b/test/Cuemon.Data.Tests/DataStatementAndOptionsTest.cs @@ -0,0 +1,51 @@ +using System; +using System.Data; +using Codebelt.Extensions.Xunit; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Cuemon.Data +{ + public class DataStatementAndOptionsTest : Test + { + public DataStatementAndOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldCaptureConfiguredValues() + { + var timeout = TimeSpan.FromSeconds(12); + var parameter = new SqliteParameter("@id", 42); + DataStatement statement = "SELECT * FROM Items"; + var configured = new DataStatement("SELECT * FROM Items WHERE Id = @id", o => + { + o.Type = CommandType.StoredProcedure; + o.Timeout = timeout; + o.Parameters = new IDataParameter[] { parameter }; + }); + var statementOptions = new DataStatementOptions(); + var managerOptions = new DataManagerOptions() { ConnectionString = "Data Source=valid" }; + + Assert.Equal("SELECT * FROM Items", statement.Text); + Assert.Equal(CommandType.StoredProcedure, configured.Type); + Assert.Equal(timeout, configured.Timeout); + Assert.Single(configured.Parameters); + Assert.Equal(parameter, configured.Parameters[0]); + Assert.Equal(CommandType.Text, statementOptions.Type); + Assert.Equal(DataStatementOptions.DefaultTimeout, statementOptions.Timeout); + Assert.Empty(statementOptions.Parameters); + Assert.False(managerOptions.LeaveCommandOpen); + Assert.False(managerOptions.LeaveConnectionOpen); + Assert.Equal(CommandBehavior.CloseConnection, managerOptions.PreferredReaderBehavior); + managerOptions.ValidateOptions(); + statementOptions.ValidateOptions(); + + statementOptions.Parameters = null; + managerOptions.ConnectionString = null; + Assert.Throws(() => statementOptions.ValidateOptions()); + Assert.Throws(() => managerOptions.ValidateOptions()); + Assert.Throws(() => new DataStatement(null)); + } + } +} diff --git a/test/Cuemon.Data.Tests/DataTransferTest.cs b/test/Cuemon.Data.Tests/DataTransferTest.cs new file mode 100644 index 000000000..d9e669e4d --- /dev/null +++ b/test/Cuemon.Data.Tests/DataTransferTest.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections; +using System.Data; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data +{ + public class DataTransferTest : Test + { + public DataTransferTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldExposeRowsColumnsAndTypedValues() + { + using var reader = CreateDataTable().CreateDataReader(); + + var rows = DataTransfer.GetRows(reader); + var first = rows[0]; + var second = rows[1]; + var columns = first.Columns; + var enumerable = (IEnumerable)rows; + + Assert.Equal(2, rows.Count); + Assert.Equal(new[] { "Id", "Name", "Created", "Notes" }, rows.ColumnNames.ToArray()); + Assert.True(rows.Contains(first)); + Assert.Equal(0, rows.IndexOf(first)); + Assert.True(enumerable.GetEnumerator().MoveNext()); + + Assert.Equal(1, first.Number); + Assert.Equal(4, columns.Count); + Assert.Equal("Id", columns[0].Name); + Assert.Equal(typeof(int), columns[0].DataType); + Assert.Equal("Id", columns[0].ToString()); + Assert.Same(columns[0], columns["Id"]); + Assert.Null(columns["Missing"]); + + Assert.Equal(1, first[(DataTransferColumn)columns[0]]); + Assert.Equal("Alice", first["Name"]); + Assert.Null(first[(DataTransferColumn)null]); + Assert.Null(first["Missing"]); + Assert.Null(first[-1]); + Assert.Equal(1, first.As(0)); + Assert.Equal(1, first.As(columns[0])); + Assert.Equal(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc), first.As("Created")); + Assert.Throws(() => first.As(0)); + Assert.Null(second["Notes"]); + + var rowText = first.ToString(); + TestOutput.WriteLine(rowText); + Assert.Contains("Id=1 [Int32]", rowText); + Assert.Contains("Name=Alice [String]", rowText); + } + + [Fact] + public void ShouldValidateReaderArguments() + { + Assert.Throws(() => DataTransfer.GetRows(null)); + Assert.Throws(() => DataTransfer.GetColumns(null)); + + using var closedReader = CreateDataTable().CreateDataReader(); + closedReader.Close(); + + Assert.Throws(() => DataTransfer.GetRows(closedReader)); + Assert.Throws(() => DataTransfer.GetColumns(closedReader)); + } + + [Fact] + public void ShouldReturnColumns_WhenReaderHasBeenRead() + { + using var reader = CreateDataTable().CreateDataReader(); + + Assert.True(reader.Read()); + + var columns = DataTransfer.GetColumns(reader); + + Assert.Equal(4, columns.Count); + Assert.Equal("Created", columns[2].Name); + Assert.Equal(typeof(DateTime), columns[2].DataType); + } + + private static DataTable CreateDataTable() + { + var table = new DataTable(); + table.Columns.Add("Id", typeof(int)); + table.Columns.Add("Name", typeof(string)); + table.Columns.Add("Created", typeof(DateTime)); + table.Columns.Add("Notes", typeof(string)); + table.Rows.Add(1, "Alice", new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc), "First"); + table.Rows.Add(2, "Bob", new DateTime(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc), DBNull.Value); + return table; + } + } +} diff --git a/test/Cuemon.Data.Tests/InOperatorTest.cs b/test/Cuemon.Data.Tests/InOperatorTest.cs new file mode 100644 index 000000000..8fd7b44d1 --- /dev/null +++ b/test/Cuemon.Data.Tests/InOperatorTest.cs @@ -0,0 +1,52 @@ +using System; +using System.Data; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Cuemon.Data +{ + public class InOperatorTest : Test + { + public InOperatorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldCreateSafeResultFromExpressions() + { + var sut = new TestInOperator(() => "@p"); + + var result = sut.ToSafeResult(new[] { 4, 9 }, args => string.Join(";", args)); + var fromParams = sut.ToSafeResult(1, 2); + var parameters = result.ToParametersArray().Cast().ToArray(); + + Assert.Equal("@p", sut.ExposedPrefix); + Assert.Equal(new[] { "@p0", "@p1" }, result.Arguments.ToArray()); + Assert.Equal("@p0;@p1", result.ToString()); + Assert.Equal(2, parameters.Length); + Assert.Equal("@p0", parameters[0].ParameterName); + Assert.Equal(4L, Convert.ToInt64(parameters[0].Value)); + Assert.Equal("@p1", parameters[1].ParameterName); + Assert.Equal(9L, Convert.ToInt64(parameters[1].Value)); + Assert.Equal("@p0,@p1", fromParams.ToString()); + Assert.Equal(parameters.Length, result.Parameters.Count()); + Assert.Throws(() => sut.ToSafeResult((System.Collections.Generic.IEnumerable)null)); + } + + private sealed class TestInOperator : InOperator + { + public TestInOperator(Func prefixFactory) : base(prefixFactory) + { + } + + public string ExposedPrefix => ParameterPrefix; + + protected override IDbDataParameter ParametersSelector(int expression, int index) + { + return new SqliteParameter(string.Concat(ParameterPrefix, index), expression); + } + } + } +} diff --git a/test/Cuemon.Data.Tests/QueryBuilderTest.cs b/test/Cuemon.Data.Tests/QueryBuilderTest.cs new file mode 100644 index 000000000..c50b3f11c --- /dev/null +++ b/test/Cuemon.Data.Tests/QueryBuilderTest.cs @@ -0,0 +1,104 @@ +using System; +using System.ComponentModel; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data +{ + public class QueryBuilderTest : Test + { + public QueryBuilderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldEncodeFragmentsAndBuildQueryText() + { + Assert.Equal("Id,Name", QueryBuilder.EncodeFragment(QueryFormat.Delimited, new[] { "Id", "Name" })); + Assert.Equal("'Id','Name'", QueryBuilder.EncodeFragment(QueryFormat.DelimitedString, new[] { "Id", "Name" })); + Assert.Equal("[Id],[Name]", QueryBuilder.EncodeFragment(QueryFormat.DelimitedSquareBracket, new[] { "Id", "Name" })); + Assert.Equal("Id", QueryBuilder.EncodeFragment(QueryFormat.Delimited, new[] { "Id", "Id" }, true)); + Assert.Throws(() => QueryBuilder.EncodeFragment(QueryFormat.Delimited, null)); + Assert.Throws(() => QueryBuilder.EncodeFragment(QueryFormat.Delimited, Array.Empty())); + Assert.Throws(() => QueryBuilder.EncodeFragment((QueryFormat)999, new[] { "Id" })); + + var defaultBuilder = new DefaultTestQueryBuilder(); + var twoArgumentBuilder = new TwoArgumentTestQueryBuilder("Products"); + var sut = new TestQueryBuilder("Products"); + sut.ReadLimit = 25; + sut.EnableDirtyReads = true; + sut.EnableReadLimit = true; + sut.EnableTableAndColumnEncapsulation = true; + sut.AppendRaw("SELECT ").AppendFormatted("{0}", "*"); + + Assert.Equal(string.Empty, defaultBuilder.GetQuery(QueryType.Select)); + Assert.Equal("Select:Products", twoArgumentBuilder.GetQuery(QueryType.Select)); + Assert.Equal(25, sut.ReadLimit); + Assert.True(sut.EnableDirtyReads); + Assert.True(sut.EnableReadLimit); + Assert.True(sut.EnableTableAndColumnEncapsulation); + Assert.Equal("SELECT *", sut.ToString()); + Assert.Equal("Select:Products", sut.GetQuery(QueryType.Select)); + Assert.Equal("Update:ArchivedProducts", sut.GetQuery(QueryType.Update, "ArchivedProducts")); + Assert.Equal("Products", sut.TableName); + Assert.Single(sut.KeyColumns); + Assert.Single(sut.Columns); + Assert.Throws(() => sut.ReadLimit = 0); + Assert.Equal(0, (int)QueryFormat.Delimited); + Assert.Equal(4, (int)QueryType.Exists); + } + + private sealed class DefaultTestQueryBuilder : QueryBuilder + { + public override string GetQuery(QueryType queryType, string tableName) + { + return ToString(); + } + } + + private sealed class TwoArgumentTestQueryBuilder : QueryBuilder + { + public TwoArgumentTestQueryBuilder(string tableName) : base(tableName, new System.Collections.Generic.Dictionary() + { + { "Id", "Id" } + }) + { + } + + public override string GetQuery(QueryType queryType, string tableName) + { + return $"{queryType}:{tableName ?? TableName}"; + } + } + + private sealed class TestQueryBuilder : QueryBuilder + { + public TestQueryBuilder(string tableName) : base(tableName, new System.Collections.Generic.Dictionary() + { + { "Id", "Id" } + }, new System.Collections.Generic.Dictionary() + { + { "Name", "Name" } + }) + { + } + + public TestQueryBuilder AppendRaw(string queryFragment) + { + Append(queryFragment); + return this; + } + + public TestQueryBuilder AppendFormatted(string queryFragment, params object[] args) + { + Append(queryFragment, args); + return this; + } + + public override string GetQuery(QueryType queryType, string tableName) + { + return $"{queryType}:{tableName ?? TableName}"; + } + } + } +} diff --git a/test/Cuemon.Data.Tests/TokenBuilderTest.cs b/test/Cuemon.Data.Tests/TokenBuilderTest.cs new file mode 100644 index 000000000..7064f164f --- /dev/null +++ b/test/Cuemon.Data.Tests/TokenBuilderTest.cs @@ -0,0 +1,39 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data +{ + public class TokenBuilderTest : Test + { + public TokenBuilderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ShouldTrackTokensAndQuotedDelimiters() + { + var sut = new TokenBuilder(',', '"', 3); + + sut.Append("a,\"b,c\",d,e"); + + Assert.True(sut.IsValid); + Assert.Equal(3, sut.Tokens); + Assert.Equal(',', sut.Delimiter); + Assert.Equal('"', sut.Qualifier); + Assert.Equal("a,\"b,c\",d,", sut.ToString()); + } + + [Fact] + public void ShouldHandleNullAndInvalidStringArguments() + { + var sut = new TokenBuilder(",", "\"", 2); + + sut.Append(null).Append("onlyone"); + + Assert.False(sut.IsValid); + Assert.Equal("onlyone", sut.ToString()); + Assert.Throws(() => new TokenBuilder("::", "\"", 1)); + Assert.Throws(() => new TokenBuilder(",", "''", 1)); + } + } +} diff --git a/test/Cuemon.Diagnostics.Tests/FaultResolverTest.cs b/test/Cuemon.Diagnostics.Tests/FaultResolverTest.cs new file mode 100644 index 000000000..d15c96082 --- /dev/null +++ b/test/Cuemon.Diagnostics.Tests/FaultResolverTest.cs @@ -0,0 +1,56 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Diagnostics +{ + public class FaultResolverTest : Test + { + public FaultResolverTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TryResolveFault_ShouldReturnTrue_WhenValidatorMatches() + { + var resolver = new FaultResolver( + ex => ex is InvalidOperationException, + ex => new ExceptionDescriptor(ex, "ERR001", "Oops")); + + var exception = new InvalidOperationException("test error"); + var result = resolver.TryResolveFault(exception, out var descriptor); + + Assert.True(result); + Assert.NotNull(descriptor); + Assert.Same(exception, descriptor.Failure); + Assert.Equal("ERR001", descriptor.Code); + Assert.Equal("Oops", descriptor.Message); + } + + [Fact] + public void TryResolveFault_ShouldReturnFalse_WhenValidatorDoesNotMatch() + { + var resolver = new FaultResolver( + ex => ex is ArgumentNullException, + ex => new ExceptionDescriptor(ex, "ERR002", "Null")); + + var exception = new InvalidOperationException("test error"); + var result = resolver.TryResolveFault(exception, out var descriptor); + + Assert.False(result); + Assert.Null(descriptor); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenValidatorIsNull() + { + Assert.Throws(() => new FaultResolver(null, ex => new ExceptionDescriptor(ex, "ERR", "Err"))); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenDescriptorIsNull() + { + Assert.Throws(() => new FaultResolver(ex => true, null)); + } + } +} diff --git a/test/Cuemon.Diagnostics.Tests/TimeMeasureTest.cs b/test/Cuemon.Diagnostics.Tests/TimeMeasureTest.cs index aa55c8c60..cd6bd5de2 100644 --- a/test/Cuemon.Diagnostics.Tests/TimeMeasureTest.cs +++ b/test/Cuemon.Diagnostics.Tests/TimeMeasureTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Cuemon.Extensions; @@ -1239,5 +1240,52 @@ await TimeMeasure.WithFuncAsync(async (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, TestOutput.WriteLine(profiler.Elapsed.ToString()); TestOutput.WriteLine(profiler.Member.ToString()); } + + [Fact] + public void WithAction_ShouldInvokeCompletedCallback_WhenThresholdIsMet() + { + var callbacks = new ConcurrentBag(); + var previous = TimeMeasure.CompletedCallback; + try + { + TimeMeasure.CompletedCallback = profiler => + { + callbacks.Add(profiler); + previous?.Invoke(profiler); + }; + + var measured = TimeMeasure.WithAction(() => Thread.Sleep(TimeSpan.FromMilliseconds(50)), o => o.TimeMeasureCompletedThreshold = TimeSpan.FromMilliseconds(10)); + + Assert.Contains(callbacks, profiler => ReferenceEquals(profiler, measured)); + } + finally + { + TimeMeasure.CompletedCallback = previous; + } + } + + [Fact] + public void ToString_ShouldIncludeParameters_WhenProfilerHasData() + { + var profiler = TimeMeasure.WithAction((a1, a2) => Thread.Sleep(TimeSpan.FromMilliseconds(10)), 1, "two"); + + var result = profiler.ToString(); + + Assert.Contains("took", result); + Assert.Contains("Parameters: {", result); + foreach (var parameterName in profiler.Data.Keys) + { + Assert.Contains(parameterName + "=", result); + } + } + + [Fact] + public void WithAction_ShouldHaveStoppedProfiler_WhenCompleted() + { + var profiler = TimeMeasure.WithAction(() => Thread.Sleep(TimeSpan.FromMilliseconds(10))); + + Assert.False(profiler.IsRunning); + Assert.False(profiler.Timer.IsRunning); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.AspNetCore.Authentication.Tests/AuthorizationResponseHandlerOptionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Authentication.Tests/AuthorizationResponseHandlerOptionsTest.cs new file mode 100644 index 000000000..e685122d6 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Authentication.Tests/AuthorizationResponseHandlerOptionsTest.cs @@ -0,0 +1,76 @@ +using System; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Authentication +{ + public class AuthorizationResponseHandlerOptionsTest : Test + { + public AuthorizationResponseHandlerOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ValidateOptions_ShouldThrowInvalidOperationException_WhenFallbackResponseHandlerIsNull() + { + var options = new AuthorizationResponseHandlerOptions + { + FallbackResponseHandler = null + }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldThrowInvalidOperationException_WhenAuthorizationFailureHandlerIsNull() + { + var options = new AuthorizationResponseHandlerOptions + { + AuthorizationFailureHandler = null + }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldNotThrow_WhenAllRequiredPropertiesAreSet() + { + var options = new AuthorizationResponseHandlerOptions(); + var ex = Record.Exception(() => options.ValidateOptions()); + Assert.Null(ex); + } + + [Fact] + public void AuthorizationFailureHandler_ShouldReturnForbiddenException_WhenFailureIsNull() + { + var options = new AuthorizationResponseHandlerOptions(); + var result = options.AuthorizationFailureHandler(null); + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + + [Fact] + public void AuthorizationFailureHandler_ShouldReturnForbiddenException_WhenFailureHasFailureReasonWithMessage() + { + var options = new AuthorizationResponseHandlerOptions(); + var failure = AuthorizationFailure.Failed(new[] { new AuthorizationFailureReason(null, "Access denied due to policy.") }); + var result = options.AuthorizationFailureHandler(failure); + Assert.NotNull(result); + Assert.Contains("Access denied due to policy.", result.Message); + } + + [Fact] + public void AddInMemoryDigestAuthenticationNonceTracker_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws(() => ServiceCollectionExtensions.AddInMemoryDigestAuthenticationNonceTracker(null)); + } + + [Fact] + public void AddAuthorizationResponseHandler_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws(() => ServiceCollectionExtensions.AddAuthorizationResponseHandler(null)); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcBuilderExtensionsTest.cs new file mode 100644 index 000000000..a2e5f05a5 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcBuilderExtensionsTest.cs @@ -0,0 +1,30 @@ +using Cuemon.Extensions.Text.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; + +public class MvcBuilderExtensionsTest : Test +{ + public MvcBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddJsonFormattersOptions_ShouldConfigureOptions_WhenCalledOnIMvcBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddControllers().AddJsonFormatters(); + + var result = builder.AddJsonFormattersOptions(o => o.Settings.WriteIndented = true); + + Assert.Same(builder, result); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.True(options.Settings.WriteIndented); + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcCoreBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcCoreBuilderExtensionsTest.cs new file mode 100644 index 000000000..c4d31ab4e --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Tests/MvcCoreBuilderExtensionsTest.cs @@ -0,0 +1,66 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json.Assets; +using Cuemon.Extensions.Text.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json; + +public class MvcCoreBuilderExtensionsTest : Test +{ + public MvcCoreBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task AddJsonFormatters_ShouldRegisterFormatters_WhenCalledOnIMvcCoreBuilder() + { + using var filter = WebHostTestFactory.Create(services => + { + services.AddMvcCore() + .AddApplicationPart(typeof(FakeController).Assembly) + .AddJsonFormatters(); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + }); + + var client = filter.Host.GetTestClient(); + var result = await client.GetAsync("/fake"); + var model = await result.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(model); + + Assert.Contains("\"date\":", model); + Assert.Contains("\"temperatureC\":", model); + Assert.Contains("\"temperatureF\":", model); + Assert.Contains("\"summary\":", model); + + Assert.Equal(StatusCodes.Status200OK, (int)result.StatusCode); + Assert.Equal(HttpMethod.Get, result.RequestMessage.Method); + } + + [Fact] + public void AddJsonFormattersOptions_ShouldConfigureOptions_WhenCalledOnIMvcCoreBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddMvcCore(); + + var result = builder.AddJsonFormattersOptions(o => o.Settings.WriteIndented = true); + + Assert.Same(builder, result); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.True(options.Settings.WriteIndented); + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcBuilderExtensionsTest.cs new file mode 100644 index 000000000..f7b22db6a --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcBuilderExtensionsTest.cs @@ -0,0 +1,31 @@ +using Codebelt.Extensions.Xunit; +using Cuemon.Xml.Serialization.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; + +public class MvcBuilderExtensionsTest : Test +{ + public MvcBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddXmlFormattersOptions_ShouldConfigureOptions_WhenCalledOnIMvcBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddControllers().AddXmlFormatters(); + + var result = builder.AddXmlFormattersOptions(o => { }); + + Assert.Same(builder, result); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.NotNull(options); + Assert.NotNull(options.Settings); + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcCoreBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcCoreBuilderExtensionsTest.cs new file mode 100644 index 000000000..b8bb9e644 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Tests/MvcCoreBuilderExtensionsTest.cs @@ -0,0 +1,67 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml.Assets; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.Xml.Serialization.Formatters; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml; + +public class MvcCoreBuilderExtensionsTest : Test +{ + public MvcCoreBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task AddXmlFormatters_ShouldRegisterFormatters_WhenCalledOnIMvcCoreBuilder() + { + using var filter = WebHostTestFactory.Create(services => + { + services.AddMvcCore() + .AddApplicationPart(typeof(FakeController).Assembly) + .AddXmlFormatters(); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + }); + + var client = filter.Host.GetTestClient(); + var result = await client.GetAsync("/fake"); + var model = await result.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(model); + + Assert.Contains("", model); + Assert.Contains("", model); + Assert.Contains("", model); + Assert.Contains("", model); + Assert.Contains("", model); + + Assert.Equal(StatusCodes.Status200OK, (int)result.StatusCode); + Assert.Equal(HttpMethod.Get, result.RequestMessage.Method); + } + + [Fact] + public void AddXmlFormattersOptions_ShouldConfigureOptions_WhenCalledOnIMvcCoreBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddMvcCore(); + + var result = builder.AddXmlFormattersOptions(o => o.SynchronizeWithXmlConvert = true); + + Assert.Same(builder, result); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.True(options.SynchronizeWithXmlConvert); + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/FilterCollectionExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/FilterCollectionExtensionsTest.cs new file mode 100644 index 000000000..75196ec79 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/FilterCollectionExtensionsTest.cs @@ -0,0 +1,77 @@ +using Cuemon.AspNetCore.Mvc.Filters.Cacheable; +using Cuemon.AspNetCore.Mvc.Filters.Diagnostics; +using Cuemon.AspNetCore.Mvc.Filters.Headers; +using Cuemon.AspNetCore.Mvc.Filters.Throttling; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Mvc.Filters; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Filters +{ + public class FilterCollectionExtensionsTest : Test + { + public FilterCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddHttpCacheable_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddHttpCacheable(); + + Assert.Equal(1, sut.Count); + } + + [Fact] + public void AddFaultDescriptor_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddFaultDescriptor(); + + Assert.Equal(1, sut.Count); + } + + [Fact] + public void AddServerTiming_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddServerTiming(); + + Assert.Equal(1, sut.Count); + } + + [Fact] + public void AddUserAgentSentinel_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddUserAgentSentinel(); + + Assert.Equal(1, sut.Count); + } + + [Fact] + public void AddThrottlingSentinel_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddThrottlingSentinel(); + + Assert.Equal(1, sut.Count); + } + + [Fact] + public void AddApiKeySentinel_ShouldAddOneFilterToCollection() + { + var sut = new FilterCollection(); + + sut.AddApiKeySentinel(); + + Assert.Equal(1, sut.Count); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/MvcBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/MvcBuilderExtensionsTest.cs new file mode 100644 index 000000000..1b2e812dd --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Mvc.Tests/Filters/MvcBuilderExtensionsTest.cs @@ -0,0 +1,132 @@ +using System; +using Cuemon.AspNetCore.Http.Headers; +using Cuemon.AspNetCore.Http.Throttling; +using Cuemon.AspNetCore.Mvc.Filters.Cacheable; +using Cuemon.AspNetCore.Mvc.Filters.Diagnostics; +using Cuemon.Diagnostics; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Mvc.Filters +{ + public class MvcBuilderExtensionsTest : Test + { + public MvcBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws("builder", () => MvcBuilderExtensions.AddApiKeySentinelOptions(null)); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws("builder", () => MvcBuilderExtensions.AddThrottlingSentinelOptions(null)); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws("builder", () => MvcBuilderExtensions.AddUserAgentSentinelOptions(null)); + } + + [Fact] + public void AddFaultDescriptorOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws("builder", () => MvcBuilderExtensions.AddFaultDescriptorOptions(null)); + } + + [Fact] + public void AddHttpCacheableOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws("builder", () => MvcBuilderExtensions.AddHttpCacheableOptions(null)); + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldReturnBuilder_WithDefaultOptions() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddApiKeySentinelOptions(); + + Assert.Same(builder, result); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldReturnBuilder_WithDefaultOptions() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddThrottlingSentinelOptions(); + + Assert.Same(builder, result); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldReturnBuilder_WithDefaultOptions() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddUserAgentSentinelOptions(); + + Assert.Same(builder, result); + } + + [Fact] + public void AddFaultDescriptorOptions_ShouldReturnBuilder_WithDefaultOptions() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddFaultDescriptorOptions(); + + Assert.Same(builder, result); + } + + [Fact] + public void AddHttpCacheableOptions_ShouldReturnBuilder_WithDefaultOptions() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddHttpCacheableOptions(); + + Assert.Same(builder, result); + } + + [Fact] + public void AddFaultDescriptorOptions_ShouldReturnBuilder_WithCustomSensitivityDetails() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddFaultDescriptorOptions(o => + { + o.SensitivityDetails = FaultSensitivityDetails.All; + }); + + Assert.Same(builder, result); + } + + [Fact] + public void AddHttpCacheableOptions_ShouldReturnBuilder_WithCustomCacheControl() + { + var services = new ServiceCollection(); + var builder = services.AddMvc(); + + var result = builder.AddHttpCacheableOptions(o => + { + o.CacheControl.MaxAge = TimeSpan.FromMinutes(5); + }); + + Assert.Same(builder, result); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ApplicationBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ApplicationBuilderExtensionsTest.cs new file mode 100644 index 000000000..2329ce9bb --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ApplicationBuilderExtensionsTest.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.AspNetCore.Http; +using Cuemon.Extensions.AspNetCore.Text.Json.Formatters; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Diagnostics +{ + public class ApplicationBuilderExtensionsTest : Test + { + public ApplicationBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task UseServerTiming_ShouldWriteServerTimingHeader() + { + using var response = await WebHostTestFactory.RunAsync( + services => services.AddServerTiming(), + app => + { + app.Use(async (context, next) => + { + context.RequestServices.GetRequiredService().AddServerTiming("db", TimeSpan.FromMilliseconds(12)); + await next(); + }); + app.UseServerTiming(); + app.Run(context => context.Response.WriteAsync("ok")); + }); + + var header = Assert.Single(response.Headers.GetValues(ServerTiming.HeaderName)); + + Assert.StartsWith("db;dur=", header, StringComparison.Ordinal); + } + + [Fact] + public async Task UseFaultDescriptorExceptionHandler_ShouldSerializeHttpExceptionDescriptor_AsJson() + { + using var response = await WebHostTestFactory.RunAsync( + services => + { + services.AddFaultDescriptorOptions(); + services.AddJsonExceptionResponseFormatter(); + }, + app => + { + app.UseFaultDescriptorExceptionHandler(); + app.Run(_ => throw new NotFoundException()); + }, + responseFactory: client => + { + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + return client.GetAsync("/"); + }); + + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); + Assert.Contains("\"status\": 404", body, StringComparison.Ordinal); + Assert.Contains("\"code\": \"NotFound\"", body, StringComparison.Ordinal); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceCollectionExtensionsTest.cs index 5b7a2ca2a..484675d2e 100644 --- a/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceCollectionExtensionsTest.cs @@ -1,7 +1,10 @@ -using System.Linq; +using System; +using System.Linq; using Cuemon.AspNetCore.Diagnostics; using Codebelt.Extensions.Xunit; +using Cuemon.Diagnostics; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Cuemon.Extensions.AspNetCore.Diagnostics @@ -21,5 +24,88 @@ public void AddServerTiming_ShouldAddToServiceCollection_HavingLifetimeOfScope() Assert.True(sut2.Lifetime == ServiceLifetime.Scoped); Assert.True(sut2.ImplementationType == typeof(ServerTiming)); } + + [Fact] + public void AddServerTiming_ShouldRegisterCustomImplementationAndConfiguredOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddServerTiming(o => o.TimeMeasureCompletedThreshold = TimeSpan.FromMilliseconds(42)); + + var descriptor = Assert.Single(services.Where(sd => sd.ServiceType == typeof(IServerTiming))); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + Assert.Equal(typeof(FakeServerTiming), descriptor.ImplementationType); + Assert.Equal(TimeSpan.FromMilliseconds(42), options.TimeMeasureCompletedThreshold); + } + + [Fact] + public void AddFaultDescriptorOptions_ShouldCopySensitivityDetailsToExceptionDescriptorOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddFaultDescriptorOptions(o => + { + o.SensitivityDetails = FaultSensitivityDetails.Failure; + o.RootHelpLink = new Uri("https://docs.cuemon.net/errors"); + o.UseBaseException = true; + }); + + var provider = services.BuildServiceProvider(); + var faultOptions = provider.GetRequiredService>().Value; + var exceptionOptions = provider.GetRequiredService>().Value; + + Assert.Equal(FaultSensitivityDetails.Failure, faultOptions.SensitivityDetails); + Assert.Equal(new Uri("https://docs.cuemon.net/errors"), faultOptions.RootHelpLink); + Assert.True(faultOptions.UseBaseException); + Assert.Equal(FaultSensitivityDetails.Failure, exceptionOptions.SensitivityDetails); + } + + [Fact] + public void AddExceptionDescriptorOptionsAndPostConfigureAll_ShouldApplyToAllRegisteredOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddExceptionDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.None); + services.AddFaultDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.Evidence); + services.PostConfigureAllExceptionDescriptorOptions(o => o.SensitivityDetails = FaultSensitivityDetails.All); + + var provider = services.BuildServiceProvider(); + var faultOptions = provider.GetRequiredService>().Value; + var exceptionOptions = provider.GetRequiredService>().Value; + + Assert.Equal(FaultSensitivityDetails.All, faultOptions.SensitivityDetails); + Assert.Equal(FaultSensitivityDetails.All, exceptionOptions.SensitivityDetails); + } + + private sealed class FakeServerTiming : IServerTiming + { + private readonly ServerTiming _inner = new ServerTiming(); + + public System.Collections.Generic.IEnumerable Metrics => _inner.Metrics; + + public IServerTiming AddServerTiming(string name) + { + _inner.AddServerTiming(name); + return this; + } + + public IServerTiming AddServerTiming(string name, TimeSpan duration) + { + _inner.AddServerTiming(name, duration); + return this; + } + + public IServerTiming AddServerTiming(string name, TimeSpan duration, string description) + { + _inner.AddServerTiming(name, duration, description); + return this; + } + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceProviderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceProviderExtensionsTest.cs index 13dbe669f..61c2d88d3 100644 --- a/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceProviderExtensionsTest.cs +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Diagnostics/ServiceProviderExtensionsTest.cs @@ -1,9 +1,14 @@ using System; using System.Linq; +using System.Net.Http; +using Cuemon.AspNetCore.Diagnostics; using Cuemon.Extensions.AspNetCore.Text.Json.Formatters; using Cuemon.Extensions.AspNetCore.Xml.Formatters; +using Cuemon.Extensions.Text.Json.Formatters; +using Cuemon.Xml.Serialization.Formatters; using Codebelt.Extensions.Xunit; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Cuemon.Extensions.AspNetCore.Diagnostics @@ -41,5 +46,26 @@ public void GetExceptionResponseFormatters_ShouldGetAllRegisteredServicesOf_IExc JsonFormatterOptions -> application/problem+json """.ReplaceLineEndings(), formattersAndResponseHandlers.ToDelimitedString(o => o.Delimiter = Environment.NewLine)); } + + [Fact] + public void GetExceptionResponseFormatters_ShouldSupportImplementationInstanceAndFactoryRegistrations() + { + var services = new ServiceCollection(); + var instance = new HttpExceptionDescriptorResponseFormatter(Options.Create(new JsonFormatterOptions())) + .Populate((_, mediaType) => new StringContent(mediaType.MediaType)); + + services.AddSingleton(instance.GetType(), instance); + services.AddSingleton(typeof(HttpExceptionDescriptorResponseFormatter), _ => + new HttpExceptionDescriptorResponseFormatter(Options.Create(new XmlFormatterOptions())) + .Populate((_, mediaType) => new StringContent(mediaType.MediaType))); + + var serviceProvider = services.BuildServiceProvider(); + + var formatters = serviceProvider.GetExceptionResponseFormatters().ToList(); + + Assert.Equal(2, formatters.Count); + Assert.Same(instance, formatters.Single(formatter => formatter.GetType() == instance.GetType())); + Assert.Contains(formatters, formatter => formatter.GetType() == typeof(HttpExceptionDescriptorResponseFormatter)); + } } } diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ApplicationBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ApplicationBuilderExtensionsTest.cs new file mode 100644 index 000000000..d8f561ad2 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ApplicationBuilderExtensionsTest.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http.Headers +{ + public class ApplicationBuilderExtensionsTest : Test + { + public ApplicationBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task UseCorrelationIdentifier_ShouldAddConfiguredHeader() + { + using var response = await WebHostTestFactory.RunAsync(pipelineSetup: app => + { + app.UseCorrelationIdentifier(o => o.HeaderName = "X-Test-Correlation"); + app.Run(context => context.Response.WriteAsync("ok")); + }); + + Assert.True(response.Headers.TryGetValues("X-Test-Correlation", out var headerValues)); + Assert.False(string.IsNullOrWhiteSpace(System.Linq.Enumerable.Single(headerValues))); + } + + [Fact] + public async Task UseRequestIdentifier_ShouldAddConfiguredHeader() + { + using var response = await WebHostTestFactory.RunAsync(pipelineSetup: app => + { + app.UseRequestIdentifier(o => o.HeaderName = "X-Test-Request"); + app.Run(context => context.Response.WriteAsync("ok")); + }); + + Assert.True(response.Headers.TryGetValues("X-Test-Request", out var headerValues)); + Assert.False(string.IsNullOrWhiteSpace(System.Linq.Enumerable.Single(headerValues))); + } + + [Fact] + public async Task UseUserAgentSentinel_ShouldAllowKnownUserAgent() + { + using var host = WebHostTestFactory.Create( + services => + { + services.AddRouting(); + services.AddUserAgentSentinelOptions(o => + { + o.RequireUserAgentHeader = true; + o.ValidateUserAgentHeader = true; + o.AllowedUserAgents.Add("Cuemon-Agent"); + }); + }, + app => + { + app.UseUserAgentSentinel(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapGet("/", () => "ok")); + }); + + var client = host.Host.GetTestClient(); + client.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "Cuemon-Agent"); + using var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task UseApiKeySentinel_ShouldAllowKnownApiKey() + { + using var host = WebHostTestFactory.Create( + services => + { + services.AddRouting(); + services.AddApiKeySentinelOptions(o => + { + o.AllowedKeys.Add("known-key"); + o.HeaderName = "X-Test-Key"; + }); + }, + app => + { + app.UseApiKeySentinel(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapGet("/", () => "ok")); + }); + + var client = host.Host.GetTestClient(); + client.DefaultRequestHeaders.Add("X-Test-Key", "known-key"); + using var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task UseCacheControl_ShouldAddCacheHeaders() + { + using var response = await WebHostTestFactory.RunAsync(pipelineSetup: app => + { + app.UseCacheControl(); + app.Run(context => context.Response.WriteAsync("payload")); + }); + + Assert.True(response.Headers.Contains(HeaderNames.CacheControl)); + Assert.NotNull(response.Content.Headers.Expires); + } + + [Fact] + public async Task UseVaryAccept_ShouldAddVaryHeader() + { + using var response = await WebHostTestFactory.RunAsync(pipelineSetup: app => + { + app.UseVaryAccept(); + app.Run(context => context.Response.WriteAsync("payload")); + }); + + Assert.True(response.Headers.TryGetValues(HeaderNames.Vary, out var values)); + Assert.Contains(HeaderNames.Accept, values); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/EntityTagCacheableValidatorTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/EntityTagCacheableValidatorTest.cs new file mode 100644 index 000000000..5b603a9ce --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/EntityTagCacheableValidatorTest.cs @@ -0,0 +1,51 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http.Headers +{ + public class EntityTagCacheableValidatorTest : Test + { + public EntityTagCacheableValidatorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task ProcessAsync_ShouldAddEntityTagHeader_WhenServerTimingIsUnavailable() + { + var sut = new EntityTagCacheableValidator(); + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().BuildServiceProvider(); + var body = new MemoryStream(Encoding.UTF8.GetBytes("Hello world!")); + + await sut.ProcessAsync(context, body); + + Assert.True(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.False(string.IsNullOrWhiteSpace(context.Response.Headers[HeaderNames.ETag])); + } + + [Fact] + public async Task ProcessAsync_ShouldRecordServerTimingMetric_WhenServerTimingIsAvailable() + { + var sut = new EntityTagCacheableValidator(); + var serverTiming = new ServerTiming(); + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().AddSingleton(serverTiming).BuildServiceProvider(); + var body = new MemoryStream(Encoding.UTF8.GetBytes("Hello world!")); + + await sut.ProcessAsync(context, body); + + var metric = Assert.Single(serverTiming.Metrics); + Assert.Equal("entity-tag", metric.Name); + Assert.True(metric.Duration.HasValue); + Assert.True(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ServiceCollectionExtensionsTest.cs new file mode 100644 index 000000000..c1f6dc7c0 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Headers/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,182 @@ +using System; +using System.Linq; +using System.Net; +using Cuemon.AspNetCore.Http.Headers; +using Cuemon.Net.Http; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http.Headers +{ + public class ServiceCollectionExtensionsTest : Test + { + public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws("services", () => ServiceCollectionExtensions.AddApiKeySentinelOptions(null)); + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldRegisterApiKeySentinelOptions_WithDefaultValues() + { + var sut = new ServiceCollection(); + + sut.AddApiKeySentinelOptions(); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + TestOutput.WriteLine($"IConfigureOptions registrations: {count}"); + + Assert.True(count >= 1); + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldRegisterApiKeySentinelOptions_WithCustomValues() + { + var sut = new ServiceCollection(); + + sut.AddApiKeySentinelOptions(o => + { + o.AllowedKeys.Add("my-api-key"); + o.HeaderName = "X-My-Api-Key"; + }); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + Assert.True(count >= 1); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws("services", () => ServiceCollectionExtensions.AddUserAgentSentinelOptions(null)); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldRegisterUserAgentSentinelOptions_WithDefaultValues() + { + var sut = new ServiceCollection(); + + sut.AddUserAgentSentinelOptions(); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + TestOutput.WriteLine($"IConfigureOptions registrations: {count}"); + + Assert.True(count >= 1); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldRegisterUserAgentSentinelOptions_WithCustomValues() + { + var sut = new ServiceCollection(); + + sut.AddUserAgentSentinelOptions(o => + { + o.AllowedUserAgents.Add("MyApp/1.0"); + o.RequireUserAgentHeader = true; + }); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + Assert.True(count >= 1); + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldResolveConfiguredOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddApiKeySentinelOptions(o => + { + o.AllowedKeys.Add("known-key"); + o.HeaderName = "X-Test-Key"; + o.GenericClientStatusCode = HttpStatusCode.Unauthorized; + o.GenericClientMessage = "custom"; + o.ForbiddenMessage = "forbidden"; + o.UseGenericResponse = true; + }); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Contains("known-key", options.AllowedKeys); + Assert.Equal("X-Test-Key", options.HeaderName); + Assert.Equal(HttpStatusCode.Unauthorized, options.GenericClientStatusCode); + Assert.Equal("custom", options.GenericClientMessage); + Assert.Equal("forbidden", options.ForbiddenMessage); + Assert.True(options.UseGenericResponse); + Assert.NotNull(options.ResponseHandler); + } + + [Fact] + public void AddApiKeySentinelOptions_ShouldResolveDefaultOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddApiKeySentinelOptions(); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Equal(HttpHeaderNames.XApiKey, options.HeaderName); + Assert.Equal(HttpStatusCode.BadRequest, options.GenericClientStatusCode); + Assert.Equal("The requirements of the request was not met.", options.GenericClientMessage); + Assert.Equal("The API key specified was rejected.", options.ForbiddenMessage); + Assert.NotNull(options.AllowedKeys); + Assert.NotNull(options.ResponseHandler); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldResolveConfiguredOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddUserAgentSentinelOptions(o => + { + o.AllowedUserAgents.Add("Cuemon-Agent"); + o.BadRequestMessage = "bad"; + o.ForbiddenMessage = "forbidden"; + o.RequireUserAgentHeader = true; + o.ValidateUserAgentHeader = true; + o.UseGenericResponse = true; + }); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Contains("Cuemon-Agent", options.AllowedUserAgents); + Assert.Equal("bad", options.BadRequestMessage); + Assert.Equal("forbidden", options.ForbiddenMessage); + Assert.True(options.RequireUserAgentHeader); + Assert.True(options.ValidateUserAgentHeader); + Assert.True(options.UseGenericResponse); + Assert.NotNull(options.ResponseHandler); + } + + [Fact] + public void AddUserAgentSentinelOptions_ShouldResolveDefaultOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddUserAgentSentinelOptions(); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Equal("The requirements of the request was not met.", options.BadRequestMessage); + Assert.Equal("The User-Agent specified was rejected.", options.ForbiddenMessage); + Assert.NotNull(options.AllowedUserAgents); + Assert.False(options.RequireUserAgentHeader); + Assert.False(options.ValidateUserAgentHeader); + Assert.False(options.UseGenericResponse); + Assert.NotNull(options.ResponseHandler); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/HttpExceptionDescriptorResponseFormatterExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/HttpExceptionDescriptorResponseFormatterExtensionsTest.cs new file mode 100644 index 000000000..4358144b5 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/HttpExceptionDescriptorResponseFormatterExtensionsTest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cuemon.AspNetCore.Diagnostics; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http +{ + public class HttpExceptionDescriptorResponseFormatterExtensionsTest : Test + { + public HttpExceptionDescriptorResponseFormatterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void SelectExceptionDescriptorHandlers_ShouldThrowArgumentNullException_WhenFormattersIsNull() + { + Assert.Throws("formatters", () => + { + HttpExceptionDescriptorResponseFormatterExtensions.SelectExceptionDescriptorHandlers(null).ToList(); + }); + } + + [Fact] + public void SelectExceptionDescriptorHandlers_ShouldReturnEmptySequence_WhenFormattersIsEmpty() + { + var sut = new List(); + + var result = sut.SelectExceptionDescriptorHandlers().ToList(); + + Assert.Empty(result); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ApplicationBuilderExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ApplicationBuilderExtensionsTest.cs new file mode 100644 index 000000000..c7f6e0f6a --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ApplicationBuilderExtensionsTest.cs @@ -0,0 +1,50 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Http.Throttling; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http.Throttling +{ + public class ApplicationBuilderExtensionsTest : Test + { + public ApplicationBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task UseThrottlingSentinel_ShouldThrottleSecondRequest_WhenQuotaIsExceeded() + { + using var host = WebHostTestFactory.Create( + services => + { + services.AddRouting(); + services.AddMemoryThrottlingCache(); + services.AddThrottlingSentinelOptions(o => + { + o.ContextResolver = _ => "global"; + o.Quota = new ThrottleQuota(1, TimeSpan.FromMinutes(1)); + }); + }, + app => + { + app.UseThrottlingSentinel(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapGet("/", () => "ok")); + }); + + var client = host.Host.GetTestClient(); + using var first = await client.GetAsync("/"); + + var second = await Assert.ThrowsAsync(() => client.GetAsync("/")); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal("Throttling rate limit quota violation. Quota limit exceeded.", second.Message); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ServiceCollectionExtensionsTest.cs new file mode 100644 index 000000000..933919dc5 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Http/Throttling/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,165 @@ +using System; +using System.Linq; +using Cuemon.AspNetCore.Http.Headers; +using Cuemon.AspNetCore.Http.Throttling; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Http.Throttling +{ + public class ServiceCollectionExtensionsTest : Test + { + public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddThrottlingCache_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws("services", () => ServiceCollectionExtensions.AddThrottlingCache(null)); + } + + [Fact] + public void AddMemoryThrottlingCache_ShouldRegisterIThrottlingCacheAsSingleton() + { + var sut = new ServiceCollection(); + + sut.AddMemoryThrottlingCache(); + + var descriptor = sut.FirstOrDefault(sd => sd.ServiceType == typeof(IThrottlingCache)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + [Fact] + public void AddThrottlingCache_ShouldRegisterIThrottlingCacheAsSingleton() + { + var sut = new ServiceCollection(); + + sut.AddThrottlingCache(); + + var descriptor = sut.FirstOrDefault(sd => sd.ServiceType == typeof(IThrottlingCache)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + Assert.Throws("services", () => ServiceCollectionExtensions.AddThrottlingSentinelOptions(null)); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldRegisterThrottlingSentinelOptions_WithDefaultValues() + { + var sut = new ServiceCollection(); + + sut.AddThrottlingSentinelOptions(); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + TestOutput.WriteLine($"IConfigureOptions registrations: {count}"); + + Assert.True(count >= 1); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldRegisterThrottlingSentinelOptions_WithCustomValues() + { + var sut = new ServiceCollection(); + + sut.AddThrottlingSentinelOptions(o => + { + o.TooManyRequestsMessage = "Slow down!"; + o.UseRetryAfterHeader = true; + }); + + var count = sut.Count(sd => sd.ServiceType == typeof(IConfigureOptions)); + + Assert.True(count >= 1); + } + + [Fact] + public void AddMemoryThrottlingCache_ShouldResolveMemoryThrottlingCache() + { + var services = new ServiceCollection(); + + services.AddMemoryThrottlingCache(); + + var cache = services.BuildServiceProvider().GetRequiredService(); + + Assert.IsType(cache); + } + + [Fact] + public void AddThrottlingCache_ShouldResolveRegisteredCache() + { + var services = new ServiceCollection(); + + services.AddThrottlingCache(); + + var cache = services.BuildServiceProvider().GetRequiredService(); + + Assert.IsType(cache); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldResolveDefaultOptions() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.AddThrottlingSentinelOptions(); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Equal("RateLimit-Limit", options.RateLimitHeaderName); + Assert.Equal("RateLimit-Remaining", options.RateLimitRemainingHeaderName); + Assert.Equal("RateLimit-Reset", options.RateLimitResetHeaderName); + Assert.Equal(RetryConditionScope.DeltaSeconds, options.RateLimitResetScope); + Assert.Equal(RetryConditionScope.DeltaSeconds, options.RetryAfterScope); + Assert.True(options.UseRetryAfterHeader); + Assert.Equal("Throttling rate limit quota violation. Quota limit exceeded.", options.TooManyRequestsMessage); + Assert.NotNull(options.ResponseHandler); + } + + [Fact] + public void AddThrottlingSentinelOptions_ShouldResolveConfiguredOptions() + { + var services = new ServiceCollection(); + Func contextResolver = _ => "global"; + var quota = new ThrottleQuota(1, TimeSpan.FromMinutes(1)); + + services.AddOptions(); + services.AddThrottlingSentinelOptions(o => + { + o.ContextResolver = contextResolver; + o.Quota = quota; + o.RateLimitHeaderName = "X-Limit"; + o.RateLimitRemainingHeaderName = "X-Remaining"; + o.RateLimitResetHeaderName = "X-Reset"; + o.RateLimitResetScope = RetryConditionScope.HttpDate; + o.RetryAfterScope = RetryConditionScope.HttpDate; + o.TooManyRequestsMessage = "Slow down!"; + o.UseRetryAfterHeader = false; + }); + + var options = services.BuildServiceProvider().GetRequiredService>().Value; + + Assert.Same(contextResolver, options.ContextResolver); + Assert.Same(quota, options.Quota); + Assert.Equal("X-Limit", options.RateLimitHeaderName); + Assert.Equal("X-Remaining", options.RateLimitRemainingHeaderName); + Assert.Equal("X-Reset", options.RateLimitResetHeaderName); + Assert.Equal(RetryConditionScope.HttpDate, options.RateLimitResetScope); + Assert.Equal(RetryConditionScope.HttpDate, options.RetryAfterScope); + Assert.Equal("Slow down!", options.TooManyRequestsMessage); + Assert.False(options.UseRetryAfterHeader); + Assert.NotNull(options.ResponseHandler); + } + } +} diff --git a/test/Cuemon.Extensions.AspNetCore.Tests/Xml/Converters/XmlConverterExtensionsTest.cs b/test/Cuemon.Extensions.AspNetCore.Tests/Xml/Converters/XmlConverterExtensionsTest.cs new file mode 100644 index 000000000..c9260f484 --- /dev/null +++ b/test/Cuemon.Extensions.AspNetCore.Tests/Xml/Converters/XmlConverterExtensionsTest.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.Diagnostics; +using Cuemon.Xml.Serialization.Converters; +using Cuemon.Xml.Serialization.Formatters; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Cuemon.Extensions.AspNetCore.Xml.Converters +{ + public class XmlConverterExtensionsTest : Test + { + public XmlConverterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddProblemDetailsConverter_ShouldAddTwoConverters() + { + var sut = new List(); + sut.AddProblemDetailsConverter(); + Assert.Equal(2, sut.Count); + } + + [Fact] + public void AddProblemDetailsConverter_ShouldSerializeProblemDetails_WithAllFields() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddProblemDetailsConverter()); + var pd = new ProblemDetails { Type = "https://example.com/error", Title = "Bad Request", Status = 400, Detail = "Something went wrong", Instance = "/api/test" }; + using var stream = formatter.Serialize(pd, typeof(ProblemDetails)); + var xml = new StreamReader(stream, Encoding.UTF8).ReadToEnd(); + TestOutput.WriteLine(xml); + Assert.Contains("ProblemDetails", xml); + Assert.Contains("Bad Request", xml); + Assert.Contains("400", xml); + Assert.Contains("Something went wrong", xml); + } + + [Fact] + public void AddProblemDetailsConverter_ShouldSerializeProblemDetails_WithNullOptionalFields() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddProblemDetailsConverter()); + var pd = new ProblemDetails { Status = 500 }; + using var stream = formatter.Serialize(pd, typeof(ProblemDetails)); + var xml = new StreamReader(stream, Encoding.UTF8).ReadToEnd(); + TestOutput.WriteLine(xml); + Assert.Contains("ProblemDetails", xml); + Assert.Contains("500", xml); + Assert.DoesNotContain("", xml); + } + + [Fact] + public void AddHttpExceptionDescriptorConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddHttpExceptionDescriptorConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddHttpExceptionDescriptorConverter_ShouldSerializeHttpExceptionDescriptor() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddHttpExceptionDescriptorConverter()); + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("Test error"), 400, "BadRequest", "A bad request occurred"); + using var stream = formatter.Serialize(descriptor, typeof(HttpExceptionDescriptor)); + var xml = new StreamReader(stream, Encoding.UTF8).ReadToEnd(); + TestOutput.WriteLine(xml); + Assert.Contains("HttpExceptionDescriptor", xml); + Assert.Contains("BadRequest", xml); + Assert.Contains("A bad request occurred", xml); + } + + [Fact] + public void AddHttpExceptionDescriptorConverter_ShouldIncludeFailure_WhenSensitivityDetailsHasFailureFlag() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddHttpExceptionDescriptorConverter(s => s.SensitivityDetails = FaultSensitivityDetails.Failure)); + var descriptor = new HttpExceptionDescriptor(new InvalidOperationException("Test error"), 500, "InternalServerError", "An error occurred"); + using var stream = formatter.Serialize(descriptor, typeof(HttpExceptionDescriptor)); + var xml = new StreamReader(stream, Encoding.UTF8).ReadToEnd(); + TestOutput.WriteLine(xml); + Assert.Contains("Failure", xml); + Assert.Contains("Test error", xml); + } + + [Fact] + public void AddStringValuesConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddStringValuesConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddStringValuesConverter_ShouldSerializeSingleValue() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddStringValuesConverter()); + var xml = SerializeInsideRoot(formatter, new StringValues("hello"), typeof(StringValues)); + TestOutput.WriteLine(xml); + Assert.Contains("hello", xml); + } + + [Fact] + public void AddStringValuesConverter_ShouldSerializeMultipleValues() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddStringValuesConverter()); + var values = new StringValues(new[] { "val1", "val2", "val3" }); + var xml = SerializeInsideRoot(formatter, values, typeof(StringValues)); + TestOutput.WriteLine(xml); + Assert.Contains("val1", xml); + Assert.Contains("val2", xml); + Assert.Contains("val3", xml); + Assert.Contains("", xml); + } + + [Fact] + public void AddHeaderDictionaryConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddHeaderDictionaryConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddHeaderDictionaryConverter_ShouldSerializeHeaders() + { + var formatter = new XmlFormatter(o => + { + o.Settings.Converters.AddHeaderDictionaryConverter(); + o.Settings.Converters.AddStringValuesConverter(); + }); + var headers = new HeaderDictionary { { "Content-Type", "application/xml" }, { "X-Custom", "test-value" } }; + var xml = SerializeInsideRoot(formatter, headers, typeof(IHeaderDictionary)); + TestOutput.WriteLine(xml); + Assert.Contains("Header", xml); + Assert.Contains("Content-Type", xml); + Assert.Contains("application/xml", xml); + } + + [Fact] + public void AddQueryCollectionConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddQueryCollectionConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddQueryCollectionConverter_ShouldSerializeQueryCollection() + { + var formatter = new XmlFormatter(o => + { + o.Settings.Converters.AddQueryCollectionConverter(); + o.Settings.Converters.AddStringValuesConverter(); + }); + var query = new QueryCollection(new Dictionary { { "id", new StringValues("42") }, { "name", new StringValues("test") } }); + var xml = SerializeInsideRoot(formatter, query, typeof(IQueryCollection)); + TestOutput.WriteLine(xml); + Assert.Contains("Field", xml); + Assert.Contains("id", xml); + Assert.Contains("42", xml); + } + + [Fact] + public void AddFormCollectionConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddFormCollectionConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddFormCollectionConverter_ShouldSerializeFormCollection() + { + var formatter = new XmlFormatter(o => + { + o.Settings.Converters.AddFormCollectionConverter(); + o.Settings.Converters.AddStringValuesConverter(); + }); + var form = new FormCollection(new Dictionary { { "username", new StringValues("alice") } }); + var xml = SerializeInsideRoot(formatter, form, typeof(IFormCollection)); + TestOutput.WriteLine(xml); + Assert.Contains("Field", xml); + Assert.Contains("username", xml); + Assert.Contains("alice", xml); + } + + [Fact] + public void AddCookieCollectionConverter_ShouldAddOneConverter() + { + var sut = new List(); + sut.AddCookieCollectionConverter(); + Assert.Single(sut); + } + + [Fact] + public void AddCookieCollectionConverter_ShouldSerializeCookieCollection() + { + var formatter = new XmlFormatter(o => o.Settings.Converters.AddCookieCollectionConverter()); + var cookies = new FakeCookieCollection(new Dictionary { { "session", "abc123" }, { "pref", "dark" } }); + var xml = SerializeInsideRoot(formatter, cookies, typeof(IRequestCookieCollection)); + TestOutput.WriteLine(xml); + Assert.Contains("Field", xml); + Assert.Contains("session", xml); + Assert.Contains("abc123", xml); + } + + // These converters write child elements, designed to be called from within a parent element. + // This helper wraps the serialization in a root element so the converter lambdas work correctly. + private static string SerializeInsideRoot(XmlFormatter formatter, object value, Type type) + { + using var ms = new MemoryStream(); + var writerSettings = new XmlWriterSettings { ConformanceLevel = ConformanceLevel.Fragment }; + using (var xmlWriter = XmlWriter.Create(ms, writerSettings)) + { + xmlWriter.WriteStartElement("Root"); + formatter.SerializeToWriter(xmlWriter, value, type); + xmlWriter.WriteEndElement(); + } + ms.Position = 0; + return new StreamReader(ms, Encoding.UTF8).ReadToEnd(); + } + + private class FakeCookieCollection : IRequestCookieCollection + { + private readonly Dictionary _cookies; + + public FakeCookieCollection(Dictionary cookies) => _cookies = cookies; + + public string this[string key] => _cookies.TryGetValue(key, out var v) ? v : null; + public int Count => _cookies.Count; + public ICollection Keys => _cookies.Keys; + public bool ContainsKey(string key) => _cookies.ContainsKey(key); + public bool TryGetValue(string key, out string value) => _cookies.TryGetValue(key, out value); + public IEnumerator> GetEnumerator() => _cookies.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _cookies.GetEnumerator(); + } + } +} diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/CollectionExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/CollectionExtensionsTest.cs index 8ff155060..7e38884e9 100644 --- a/test/Cuemon.Extensions.Collections.Generic.Tests/CollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/CollectionExtensionsTest.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Codebelt.Extensions.Xunit; @@ -101,5 +102,38 @@ public void AddRange_ShouldAddNineItems_ByParamsArray_UsingGenericCollection() i => Assert.Equal(8, i), i => Assert.Equal(9, i)); } + [Fact] + public void ToPartitioner_ShouldThrowArgumentNullException_WhenCollectionIsNull() + { + ICollection sut = null; + + Assert.Throws(() => sut.ToPartitioner()); + } + + [Fact] + public void AddRange_ShouldThrowArgumentNullException_WhenCollectionIsNull() + { + ICollection sut = null; + + Assert.Throws(() => sut.AddRange(1, 2, 3)); + } + + [Fact] + public void AddRange_ShouldThrowArgumentNullException_WhenSourceArrayIsNull() + { + var sut = new List(); + int[] source = null; + + Assert.Throws(() => sut.AddRange(source)); + } + + [Fact] + public void AddRange_ShouldThrowArgumentNullException_WhenSourceSequenceIsNull() + { + var sut = new Collection(); + IEnumerable source = null; + + Assert.Throws(() => sut.AddRange(source)); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/DictionaryExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/DictionaryExtensionsTest.cs index b0f5bc227..aa0768618 100644 --- a/test/Cuemon.Extensions.Collections.Generic.Tests/DictionaryExtensionsTest.cs +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/DictionaryExtensionsTest.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Codebelt.Extensions.Xunit; using Xunit; @@ -170,5 +171,143 @@ public void AddOrUpdate_ShouldUpdateExistingItem() Assert.Equal("Cuemon", sut1[1]); Assert.True(sut1.Count == 10); } + [Fact] + public void CopyTo_ShouldCopyEntriesToDestination() + { + var sut1 = new Dictionary + { + { 1, "Cuemon" }, + { 2, "Geekle" } + }; + var sut2 = sut1.CopyTo(new Dictionary()); + + Assert.Equal(sut1, sut2); + } + + [Fact] + public void CopyTo_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.CopyTo(new Dictionary())); + } + + [Fact] + public void CopyTo_ShouldThrowArgumentNullException_WhenDestinationIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.CopyTo(null)); + } + + [Fact] + public void CopyToWithCopier_ShouldCopyEntriesUsingCustomCopier() + { + var sut1 = new Dictionary + { + { 1, "Cuemon" }, + { 2, "Geekle" } + }; + var sut2 = sut1.CopyTo(new Dictionary(), (source, destination) => + { + foreach (var item in source) + { + destination[item.Key] = item.Value.ToUpperInvariant(); + } + }); + + Assert.Equal("CUEMON", sut2[1]); + Assert.Equal("GEEKLE", sut2[2]); + } + + [Fact] + public void CopyToWithCopier_ShouldThrowArgumentNullException_WhenCopierIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.CopyTo(new Dictionary(), null)); + } + +#if NETSTANDARD2_0_OR_GREATER + [Fact] + public void GetValueOrDefault_ShouldThrowArgumentNullException_WhenDictionaryIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.GetValueOrDefault("key")); + } + + [Fact] + public void GetValueOrDefault_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.GetValueOrDefault(null)); + } +#endif + + [Fact] + public void GetValueOrDefault_ShouldThrowArgumentNullException_WhenDefaultProviderIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.GetValueOrDefault("key", null)); + } + + [Fact] + public void TryGetValueOrFallback_ShouldThrowArgumentNullException_WhenDictionaryIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.TryGetValueOrFallback("key", keys => keys.First(), out _)); + } + + [Fact] + public void ToEnumerable_ShouldThrowArgumentNullException_WhenDictionaryIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.ToEnumerable()); + } + + [Fact] + public void TryAddWithCondition_ShouldThrowArgumentNullException_WhenDictionaryIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.TryAdd("key", "value", _ => true)); + } + + [Fact] + public void TryAddWithCondition_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.TryAdd(null, "value", _ => true)); + } + + [Fact] + public void TryAddWithCondition_ShouldThrowArgumentNullException_WhenConditionIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.TryAdd("key", "value", null)); + } + + [Fact] + public void AddOrUpdate_ShouldThrowArgumentNullException_WhenDictionaryIsNull() + { + IDictionary sut = null; + + Assert.Throws(() => sut.AddOrUpdate("key", "value")); + } + + [Fact] + public void AddOrUpdate_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var sut = new Dictionary(); + + Assert.Throws(() => sut.AddOrUpdate(null, "value")); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/EnumerableExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/EnumerableExtensionsTest.cs index 2e5be518e..cff94f145 100644 --- a/test/Cuemon.Extensions.Collections.Generic.Tests/EnumerableExtensionsTest.cs +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/EnumerableExtensionsTest.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Codebelt.Extensions.Xunit; using Cuemon.Collections.Generic; @@ -296,5 +297,97 @@ public void ToPaginationList_ShouldProvideSubsetOfSequenceWithPageSizeOfTen_From i => Assert.Equal(1022, i), i => Assert.Equal(1023, i)); } + [Fact] + public void Chunk_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable sut = null; + + Assert.Throws(() => sut.Chunk()); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Chunk_ShouldThrowArgumentOutOfRangeException_WhenSizeIsInvalid(int size) + { + Assert.Throws(() => Enumerable.Range(0, 8).Chunk(size)); + } + + [Fact] + public void Shuffle_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable sut = null; + + Assert.Throws(() => sut.Shuffle().ToList()); + } + + [Fact] + public void Shuffle_ShouldThrowArgumentNullException_WhenRandomizerIsNull() + { + Assert.Throws(() => Enumerable.Range(0, 8).Shuffle(null).ToList()); + } + + [Fact] + public void OrderAscending_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable sut = null; + + Assert.Throws(() => sut.OrderAscending().ToList()); + } + + [Fact] + public void OrderAscending_ShouldThrowArgumentNullException_WhenComparerIsNull() + { + Assert.Throws(() => Enumerable.Range(0, 8).OrderAscending(null).ToList()); + } + + [Fact] + public void OrderDescending_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable sut = null; + + Assert.Throws(() => sut.OrderDescending().ToList()); + } + + [Fact] + public void OrderDescending_ShouldThrowArgumentNullException_WhenComparerIsNull() + { + Assert.Throws(() => Enumerable.Range(0, 8).OrderDescending(null).ToList()); + } + + [Fact] + public void RandomOrDefault_ShouldReturnDefault_WhenSourceIsEmpty() + { + var sut = Array.Empty(); + + Assert.Equal(default, sut.RandomOrDefault()); + } + + [Fact] + public void RandomOrDefault_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable sut = null; + + Assert.Throws(() => sut.RandomOrDefault()); + } + + [Fact] + public void ToDictionary_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable> sut = null; + + Assert.Throws(() => sut.ToDictionary()); + } + + [Fact] + public void ToDictionary_ShouldThrowArgumentNullException_WhenComparerIsNull() + { + var sut = new[] + { + new KeyValuePair("a", 1) + }; + + Assert.Throws(() => sut.ToDictionary(null)); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/ListExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/ListExtensionsTest.cs index a73836a0e..de09bfeb1 100644 --- a/test/Cuemon.Extensions.Collections.Generic.Tests/ListExtensionsTest.cs +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/ListExtensionsTest.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Codebelt.Extensions.Xunit; using Xunit; @@ -110,5 +111,74 @@ public void Previous_ShouldPeekBackwardUntilDefaultWithinTheList() i => Assert.Equal(null, i), i => Assert.Equal(null, i)); } + [Fact] + public void HasIndex_ShouldThrowArgumentNullException_WhenListIsNull() + { + IList sut = null; + + Assert.Throws(() => sut.HasIndex(0)); + } + + [Fact] + public void Next_ShouldThrowArgumentNullException_WhenListIsNull() + { + IList sut = null; + + Assert.Throws(() => sut.Next(0)); + } + + [Fact] + public void Next_ShouldThrowArgumentOutOfRangeException_WhenIndexIsNegative() + { + var sut = new List { 1, 2, 3 }; + + Assert.Throws(() => sut.Next(-1)); + } + + [Fact] + public void Previous_ShouldThrowArgumentNullException_WhenListIsNull() + { + IList sut = null; + + Assert.Throws(() => sut.Previous(0)); + } + + [Fact] + public void Previous_ShouldThrowArgumentOutOfRangeException_WhenIndexIsNegative() + { + var sut = new List { 1, 2, 3 }; + + Assert.Throws(() => sut.Previous(-1)); + } + + [Fact] + public void TryAdd_ShouldAddItem_WhenMissing() + { + var sut = new List { 1, 2, 3 }; + + var result = sut.TryAdd(4); + + Assert.True(result); + Assert.Equal(new[] { 1, 2, 3, 4 }, sut); + } + + [Fact] + public void TryAdd_ShouldReturnFalse_WhenItemAlreadyExists() + { + var sut = new List { 1, 2, 3 }; + + var result = sut.TryAdd(3); + + Assert.False(result); + Assert.Equal(new[] { 1, 2, 3 }, sut); + } + + [Fact] + public void TryAdd_ShouldThrowArgumentNullException_WhenListIsNull() + { + IList sut = null; + + Assert.Throws(() => sut.TryAdd(1)); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.Collections.Generic.Tests/QueueExtensionsTest.cs b/test/Cuemon.Extensions.Collections.Generic.Tests/QueueExtensionsTest.cs new file mode 100644 index 000000000..ba6f737e5 --- /dev/null +++ b/test/Cuemon.Extensions.Collections.Generic.Tests/QueueExtensionsTest.cs @@ -0,0 +1,39 @@ +#if NETSTANDARD2_0_OR_GREATER +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Collections.Generic +{ + public class QueueExtensionsTest : Test + { + public QueueExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TryPeek_ShouldReturnTrueAndFirstElement_WhenQueueIsNotEmpty() + { + var sut = new Queue(new[] { 10, 20, 30 }); + + var result = sut.TryPeek(out var value); + + Assert.True(result); + Assert.Equal(10, value); + Assert.Equal(3, sut.Count); + } + + [Fact] + public void TryPeek_ShouldReturnFalseAndDefaultValue_WhenQueueIsEmpty() + { + var sut = new Queue(); + + var result = sut.TryPeek(out var value); + + Assert.False(result); + Assert.Equal(default, value); + Assert.Empty(sut); + } + } +} +#endif diff --git a/test/Cuemon.Extensions.Core.Tests/ActionFactoryTest.cs b/test/Cuemon.Extensions.Core.Tests/ActionFactoryTest.cs new file mode 100644 index 000000000..ff30532bd --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/ActionFactoryTest.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions +{ + public class ActionFactoryTest : Test + { + public ActionFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldExecuteWrappedActions_WhenCreatingFactoriesFromZeroToFifteenArguments() + { + var executed = new List(); + + ActionFactory.Create(() => executed.Add("0")).ExecuteMethod(); + ActionFactory.Create((int a1) => executed.Add($"{a1}"), 1).ExecuteMethod(); + ActionFactory.Create((int a1, int a2) => executed.Add($"{a1},{a2}"), 1, 2).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3) => executed.Add($"{a1},{a2},{a3}"), 1, 2, 3).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4) => executed.Add($"{a1},{a2},{a3},{a4}"), 1, 2, 3, 4).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5) => executed.Add($"{a1},{a2},{a3},{a4},{a5}"), 1, 2, 3, 4, 5).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6}"), 1, 2, 3, 4, 5, 6).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7}"), 1, 2, 3, 4, 5, 6, 7).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8}"), 1, 2, 3, 4, 5, 6, 7, 8).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9}"), 1, 2, 3, 4, 5, 6, 7, 8, 9).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14).ExecuteMethod(); + ActionFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15) => executed.Add($"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14},{a15}"), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15).ExecuteMethod(); + + Assert.Equal(new[] + { + "0", + "1", + "1,2", + "1,2,3", + "1,2,3,4", + "1,2,3,4,5", + "1,2,3,4,5,6", + "1,2,3,4,5,6,7", + "1,2,3,4,5,6,7,8", + "1,2,3,4,5,6,7,8,9", + "1,2,3,4,5,6,7,8,9,10", + "1,2,3,4,5,6,7,8,9,10,11", + "1,2,3,4,5,6,7,8,9,10,11,12", + "1,2,3,4,5,6,7,8,9,10,11,12,13", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" + }, executed); + } + + [Fact] + public void Invoke_ShouldExecuteAction_WhenTupleIsProvided() + { + var result = string.Empty; + + ActionFactory.Invoke((MutableTuple tuple) => result = $"{tuple.Arg1}:{tuple.Arg2}:{tuple.Arg3}", MutableTupleFactory.CreateThree(1, 2, 3)); + + Assert.Equal("1:2:3", result); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/FuncFactoryTest.cs b/test/Cuemon.Extensions.Core.Tests/FuncFactoryTest.cs new file mode 100644 index 000000000..7215623b0 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/FuncFactoryTest.cs @@ -0,0 +1,64 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions +{ + public class FuncFactoryTest : Test + { + public FuncFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldExecuteWrappedFunctions_WhenCreatingFactoriesFromZeroToFifteenArguments() + { + var actual = new[] + { + FuncFactory.Create(() => "0").ExecuteMethod(), + FuncFactory.Create((int a1) => $"{a1}", 1).ExecuteMethod(), + FuncFactory.Create((int a1, int a2) => $"{a1},{a2}", 1, 2).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3) => $"{a1},{a2},{a3}", 1, 2, 3).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4) => $"{a1},{a2},{a3},{a4}", 1, 2, 3, 4).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5) => $"{a1},{a2},{a3},{a4},{a5}", 1, 2, 3, 4, 5).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6) => $"{a1},{a2},{a3},{a4},{a5},{a6}", 1, 2, 3, 4, 5, 6).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7}", 1, 2, 3, 4, 5, 6, 7).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8}", 1, 2, 3, 4, 5, 6, 7, 8).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9}", 1, 2, 3, 4, 5, 6, 7, 8, 9).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14).ExecuteMethod(), + FuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15) => $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14},{a15}", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15).ExecuteMethod() + }; + + Assert.Equal(new[] + { + "0", + "1", + "1,2", + "1,2,3", + "1,2,3,4", + "1,2,3,4,5", + "1,2,3,4,5,6", + "1,2,3,4,5,6,7", + "1,2,3,4,5,6,7,8", + "1,2,3,4,5,6,7,8,9", + "1,2,3,4,5,6,7,8,9,10", + "1,2,3,4,5,6,7,8,9,10,11", + "1,2,3,4,5,6,7,8,9,10,11,12", + "1,2,3,4,5,6,7,8,9,10,11,12,13", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" + }, actual); + } + + [Fact] + public void Invoke_ShouldExecuteFunction_WhenTupleIsProvided() + { + var result = FuncFactory.Invoke((MutableTuple tuple) => $"{tuple.Arg1}:{tuple.Arg2}:{tuple.Arg3}", MutableTupleFactory.CreateThree(1, 2, 3)); + + Assert.Equal("1:2:3", result); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/Globalization/RegionInfoExtensionsTest.cs b/test/Cuemon.Extensions.Core.Tests/Globalization/RegionInfoExtensionsTest.cs new file mode 100644 index 000000000..b5f145ec8 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/Globalization/RegionInfoExtensionsTest.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Globalization +{ + public class RegionInfoExtensionsTest : Test + { + public RegionInfoExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void GetCultures_ShouldReturnMatchingCultures_WhenRegionExists() + { + var cultures = new RegionInfo("US").GetCultures().ToList(); + + Assert.NotEmpty(cultures); + Assert.Contains(cultures, culture => culture.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void GetCultures_ShouldThrowArgumentNullException_WhenRegionIsNull() + { + RegionInfo region = null; + + var exception = Assert.Throws(() => region.GetCultures().ToList()); + + Assert.Equal("region", exception.ParamName); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/MethodDescriptorExtensionsTest.cs b/test/Cuemon.Extensions.Core.Tests/MethodDescriptorExtensionsTest.cs new file mode 100644 index 000000000..7b478ba44 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/MethodDescriptorExtensionsTest.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Cuemon.Reflection; +using Xunit; + +namespace Cuemon.Extensions +{ + public class MethodDescriptorExtensionsTest : Test + { + public MethodDescriptorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void HasParameters_ShouldReturnFalse_WhenDescriptorHasNoParameters() + { + var method = typeof(MethodDescriptorExtensionsTest).GetMethod(nameof(ParameterlessMethod), BindingFlags.NonPublic | BindingFlags.Static); + var sut = MethodDescriptor.Create(method); + + Assert.False(sut.HasParameters()); + } + + [Fact] + public void HasParameters_ShouldReturnTrue_WhenDescriptorHasParameters() + { + var method = typeof(MethodDescriptorExtensionsTest).GetMethod(nameof(MethodWithParameters), BindingFlags.NonPublic | BindingFlags.Static); + var sut = MethodDescriptor.Create(method); + + Assert.True(sut.HasParameters()); + } + + private static void ParameterlessMethod() + { + } + + private static void MethodWithParameters(int number, string text) + { + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyDecoratorExtensionsTest.cs b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyDecoratorExtensionsTest.cs new file mode 100644 index 000000000..bbfb3cc88 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyDecoratorExtensionsTest.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Runtime +{ + public class HierarchyDecoratorExtensionsTest : Test + { + public HierarchyDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void NavigationExtensions_ShouldReturnExpectedNodes_WhenHierarchyHasMultipleLevels() + { + var root = BuildStringHierarchy(out var childOne, out var grandchild, out var childTwo); + var ancestors = Decorator.Enclose(grandchild).AncestorsAndSelf().Select(h => h.Instance).ToList(); + var descendants = Decorator.Enclose(root).DescendantsAndSelf().ToList(); + var siblings = Decorator.Enclose(childOne).SiblingsAndSelf().Select(h => h.Instance).ToList(); + var nodesAtDepth = Decorator.Enclose(grandchild).SiblingsAndSelfAt(1).Select(h => h.Instance).ToList(); + var allNodes = Decorator.Enclose(childOne).FlattenAll().ToList(); + + Assert.Same(root, Decorator.Enclose(grandchild).Root()); + Assert.Equal(new[] { "root", "child-one" }, ancestors); + Assert.Equal(4, descendants.Count); + Assert.Contains(root, descendants); + Assert.Contains(childOne, descendants); + Assert.Contains(grandchild, descendants); + Assert.Contains(childTwo, descendants); + Assert.Equal(new[] { "child-one", "child-two" }, siblings); + Assert.Equal(new[] { "child-one", "child-two" }, nodesAtDepth); + Assert.Same(grandchild, Decorator.Enclose(root).NodeAt(2)); + Assert.Equal(4, allNodes.Count); + Assert.Throws(() => Decorator.Enclose(root).NodeAt(99)); + } + + [Fact] + public void FindAndReplaceExtensions_ShouldTransformMatchingNodes_WhenPredicatesMatch() + { + var root = BuildStringHierarchy(out var childOne, out var grandchild, out var childTwo); + + Assert.Equal("child-one", Decorator.Enclose(root).FindFirstInstance(h => h.Instance.StartsWith("child", StringComparison.Ordinal))); + Assert.Equal("grandchild", Decorator.Enclose(root).FindSingleInstance(h => h.Instance == "grandchild")); + Assert.Same(childTwo, Decorator.Enclose(root).FindFirst(h => h.Instance == "child-two")); + Assert.Same(grandchild, Decorator.Enclose(root).FindSingle(h => h.Instance == "grandchild")); + Assert.Equal(new[] { "child-one", "child-two" }, Decorator.Enclose(root).FindInstance(h => h.Depth == 1).OrderBy(value => value).ToArray()); + Assert.Equal(2, Decorator.Enclose(root).Find(h => h.Depth == 1).Count()); + + Decorator.Enclose(grandchild).Replace((node, value) => node.Replace(value.ToUpperInvariant())); + Decorator.Enclose(Decorator.Enclose(root).Find(h => h.Depth == 1)).ReplaceAll((node, value) => node.Replace(value.ToUpperInvariant())); + + Assert.Equal("GRANDCHILD", grandchild.Instance); + Assert.Equal(new[] { "CHILD-ONE", "CHILD-TWO" }, root.GetChildren().Select(h => h.Instance).ToArray()); + } + + [Fact] + public void FormatterExtensions_ShouldConvertPrimitiveAndSpecialNodes_WhenDataPairsAreWrapped() + { + var integerNode = BuildDataPairHierarchy(new DataPair(typeof(int).Name, "42", typeof(string))); + var uri = new Uri("https://example.com/path?value=42", UriKind.Absolute); + var uriNode = BuildDataPairHierarchy(new DataPair("OriginalString", uri.OriginalString, typeof(string))); + var fallbackUriNode = BuildDataPairHierarchy(new DataPair("Value", uri.OriginalString, typeof(string))); + var timestamp = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var directDateTimeNode = BuildDataPairHierarchy(new DataPair("When", timestamp, typeof(DateTime))); + var fallbackDateTimeNode = BuildDataPairHierarchy(new DataPair("When", timestamp.ToString("O", CultureInfo.InvariantCulture), typeof(string))); + var guid = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guidNode = BuildDataPairHierarchy(new DataPair("Value", guid.ToString("D"), typeof(string))); + var stringNode = BuildDataPairHierarchy(new DataPair("Text", "hello", typeof(string))); + var decimalNode = BuildDataPairHierarchy(new DataPair("Amount", "42.5", typeof(string))); + + Assert.Equal(42, Decorator.Enclose(integerNode).UseConvertibleFormatter()); + Assert.Equal(uri, Decorator.Enclose(uriNode).UseUriFormatter()); + Assert.Equal(uri, Decorator.Enclose(fallbackUriNode).UseUriFormatter()); + Assert.Equal(timestamp, Decorator.Enclose(directDateTimeNode).UseDateTimeFormatter()); + Assert.Equal(timestamp, Decorator.Enclose(fallbackDateTimeNode).UseDateTimeFormatter().ToUniversalTime()); + Assert.Equal(guid, Decorator.Enclose(guidNode).UseGuidFormatter()); + Assert.Equal("hello", Decorator.Enclose(stringNode).UseStringFormatter()); + Assert.Equal(42.5m, Decorator.Enclose(decimalNode).UseDecimalFormatter()); + } + + [Fact] + public void CollectionFormatters_ShouldMaterializeTypedCollections_WhenChildrenRepresentStructuredValues() + { + var collectionNode = BuildCollectionHierarchy(typeof(int), 1, 2, 3); + var dictionaryNode = BuildDictionaryHierarchy(typeof(int), new KeyValuePair("alpha", 1), new KeyValuePair("beta", 2)); + var collection = Decorator.Enclose(collectionNode).UseCollection(typeof(int)); + var dictionary = Decorator.Enclose(dictionaryNode).UseDictionary(new[] { typeof(string), typeof(int) }); + + Assert.Equal(new[] { 1, 2, 3 }, collection.Cast().ToArray()); + Assert.Equal(2, dictionary.Count); + Assert.Equal(1, dictionary["alpha"]); + Assert.Equal(2, dictionary["beta"]); + } + + [Fact] + public void SpecializedCollectionAndDictionaryFormatters_ShouldHandleSupportedAndUnsupportedTypes_WhenMaterializingValues() + { + var uri = new Uri("https://example.com/value", UriKind.Absolute); + var timestamp = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var guid = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + var uriCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(Uri), uri.OriginalString)).UseCollection(typeof(Uri)); + var decimalCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(decimal), "42.5")).UseCollection(typeof(decimal)); + var stringCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(string), "alpha")).UseCollection(typeof(string)); + var guidCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(Guid), guid.ToString("D"))).UseCollection(typeof(Guid)); + var dateTimeCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(DateTime), timestamp)).UseCollection(typeof(DateTime)); + var unsupportedCollection = Decorator.Enclose(BuildCollectionHierarchy(typeof(object), new object())).UseCollection(typeof(object)); + + Assert.Equal(uri, uriCollection.Cast().Single()); + Assert.Equal(42.5m, decimalCollection.Cast().Single()); + Assert.Equal("alpha", stringCollection.Cast().Single()); + Assert.Equal(guid, guidCollection.Cast().Single()); + Assert.Equal(timestamp, dateTimeCollection.Cast().Single()); + Assert.Empty(unsupportedCollection.Cast()); + + var uriDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(Uri), new KeyValuePair("uri", uri.OriginalString))).UseDictionary(new[] { typeof(string), typeof(Uri) }); + var decimalDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(decimal), new KeyValuePair("amount", "42.5"))).UseDictionary(new[] { typeof(string), typeof(decimal) }); + var stringDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(string), new KeyValuePair("text", "alpha"))).UseDictionary(new[] { typeof(string), typeof(string) }); + var guidDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(Guid), new KeyValuePair("id", guid.ToString("D")))).UseDictionary(new[] { typeof(string), typeof(Guid) }); + var dateTimeDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(DateTime), new KeyValuePair("timestamp", timestamp))).UseDictionary(new[] { typeof(string), typeof(DateTime) }); + var unsupportedDictionary = Decorator.Enclose(BuildDictionaryHierarchy(typeof(object), new KeyValuePair("unsupported", new object()))).UseDictionary(new[] { typeof(string), typeof(object) }); + + Assert.Equal(uri, uriDictionary["uri"]); + Assert.Equal(42.5m, decimalDictionary["amount"]); + Assert.Equal("alpha", stringDictionary["text"]); + Assert.Equal(guid, guidDictionary["id"]); + Assert.Equal(timestamp, dateTimeDictionary["timestamp"]); + Assert.Equal(0, unsupportedDictionary.Count); + } + + private static Hierarchy BuildStringHierarchy(out IHierarchy childOne, out IHierarchy grandchild, out IHierarchy childTwo) + { + var root = new Hierarchy(); + root.Add("root"); + childOne = root.Add("child-one"); + grandchild = childOne.Add("grandchild"); + childTwo = root.Add("child-two"); + return root; + } + + private static IHierarchy BuildDataPairHierarchy(DataPair pair) + { + var hierarchy = new Hierarchy(); + hierarchy.Add(pair); + return hierarchy; + } + + private static IHierarchy BuildCollectionHierarchy(Type valueType, params object[] values) + { + var hierarchy = new Hierarchy(); + hierarchy.Add(new DataPair("Items", null, typeof(List))); + foreach (var value in values) + { + hierarchy.Add(CreateValuePair(valueType, value)); + } + return hierarchy; + } + + private static IHierarchy BuildDictionaryHierarchy(Type valueType, params KeyValuePair[] values) + { + var hierarchy = new Hierarchy(); + hierarchy.Add(new DataPair("Entries", null, typeof(Dictionary))); + foreach (var value in values) + { + var keyNode = hierarchy.Add(new DataPair("Key", value.Key, typeof(string))); + keyNode.Add(CreateValuePair(valueType, value.Value)); + } + return hierarchy; + } + + private static DataPair CreateValuePair(Type valueType, object value) + { + if (valueType.IsPrimitive) + { + return new DataPair(valueType.Name, value, value.GetType()); + } + + if (valueType == typeof(Uri)) + { + return new DataPair("OriginalString", value, typeof(string)); + } + + if (valueType == typeof(DateTime)) + { + return new DataPair("When", value, typeof(DateTime)); + } + + return new DataPair("Value", value, value?.GetType() ?? typeof(object)); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyOptionsTest.cs b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyOptionsTest.cs new file mode 100644 index 000000000..afbbd03a6 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyOptionsTest.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Cuemon.Reflection; +using Xunit; + +namespace Cuemon.Extensions.Runtime +{ + public class HierarchyOptionsTest : Test + { + public HierarchyOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldInitializeDefaultsAndDelegates_WhenCreated() + { + var sut = new HierarchyOptions(); + var nameProperty = typeof(HierarchyOptionsModel).GetProperty(nameof(HierarchyOptionsModel.Name)); + var countProperty = typeof(List).GetProperty(nameof(List.Count)); + var capacityProperty = typeof(List).GetProperty(nameof(List.Capacity)); + + Assert.Equal(10, sut.MaxDepth); + Assert.Equal(2, sut.MaxCircularCalls); + Assert.NotNull(sut.ReflectionRules); + Assert.NotNull(sut.SkipPropertyType); + Assert.NotNull(sut.SkipProperty); + Assert.NotNull(sut.HasCircularReference); + Assert.NotNull(sut.ValueResolver); + Assert.True(sut.SkipPropertyType(typeof(string))); + Assert.False(sut.SkipPropertyType(typeof(HierarchyOptionsModel))); + Assert.True(sut.SkipProperty(countProperty)); + Assert.False(sut.SkipProperty(capacityProperty)); + Assert.Equal("alpha", sut.ValueResolver(new HierarchyOptionsModel { Name = "alpha" }, nameProperty)); + } + + [Fact] + public void Properties_ShouldValidateAssignedValues_WhenConfigured() + { + var sut = new HierarchyOptions(); + var reflectionRules = new MemberReflection(excludePrivate: true); + Func skipPropertyType = type => type == typeof(HierarchyOptionsModel); + Func skipProperty = property => property.Name == nameof(HierarchyOptionsModel.Name); + Func hasCircularReference = _ => false; + Func valueResolver = (_, property) => property.Name; + + Assert.Throws(() => sut.MaxDepth = -1); + Assert.Throws(() => sut.MaxCircularCalls = -1); + Assert.Throws(() => sut.ReflectionRules = null); + Assert.Throws(() => sut.SkipPropertyType = null); + Assert.Throws(() => sut.SkipProperty = null); + Assert.Throws(() => sut.HasCircularReference = null); + Assert.Throws(() => sut.ValueResolver = null); + + sut.MaxDepth = 3; + sut.MaxCircularCalls = 4; + sut.ReflectionRules = reflectionRules; + sut.SkipPropertyType = skipPropertyType; + sut.SkipProperty = skipProperty; + sut.HasCircularReference = hasCircularReference; + sut.ValueResolver = valueResolver; + + Assert.Equal(3, sut.MaxDepth); + Assert.Equal(4, sut.MaxCircularCalls); + Assert.Same(reflectionRules, sut.ReflectionRules); + Assert.Same(skipPropertyType, sut.SkipPropertyType); + Assert.Same(skipProperty, sut.SkipProperty); + Assert.Same(hasCircularReference, sut.HasCircularReference); + Assert.Same(valueResolver, sut.ValueResolver); + } + + private sealed class HierarchyOptionsModel + { + public string Name { get; set; } + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyTest.cs b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyTest.cs new file mode 100644 index 000000000..2cbba64aa --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/Runtime/HierarchyTest.cs @@ -0,0 +1,137 @@ +using System; +using System.Globalization; +using System.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Runtime +{ + public class HierarchyTest : Test + { + public HierarchyTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Add_ShouldAssignRelationshipsAndMetadata_WhenHierarchyGrows() + { + var root = new Hierarchy(); + var rootNode = root.Add("root"); + var child = root.Add("child"); + var grandchild = child.Add("grandchild"); + var sibling = root.Add("sibling"); + + Assert.Same(root, rootNode); + Assert.Equal(0, root.Depth); + Assert.Equal(0, root.Index); + Assert.False(root.HasParent); + Assert.True(root.HasChildren); + Assert.Equal(1, child.Depth); + Assert.Equal(1, child.Index); + Assert.True(child.HasParent); + Assert.True(child.HasChildren); + Assert.Equal(2, grandchild.Depth); + Assert.Equal(2, grandchild.Index); + Assert.Equal(1, sibling.Depth); + Assert.Equal(3, sibling.Index); + Assert.Same(root, child.GetParent()); + Assert.Same(child, grandchild.GetParent()); + Assert.Equal(new[] { child, sibling }, root.GetChildren().ToArray()); + Assert.Same(grandchild, root[2]); + } + + [Fact] + public void Replace_ShouldUpdateWrappedInstance_WhenUsingOverloads() + { + var root = new Hierarchy(); + root.Add("42", typeof(string)); + + root.Replace(84); + Assert.Equal(84, root.Instance); + Assert.Equal(typeof(int), root.InstanceType); + + root.Replace("84", typeof(string)); + Assert.Equal("84", root.Instance); + Assert.Equal(typeof(string), root.InstanceType); + Assert.Throws(() => root.Replace("ignored", null)); + } + + [Fact] + public void GetPath_ShouldReturnExpectedPaths_WhenUsingDefaultAndCustomResolvers() + { + var root = new Hierarchy(); + root.Add(new RootNode(), typeof(RootNode)); + var child = root.Add(new ChildNode(), typeof(ChildNode)); + var leaf = child.Add(new LeafNode(), typeof(LeafNode)); + + Assert.Equal("RootNode.ChildNode.LeafNode", leaf.GetPath()); + Assert.Equal("0.1.2", leaf.GetPath(h => h.Index.ToString(CultureInfo.InvariantCulture))); + } + + [Fact] + public void GetObjectHierarchy_ShouldBuildObjectTree_WhenObjectContainsNestedProperties() + { + var source = new ObjectGraph { Name = "alpha", Child = new ChildGraph { Count = 7 } }; + var hierarchy = Hierarchy.GetObjectHierarchy(source); + var flattened = Decorator.Enclose(hierarchy).FlattenAll().ToList(); + + Assert.Equal(typeof(ObjectGraph), hierarchy.InstanceType); + Assert.Contains(flattened, h => Equals(h.Instance, "alpha")); + var childNode = flattened.Single(h => Equals(h.MemberReference?.Name, nameof(ObjectGraph.Child))); + Assert.Equal(typeof(ChildGraph), childNode.InstanceType); + Assert.Equal(3, flattened.Count); + } + + [Fact] + public void GetObjectHierarchy_ShouldReturnRootOnly_WhenSourceIsSimpleType() + { + var hierarchy = Hierarchy.GetObjectHierarchy("alpha"); + + Assert.Equal("alpha", hierarchy.Instance); + Assert.Equal(typeof(string), hierarchy.InstanceType); + Assert.False(hierarchy.HasChildren); + } + + [Fact] + public void StaticOperations_ShouldTraverseAndFindNodes_WhenHierarchyIsQueried() + { + var root = new Hierarchy(); + root.Add("root"); + var child = root.Add("child"); + var grandchild = child.Add("grandchild"); + var found = Hierarchy.Find(root, h => h.Instance.Contains("child", StringComparison.Ordinal)).ToList(); + var ancestors = Hierarchy.TraverseWhileNotNull(grandchild, h => h.GetParent()).Select(h => h.Instance).ToList(); + var traversed = Hierarchy.TraverseWhileNotEmpty>(root, h => h.GetChildren()).Select(h => h.Instance).ToList(); + + Assert.Equal(1, found.Count); + Assert.Equal(new[] { "child", "root" }, ancestors); + Assert.Equal(new[] { "root", "child", "grandchild" }, traversed); + Assert.Throws(() => Hierarchy.Find(root, null)); + Assert.Throws(() => Hierarchy.TraverseWhileNotNull(null, h => h)); + } + + private sealed class RootNode + { + } + + private sealed class ChildNode + { + } + + private sealed class LeafNode + { + } + + private sealed class ObjectGraph + { + public string Name { get; set; } + + public ChildGraph Child { get; set; } + } + + private sealed class ChildGraph + { + public int Count { get; set; } + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/Runtime/Serialization/HierarchySerializerTest.cs b/test/Cuemon.Extensions.Core.Tests/Runtime/Serialization/HierarchySerializerTest.cs new file mode 100644 index 000000000..19665afa7 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/Runtime/Serialization/HierarchySerializerTest.cs @@ -0,0 +1,47 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Runtime.Serialization +{ + public class HierarchySerializerTest : Test + { + public HierarchySerializerTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldCreateHierarchyNodes_WhenSerializingObjectGraph() + { + var sut = new HierarchySerializer(new SerializerRoot { Name = "alpha", Child = new SerializerChild { Count = 7 } }); + + Assert.NotNull(sut.Nodes); + Assert.Equal(typeof(SerializerRoot), sut.Nodes.InstanceType); + Assert.True(sut.Nodes.HasChildren); + } + + [Fact] + public void ToString_ShouldDescribeHierarchy_WhenNodesHaveChildren() + { + var sut = new HierarchySerializer(new SerializerRoot { Name = "alpha", Child = new SerializerChild { Count = 7 } }); + var text = sut.ToString(); + + Assert.Contains("SerializerRoot", text); + Assert.Contains("SerializerRoot.String", text); + Assert.Contains("SerializerRoot.SerializerChild", text); + + TestOutput.WriteLine(text); + } + + private sealed class SerializerRoot + { + public string Name { get; set; } + + public SerializerChild Child { get; set; } + } + + private sealed class SerializerChild + { + public int Count { get; set; } + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/TesterFuncFactoryTest.cs b/test/Cuemon.Extensions.Core.Tests/TesterFuncFactoryTest.cs new file mode 100644 index 000000000..4ea7ec9d0 --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/TesterFuncFactoryTest.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions +{ + public class TesterFuncFactoryTest : Test + { + public TesterFuncFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldExecuteWrappedTesterFunctions_WhenCreatingFactoriesFromZeroToFifteenArguments() + { + var results = new List(); + var successes = new List(); + + successes.Add(TesterFuncFactory.Create((out string result) => { result = "0"; return true; }).ExecuteMethod(out var r0)); + results.Add(r0); + successes.Add(TesterFuncFactory.Create((int a1, out string result) => { result = $"{a1}"; return true; }, 1).ExecuteMethod(out var r1)); + results.Add(r1); + successes.Add(TesterFuncFactory.Create((int a1, int a2, out string result) => { result = $"{a1},{a2}"; return true; }, 1, 2).ExecuteMethod(out var r2)); + results.Add(r2); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, out string result) => { result = $"{a1},{a2},{a3}"; return true; }, 1, 2, 3).ExecuteMethod(out var r3)); + results.Add(r3); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, out string result) => { result = $"{a1},{a2},{a3},{a4}"; return true; }, 1, 2, 3, 4).ExecuteMethod(out var r4)); + results.Add(r4); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5}"; return true; }, 1, 2, 3, 4, 5).ExecuteMethod(out var r5)); + results.Add(r5); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6}"; return true; }, 1, 2, 3, 4, 5, 6).ExecuteMethod(out var r6)); + results.Add(r6); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7}"; return true; }, 1, 2, 3, 4, 5, 6, 7).ExecuteMethod(out var r7)); + results.Add(r7); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8).ExecuteMethod(out var r8)); + results.Add(r8); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9).ExecuteMethod(out var r9)); + results.Add(r9); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10).ExecuteMethod(out var r10)); + results.Add(r10); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).ExecuteMethod(out var r11)); + results.Add(r11); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).ExecuteMethod(out var r12)); + results.Add(r12); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13).ExecuteMethod(out var r13)); + results.Add(r13); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14).ExecuteMethod(out var r14)); + results.Add(r14); + successes.Add(TesterFuncFactory.Create((int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, int a14, int a15, out string result) => { result = $"{a1},{a2},{a3},{a4},{a5},{a6},{a7},{a8},{a9},{a10},{a11},{a12},{a13},{a14},{a15}"; return true; }, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15).ExecuteMethod(out var r15)); + results.Add(r15); + + Assert.DoesNotContain(false, successes); + Assert.Equal(new[] + { + "0", + "1", + "1,2", + "1,2,3", + "1,2,3,4", + "1,2,3,4,5", + "1,2,3,4,5,6", + "1,2,3,4,5,6,7", + "1,2,3,4,5,6,7,8", + "1,2,3,4,5,6,7,8,9", + "1,2,3,4,5,6,7,8,9,10", + "1,2,3,4,5,6,7,8,9,10,11", + "1,2,3,4,5,6,7,8,9,10,11,12", + "1,2,3,4,5,6,7,8,9,10,11,12,13", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14", + "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" + }, results); + } + + [Fact] + public void Invoke_ShouldExecuteTesterFunction_WhenTupleIsProvided() + { + var success = TesterFuncFactory.Invoke((MutableTuple tuple, out string result) => + { + result = $"{tuple.Arg1}:{tuple.Arg2}:{tuple.Arg3}"; + return true; + }, MutableTupleFactory.CreateThree(1, 2, 3), out var result); + + Assert.True(success); + Assert.Equal("1:2:3", result); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/VerticalDirectionTest.cs b/test/Cuemon.Extensions.Core.Tests/VerticalDirectionTest.cs new file mode 100644 index 000000000..d7f046fde --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/VerticalDirectionTest.cs @@ -0,0 +1,19 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions +{ + public class VerticalDirectionTest : Test + { + public VerticalDirectionTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void EnumValues_ShouldMatchExpectedDirections_WhenAccessed() + { + Assert.Equal(0, (int)VerticalDirection.Down); + Assert.Equal(1, (int)VerticalDirection.Up); + } + } +} diff --git a/test/Cuemon.Extensions.Core.Tests/WrapperTest.cs b/test/Cuemon.Extensions.Core.Tests/WrapperTest.cs new file mode 100644 index 000000000..2e1fc9acb --- /dev/null +++ b/test/Cuemon.Extensions.Core.Tests/WrapperTest.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions +{ + public class WrapperTest : Test + { + public WrapperTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenInstanceIsNull() + { + string instance = null; + + var exception = Assert.Throws(() => new Wrapper(instance)); + + Assert.Equal("instance", exception.ParamName); + } + + [Fact] + public void ParseInstance_ShouldThrowArgumentNullException_WhenWrapperIsNull() + { + IWrapper wrapper = null; + + var exception = Assert.Throws(() => Wrapper.ParseInstance(wrapper)); + + Assert.Equal("wrapper", exception.ParamName); + } + + [Fact] + public void InstanceAs_ShouldConvertWrappedValue_WhenUsingInvariantAndExplicitProviders() + { + var invariant = new Wrapper("42"); + var providerAware = new Wrapper(42); + var culture = CultureInfo.GetCultureInfo("da-DK"); + + Assert.Equal(42, invariant.InstanceAs()); + Assert.Equal("42", providerAware.InstanceAs(culture)); + } + + [Fact] + public void Wrapper_ShouldExposeMetadataAndStructuredFormatting_WhenWrappingComplexValues() + { + var member = typeof(WrapperTestModel).GetProperty(nameof(WrapperTestModel.Name), BindingFlags.Public | BindingFlags.Instance); + var wrapper = new Wrapper(42, member); + var pair = new Wrapper>(new KeyValuePair("alpha", null)); + var comparer = new Wrapper(StringComparer.OrdinalIgnoreCase); + + wrapper.Data.Add("answer", true); + + Assert.True(wrapper.HasMemberReference); + Assert.Same(member, wrapper.MemberReference); + Assert.True(wrapper.Data.ContainsKey("answer")); + Assert.Equal("[alpha,null]", pair.ToString()); + Assert.Contains("Comparer", comparer.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void ParseInstance_ShouldReturnExpectedRepresentation_WhenWrappingPrimitiveAndSpecialValues() + { + var timestamp = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var bytes = new byte[] { 1, 2, 3, 4 }; + var guid = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var uri = new Uri("https://example.com/path?value=42", UriKind.Absolute); + + Assert.Equal("false", new Wrapper(false).ToString()); + Assert.Equal("42.5", new Wrapper(42.5m).ToString()); + Assert.Equal(timestamp.ToString("O", CultureInfo.InvariantCulture), new Wrapper(timestamp).ToString()); + Assert.Equal("hello", new Wrapper("hello").ToString()); + Assert.Equal(Convert.ToBase64String(bytes), new Wrapper(bytes).ToString()); + Assert.Equal(guid.ToString("D"), new Wrapper(guid).ToString()); + Assert.Equal(typeof(Dictionary).ToFriendlyName(), new Wrapper(typeof(Dictionary)).ToString()); + Assert.Equal(uri.OriginalString, new Wrapper(uri).ToString()); + } + + private sealed class WrapperTestModel + { + public string Name { get; set; } + } + } +} diff --git a/test/Cuemon.Extensions.Data.Integrity.Tests/CacheValidatorTest.cs b/test/Cuemon.Extensions.Data.Integrity.Tests/CacheValidatorTest.cs new file mode 100644 index 000000000..7096c3628 --- /dev/null +++ b/test/Cuemon.Extensions.Data.Integrity.Tests/CacheValidatorTest.cs @@ -0,0 +1,160 @@ +using System; +using System.ComponentModel; +using System.Reflection; +using Cuemon.Extensions; +using Cuemon.Extensions.Data.Integrity; +using Cuemon.Security; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data.Integrity; + +public class CacheValidatorTest : Test +{ + public CacheValidatorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void GetMostSignificant_ShouldReturnDefault_WhenSequenceIsEmpty() + { + var result = CacheValidator.GetMostSignificant(); + + Assert.NotNull(result); + Assert.Equal(CacheValidator.Default.ToString(), result.ToString()); + } + + [Fact] + public void Constructor_ShouldInitializeCreatedOnly_WhenEntityInfoHasNoModifiedDate() + { + var created = 0d.FromUnixEpochTime(); + var entity = new EntityInfo(created); + + Assert.Equal(created, entity.Created); + Assert.Null(entity.Modified); + } + + [Fact] + public void GetMostSignificant_ShouldReturnMostSignificant_WhenMultipleCacheValidatorsProvided() + { + var first = 0d.FromUnixEpochTime().GetCacheValidator(); + var second = 1d.FromUnixEpochTime().GetCacheValidator(); + + var result = CacheValidator.GetMostSignificant(first, second); + + Assert.Same(second, result); + } + + [Fact] + public void AssemblyReference_ShouldSetAndGetAssembly() + { + var original = CacheValidator.AssemblyReference; + var expected = typeof(CacheValidatorTest).Assembly; + + try + { + CacheValidator.AssemblyReference = expected; + + Assert.Same(expected, CacheValidator.AssemblyReference); + } + finally + { + CacheValidator.AssemblyReference = original; + } + } + + [Fact] + public void AssemblyReference_ShouldThrowArgumentNullException_WhenSetToNull() + { + Assert.Throws(() => CacheValidator.AssemblyReference = null); + } + + [Fact] + public void ReferencePoint_ShouldReturnNonDefaultCacheValidator() + { + var original = CacheValidator.AssemblyReference; + var assembly = typeof(CacheValidatorTest).Assembly; + + try + { + CacheValidator.AssemblyReference = assembly; + var result = CacheValidator.ReferencePoint; + + Assert.NotNull(result); + Assert.NotEqual(CacheValidator.Default.ToString(), result.ToString()); + TestOutput.WriteLine(result.ToString()); + } + finally + { + CacheValidator.AssemblyReference = original; + } + } + + [Fact] + public void Default_ShouldReturnNewInstanceEachTime() + { + var first = CacheValidator.Default; + var second = CacheValidator.Default; + + Assert.NotSame(first, second); + Assert.Equal(first.ToString(), second.ToString()); + } + + [Fact] + public void Clone_ShouldReturnEqualButDistinctInstance() + { + var sut = CacheValidator.Default; + + var clone = sut.Clone(); + + Assert.NotSame(sut, clone); + Assert.Equal(sut.ToString(), clone.ToString()); + } + + [Fact] + public void GetMostSignificant_Instance_ShouldReturnMaxTickDateTime() + { + var created = 0d.FromUnixEpochTime(); + var modified = created.AddDays(7); + var sut = created.GetCacheValidator(modified); + + var result = sut.GetMostSignificant(); + + Assert.Equal(modified, result); + } + + [Fact] + public void CombineWith_ShouldModifyChecksum() + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + var original = sut.ToString(); + + var result = sut.CombineWith(Convertible.GetBytes(1234567890)); + + Assert.Same(sut, result); + Assert.NotEqual(original, sut.ToString()); + TestOutput.WriteLine(sut.ToString()); + } + + [Fact] + public void Constructor_ShouldCombineChecksum_WhenMethodIsCombined() + { + var created = 0d.FromUnixEpochTime(); + var modified = created.AddDays(7); + var entity = new EntityInfo(created, modified, Convertible.GetBytes(1234567890)); + var unaltered = new CacheValidator(entity, () => HashFactory.CreateFnv128()); + + var sut = new CacheValidator(entity, () => HashFactory.CreateFnv128(), EntityDataIntegrityMethod.Combined); + + Assert.Equal(EntityDataIntegrityMethod.Combined, sut.Method); + Assert.NotEqual(unaltered.ToString(), sut.ToString()); + } + + [Fact] + public void Constructor_ShouldThrowInvalidEnumArgumentException_WhenMethodIsInvalid() + { + var entity = new EntityInfo(0d.FromUnixEpochTime(), 0d.FromUnixEpochTime().AddDays(7), Convertible.GetBytes(1234567890)); + + Assert.Throws(() => new CacheValidator(entity, () => HashFactory.CreateFnv128(), (EntityDataIntegrityMethod)int.MaxValue)); + } +} diff --git a/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderExtensionsTest.cs b/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderExtensionsTest.cs new file mode 100644 index 000000000..b41da1afb --- /dev/null +++ b/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderExtensionsTest.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using Cuemon.Data.Integrity; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Data.Integrity; + +public class ChecksumBuilderExtensionsTest : Test +{ + public ChecksumBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsDouble() + { + AssertChecksumChanges(sut => sut.CombineWith(3.14d)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsShort() + { + AssertChecksumChanges(sut => sut.CombineWith((short)42)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsString() + { + AssertChecksumChanges(sut => sut.CombineWith("cuemon")); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsInt() + { + AssertChecksumChanges(sut => sut.CombineWith(42)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsLong() + { + AssertChecksumChanges(sut => sut.CombineWith(42L)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsFloat() + { + AssertChecksumChanges(sut => sut.CombineWith(3.14f)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsUShort() + { + AssertChecksumChanges(sut => sut.CombineWith((ushort)42)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsUInt() + { + AssertChecksumChanges(sut => sut.CombineWith(42U)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsULong() + { + AssertChecksumChanges(sut => sut.CombineWith(42UL)); + } + + [Fact] + public void CombineWith_ShouldChangeChecksum_WhenAdditionalChecksumIsByteArray() + { + AssertChecksumChanges(sut => ChecksumBuilderExtensions.CombineWith(sut, new byte[] { 1, 2, 3, 4 })); + } + + [Fact] + public void CombineWith_ShouldReturnSameInstanceUnchanged_WhenByteArrayIsNull() + { + AssertChecksumUnchanged((byte[])null); + } + + [Fact] + public void CombineWith_ShouldReturnSameInstanceUnchanged_WhenByteArrayIsEmpty() + { + AssertChecksumUnchanged(Array.Empty()); + } + + private void AssertChecksumChanges(Func combine) + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + var original = sut.ToString(); + + var result = combine(sut); + + Assert.NotNull(result); + Assert.Same(sut, result); + Assert.NotEqual(original, result.ToString()); + TestOutput.WriteLine(result.ToString()); + } + + private void AssertChecksumUnchanged(byte[] additionalChecksum) + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + var original = sut.ToString(); + + var result = ChecksumBuilderExtensions.CombineWith(sut, additionalChecksum); + + Assert.NotNull(result); + Assert.Same(sut, result); + Assert.Equal(original, result.ToString()); + TestOutput.WriteLine(result.ToString()); + } +} diff --git a/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderTest.cs b/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderTest.cs new file mode 100644 index 000000000..277b80a5b --- /dev/null +++ b/test/Cuemon.Extensions.Data.Integrity.Tests/ChecksumBuilderTest.cs @@ -0,0 +1,62 @@ +using Cuemon.Extensions; +using Cuemon.Extensions.Data.Integrity; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Data.Integrity; + +public class ChecksumBuilderTest : Test +{ + public ChecksumBuilderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Equals_ShouldReturnTrue_WhenSameChecksum() + { + var first = 0d.FromUnixEpochTime().GetCacheValidator(); + _ = first.Checksum; + var second = first.Clone(); + + Assert.True(first.Equals(second)); + } + + [Fact] + public void Equals_ShouldReturnFalse_WhenDifferentChecksum() + { + var first = 0d.FromUnixEpochTime().GetCacheValidator(); + var second = 1d.FromUnixEpochTime().GetCacheValidator(); + + Assert.False(first.Equals(second)); + } + + [Fact] + public void GetHashCode_ShouldReturnConsistentValue() + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + + var first = sut.GetHashCode(); + var second = sut.GetHashCode(); + + Assert.Equal(first, second); + } + + [Fact] + public void ToString_ShouldReturnHexString() + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + var result = sut.ToString(); + + Assert.False(string.IsNullOrWhiteSpace(result)); + Assert.Matches("^[0-9a-f]+$", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void Equals_ShouldReturnFalse_WhenObjectIsNotChecksumBuilder() + { + var sut = 0d.FromUnixEpochTime().GetCacheValidator(); + + Assert.False(sut.Equals(new object())); + } +} diff --git a/test/Cuemon.Extensions.Data.Integrity.Tests/FileInfoExtensionsTest.cs b/test/Cuemon.Extensions.Data.Integrity.Tests/FileInfoExtensionsTest.cs new file mode 100644 index 000000000..ba5c5dd71 --- /dev/null +++ b/test/Cuemon.Extensions.Data.Integrity.Tests/FileInfoExtensionsTest.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using Cuemon.Data.Integrity; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Data.Integrity; + +public class FileInfoExtensionsTest : Test +{ + public FileInfoExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void GetCacheValidator_ShouldThrowArgumentNullException_WhenFileInfoIsNull() + { + FileInfo file = null; + + Assert.Throws(() => file.GetCacheValidator()); + } + + [Fact] + public void GetCacheValidator_ShouldReturnValidCacheValidator_WhenFileExists() + { + var path = Path.Combine(AppContext.BaseDirectory, $"file-info-{Guid.NewGuid():N}.tmp"); + + try + { + File.WriteAllText(path, "cuemon"); + + var result = new FileInfo(path).GetCacheValidator(); + + Assert.NotNull(result); + Assert.NotEqual(CacheValidator.Default.ToString(), result.ToString()); + TestOutput.WriteLine(result.ToString()); + } + finally + { + if (File.Exists(path)) { File.Delete(path); } + } + } + + [Fact] + public void GetCacheValidator_ShouldReturnDefault_WhenFileDoesNotExist() + { + var path = Path.Combine(AppContext.BaseDirectory, $"missing-file-{Guid.NewGuid():N}.tmp"); + var file = new FileInfo(path); + + var result = file.GetCacheValidator(setup: options => options.BytesToRead = 1); + + Assert.Equal(CacheValidator.Default.ToString(), result.ToString()); + TestOutput.WriteLine(result.ToString()); + } +} diff --git a/test/Cuemon.Extensions.Hosting.Tests/HostBuilderExtensionsTest.cs b/test/Cuemon.Extensions.Hosting.Tests/HostBuilderExtensionsTest.cs new file mode 100644 index 000000000..11aad469f --- /dev/null +++ b/test/Cuemon.Extensions.Hosting.Tests/HostBuilderExtensionsTest.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Cuemon.Extensions.Hosting; + +public class HostBuilderExtensionsTest : Test +{ + public HostBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ConfigureConfigurationSources_ShouldThrowArgumentNullException_WhenHostBuilderIsNull() + { + IHostBuilder hostBuilder = null; + + Assert.Throws(() => hostBuilder.ConfigureConfigurationSources((environment, sources) => { })); + } + + [Fact] + public void ConfigureConfigurationSources_ShouldThrowArgumentNullException_WhenDelegateIsNull() + { + Assert.Throws(() => Host.CreateDefaultBuilder().ConfigureConfigurationSources(null)); + } + + [Fact] + public void ConfigureConfigurationSources_ShouldInvokeDelegateWithEnvironmentAndSources_WhenHostIsBuilt() + { + var delegateCalled = false; + IHostEnvironment capturedEnvironment = null; + IList capturedSources = null; + + var hostBuilder = Host.CreateDefaultBuilder() + .ConfigureConfigurationSources((environment, sources) => + { + delegateCalled = true; + capturedEnvironment = environment; + capturedSources = sources; + }); + + using (var host = hostBuilder.Build()) + { + Assert.True(delegateCalled); + Assert.NotNull(capturedEnvironment); + Assert.NotNull(capturedSources); + } + } + + [Fact] + public void RemoveConfigurationSource_ShouldInvokePredicateForEachSource_WhenHostIsBuilt() + { + var predicateCalled = false; + IConfigurationSource sourceToRemove = null; + + var hostBuilder = Host.CreateDefaultBuilder() + .ConfigureAppConfiguration((context, builder) => + { + builder.AddInMemoryCollection(new Dictionary + { + { "Sentinel", "Present" } + }); + sourceToRemove = builder.Sources[builder.Sources.Count - 1]; + }) + .RemoveConfigurationSource((environment, source) => + { + predicateCalled = true; + return ReferenceEquals(source, sourceToRemove); + }); + + using (var host = hostBuilder.Build()) + { + var configuration = (IConfiguration)host.Services.GetService(typeof(IConfiguration)); + + Assert.True(predicateCalled); + Assert.NotNull(configuration); + Assert.Null(configuration["Sentinel"]); + } + } +} diff --git a/test/Cuemon.Extensions.Net.Tests/Http/HttpMethodExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/Http/HttpMethodExtensionsTest.cs new file mode 100644 index 000000000..32be7c436 --- /dev/null +++ b/test/Cuemon.Extensions.Net.Tests/Http/HttpMethodExtensionsTest.cs @@ -0,0 +1,25 @@ +using System; +using System.Net.Http; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Net.Http +{ + public class HttpMethodExtensionsTest : Test + { + public HttpMethodExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void HttpMethodExtensions_ShouldConvertMethods() + { + Assert.Equal(Cuemon.Net.Http.HttpMethods.Get, HttpMethod.Get.ToHttpMethod()); + Assert.Equal(Cuemon.Net.Http.HttpMethods.Post, HttpMethod.Post.ToHttpMethod()); + Assert.Equal(Cuemon.Net.Http.HttpMethods.Put, HttpMethod.Put.ToHttpMethod()); + Assert.Equal(Cuemon.Net.Http.HttpMethods.Delete, HttpMethod.Delete.ToHttpMethod()); + Assert.Equal(Cuemon.Net.Http.HttpMethods.Patch, new HttpMethod("PATCH").ToHttpMethod()); + Assert.Throws(() => HttpMethodExtensions.ToHttpMethod(null)); + } + } +} diff --git a/test/Cuemon.Extensions.Net.Tests/Http/SlimHttpClientFactoryTest.cs b/test/Cuemon.Extensions.Net.Tests/Http/SlimHttpClientFactoryTest.cs new file mode 100644 index 000000000..b29e2888d --- /dev/null +++ b/test/Cuemon.Extensions.Net.Tests/Http/SlimHttpClientFactoryTest.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Net.Http +{ + public class SlimHttpClientFactoryTest : Test + { + public SlimHttpClientFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void SlimHttpClientFactory_ShouldValidateNullHandlerFactory() + { + Assert.Throws(() => new SlimHttpClientFactory(null)); + } + + [Fact] + public void SlimHttpClientFactory_ShouldCreateClientsAndExposeOptions() + { + var invocations = 0; + var firstInner = new TrackingAwareHttpClientHandler(); + var created = new Queue(new[] { firstInner }); + var sut = new SlimHttpClientFactory(() => + { + invocations++; + return created.Dequeue(); + }, o => o.HandlerLifetime = TimeSpan.Zero); + + using var client = sut.CreateClient("alpha"); + + Assert.Equal(1, invocations); + Assert.NotNull(client); + } + +#if NET9_0_OR_GREATER + [Fact] + public void SlimHttpClientFactory_ShouldReuseHandlersAndProtectInnerHandlerOnDispose() + { + var invocations = 0; + var firstInner = new TrackingAwareHttpClientHandler(); + var secondInner = new TrackingAwareHttpClientHandler(); + var created = new Queue(new[] { firstInner, secondInner }); + var sut = new SlimHttpClientFactory(() => + { + invocations++; + return created.Dequeue(); + }, o => o.HandlerLifetime = TimeSpan.Zero); + var factory = (IHttpMessageHandlerFactory)sut; + var options = new SlimHttpClientFactoryOptions() { HandlerLifetime = TimeSpan.Zero }; + + var handlerA1 = factory.CreateHandler("alpha"); + var handlerA2 = factory.CreateHandler("alpha"); + var handlerB = factory.CreateHandler("beta"); + using var client = sut.CreateClient("alpha"); + + Assert.Same(handlerA1, handlerA2); + Assert.NotSame(handlerA1, handlerB); + Assert.Equal(2, invocations); + Assert.Equal(TimeSpan.FromSeconds(15), options.HandlerLifetime); + handlerA1.Dispose(); + Assert.False(firstInner.WasDisposed); + } + + [Fact] + public void SlimHttpClientFactory_ShouldCacheHandlersPerKey() + { + var invocations = 0; + var firstInner = new TrackingAwareHttpClientHandler(); + var secondInner = new TrackingAwareHttpClientHandler(); + var created = new Queue(new[] { firstInner, secondInner }); + var sut = new SlimHttpClientFactory(() => + { + invocations++; + return created.Dequeue(); + }, o => o.HandlerLifetime = TimeSpan.Zero); + var factory = (IHttpMessageHandlerFactory)sut; + + // Request handlers for different keys + var handler1 = factory.CreateHandler("key1"); + Assert.Equal(1, invocations); + + var handler2 = factory.CreateHandler("key1"); + Assert.Equal(1, invocations); // Should reuse cached handler for same key + + var handler3 = factory.CreateHandler("key2"); + Assert.Equal(2, invocations); // Should create new handler for different key + + // Verify caching and distinctness + Assert.Same(handler1, handler2); // Same key, same handler + Assert.NotSame(handler1, handler3); // Different keys, different handlers + } +#endif + + private sealed class TrackingAwareHttpClientHandler : HttpClientHandler + { + public bool WasDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + WasDisposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/test/Cuemon.Extensions.Net.Tests/Http/UriExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/Http/UriExtensionsTest.cs index 8cdc88bb0..f47b1a947 100644 --- a/test/Cuemon.Extensions.Net.Tests/Http/UriExtensionsTest.cs +++ b/test/Cuemon.Extensions.Net.Tests/Http/UriExtensionsTest.cs @@ -23,9 +23,11 @@ public UriExtensionsTest(ITestOutputHelper output) : base(output) [Fact] public async Task HttpGetAsync_ShouldGetResponseFromUri() { - var uri = new Uri("https://www.cuemon.net/"); + // Test SlimHttpClientFactory robustness under parallel load using a reliable external server + var uri = new Uri("https://httpbin.org/status/200"); var expected = 125; var atomicCount = 0; + await ParallelFactory.ForAsync(0, expected, async (i, ct) => { using (var response = await uri.HttpGetAsync(ct)) @@ -34,6 +36,27 @@ await ParallelFactory.ForAsync(0, expected, async (i, ct) => Assert.Equal(HttpStatusCode.OK, response.StatusCode); } }); + + Assert.Equal(expected, atomicCount); + } + + [Fact] + public async Task HttpGetAsync_ShouldHandleHttpStatusCodes() + { + // Test that the extension method properly returns non-OK status codes under parallel load + var uri = new Uri("https://httpbin.org/status/404"); + var expected = 50; + var atomicCount = 0; + + await ParallelFactory.ForAsync(0, expected, async (i, ct) => + { + using (var response = await uri.HttpGetAsync(ct)) + { + Interlocked.Increment(ref atomicCount); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + }); + Assert.Equal(expected, atomicCount); } } diff --git a/test/Cuemon.Extensions.Net.Tests/HttpStatusCodeExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/HttpStatusCodeExtensionsTest.cs new file mode 100644 index 000000000..7a2b04473 --- /dev/null +++ b/test/Cuemon.Extensions.Net.Tests/HttpStatusCodeExtensionsTest.cs @@ -0,0 +1,35 @@ +using System.Net; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Net +{ + public class HttpStatusCodeExtensionsTest : Test + { + public HttpStatusCodeExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData((HttpStatusCode)99, false, false, false, false, false)] + [InlineData((HttpStatusCode)100, true, false, false, false, false)] + [InlineData((HttpStatusCode)199, true, false, false, false, false)] + [InlineData((HttpStatusCode)200, false, true, false, false, false)] + [InlineData((HttpStatusCode)299, false, true, false, false, false)] + [InlineData((HttpStatusCode)300, false, false, true, false, false)] + [InlineData((HttpStatusCode)399, false, false, true, false, false)] + [InlineData((HttpStatusCode)400, false, false, false, true, false)] + [InlineData((HttpStatusCode)499, false, false, false, true, false)] + [InlineData((HttpStatusCode)500, false, false, false, false, true)] + [InlineData((HttpStatusCode)599, false, false, false, false, true)] + [InlineData((HttpStatusCode)600, false, false, false, false, false)] + public void HttpStatusCodeExtensions_ShouldMatchExpectedRanges(HttpStatusCode statusCode, bool information, bool success, bool redirection, bool clientError, bool serverError) + { + Assert.Equal(information, statusCode.IsInformationStatusCode()); + Assert.Equal(success, statusCode.IsSuccessStatusCode()); + Assert.Equal(redirection, statusCode.IsRedirectionStatusCode()); + Assert.Equal(clientError, statusCode.IsClientErrorStatusCode()); + Assert.Equal(serverError, statusCode.IsServerErrorStatusCode()); + } + } +} diff --git a/test/Cuemon.Extensions.Net.Tests/Security/UriExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/Security/UriExtensionsTest.cs new file mode 100644 index 000000000..d9448f28b --- /dev/null +++ b/test/Cuemon.Extensions.Net.Tests/Security/UriExtensionsTest.cs @@ -0,0 +1,32 @@ +using System; +using System.Security; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Net.Security +{ + public class UriExtensionsTest : Test + { + private static readonly byte[] Secret = Decorator.Enclose("1234").ToByteArray(); + + public UriExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void UriExtensions_ShouldSignAndValidateUris() + { + var location = new Uri("https://example.com/search?q=cuemon"); + var signed = location.ToSignedUri(Secret, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddMinutes(1)); + + signed.ValidateSignedUri(Secret); + + Assert.NotEqual(location, signed); + Assert.Throws(() => UriExtensions.ToSignedUri(null, Secret)); + Assert.Throws(() => location.ToSignedUri(null)); + Assert.Throws(() => UriExtensions.ValidateSignedUri(null, Secret)); + Assert.Throws(() => signed.ValidateSignedUri(null)); + Assert.Throws(() => new Uri("https://example.com/?q=cuemon").ValidateSignedUri(Secret)); + } + } +} diff --git a/test/Cuemon.Extensions.Net.Tests/StringExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/StringExtensionsTest.cs new file mode 100644 index 000000000..aecccfd6c --- /dev/null +++ b/test/Cuemon.Extensions.Net.Tests/StringExtensionsTest.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Net +{ + public class StringExtensionsTest : Test + { + public StringExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Extensions_ShouldEncodeAndFormatQueryValues() + { + var bytes = Encoding.ASCII.GetBytes("a &"); + var dictionary = new Dictionary + { + { "name", new[] { "Jane Doe" } }, + { "tag", new[] { "one", "two" } } + }; + var values = new NameValueCollection() + { + { "message", "hello world" }, + { "tag", "alpha,beta" } + }; + + Assert.Equal("a+%26", Encoding.ASCII.GetString(bytes.UrlEncode(0, bytes.Length))); + Assert.Equal("?name=Jane+Doe&tag=one&tag=two", dictionary.ToQueryString(true)); + Assert.Equal("?message=hello+world&tag=alpha&tag=beta", values.ToQueryString(true)); + Assert.Equal("hello+world", "hello world".UrlEncode()); + Assert.Equal("hello world", "hello+world".UrlDecode()); + Assert.Null(((string)null).UrlEncode()); + Assert.Throws(() => ByteArrayExtensions.UrlEncode(null, 0, 0)); + Assert.Throws(() => DictionaryExtensions.ToQueryString(null)); + Assert.Throws(() => NameValueCollectionExtensions.ToQueryString(null)); + } + } +} diff --git a/test/Cuemon.Extensions.Runtime.Caching.Tests/CacheEnumerableExtensionsTest.cs b/test/Cuemon.Extensions.Runtime.Caching.Tests/CacheEnumerableExtensionsTest.cs index a6ebc98ac..e34001883 100644 --- a/test/Cuemon.Extensions.Runtime.Caching.Tests/CacheEnumerableExtensionsTest.cs +++ b/test/Cuemon.Extensions.Runtime.Caching.Tests/CacheEnumerableExtensionsTest.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Cuemon.Extensions.Runtime.Caching.Assets; using Codebelt.Extensions.Xunit.Hosting; +using Cuemon.Runtime; using Cuemon.Runtime.Caching; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -934,12 +936,193 @@ public void Memoize_ShouldCacheAndReturnFunctionDelegateHavingFiveParameterUsing Assert.Equal(0, _cache.Count(CacheEnumerableExtensions.MemoizationScope)); } + [Fact] + public void GetOrAdd_ShouldUseDependencyOverload_AndReturnCachedValue() + { + var cache = CreateCacheForExtensionTests(); + var key = Generate.RandomString(16); + var dependency = new PassiveDependency(); + var factoryCalls = 0; + + var first = cache.GetOrAdd(key, dependency, () => + { + factoryCalls++; + return "alpha"; + }); + var second = cache.GetOrAdd(key, dependency, () => + { + factoryCalls++; + return "beta"; + }); + + Assert.Equal("alpha", first); + Assert.Equal("alpha", second); + Assert.Equal(1, factoryCalls); + Assert.True(cache.Contains(key)); + } + + [Fact] + public void GetOrAdd_ShouldUseDependenciesOverload_AndReturnCachedValue() + { + var cache = CreateCacheForExtensionTests(); + var key = Generate.RandomString(16); + var dependencies = CreateDependencies(); + var factoryCalls = 0; + + var first = cache.GetOrAdd(key, dependencies, () => + { + factoryCalls++; + return 42; + }); + var second = cache.GetOrAdd(key, dependencies, () => + { + factoryCalls++; + return 84; + }); + + Assert.Equal(42, first); + Assert.Equal(42, second); + Assert.Equal(1, factoryCalls); + Assert.True(cache.Contains(key)); + } + + [Fact] + public void GetOrAdd_ShouldUseInvalidationOverload_AndReturnCachedValueOnCacheHit() + { + var cache = CreateCacheForExtensionTests(); + var key = Generate.RandomString(16); + var invalidation = new CacheInvalidation(TimeSpan.FromMinutes(1)); + var factoryCalls = 0; + + var first = cache.GetOrAdd(key, invalidation, () => + { + factoryCalls++; + return Guid.Empty; + }); + var second = cache.GetOrAdd(key, invalidation, () => + { + factoryCalls++; + return Guid.NewGuid(); + }); + + Assert.Equal(Guid.Empty, first); + Assert.Equal(Guid.Empty, second); + Assert.Equal(1, factoryCalls); + Assert.True(cache.Contains(key)); + } + + [Fact] + public void GetOrAdd_ShouldThrowArgumentNullException_WhenCacheIsNull() + { + var invalidation = new CacheInvalidation(TimeSpan.FromMinutes(1)); + var exception = Assert.Throws(() => CacheEnumerableExtensions.GetOrAdd(null, "key", "scope", invalidation, () => "value")); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void GetOrAdd_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var cache = CreateCacheForExtensionTests(); + var invalidation = new CacheInvalidation(TimeSpan.FromMinutes(1)); + var exception = Assert.Throws(() => cache.GetOrAdd(null, "scope", invalidation, () => "value")); + + Assert.Equal("key", exception.ParamName); + } + + [Fact] + public void GetOrAdd_ShouldThrowArgumentNullException_WhenInvalidationIsNull() + { + var cache = CreateCacheForExtensionTests(); + var exception = Assert.Throws(() => CacheEnumerableExtensions.GetOrAdd(cache, "key", "scope", (CacheInvalidation)null, () => "value")); + + Assert.Equal("invalidation", exception.ParamName); + } + + [Fact] + public void GetOrAdd_ShouldThrowArgumentNullException_WhenValueFactoryIsNull() + { + var cache = CreateCacheForExtensionTests(); + var invalidation = new CacheInvalidation(TimeSpan.FromMinutes(1)); + var exception = Assert.Throws(() => cache.GetOrAdd("key", "scope", invalidation, null)); + + Assert.Equal("valueFactory", exception.ParamName); + } + + [Fact] + public void Memoize_ShouldCacheEnumerableDependencyOverloads() + { + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func(() => Generate.RandomString(5))), memoized => memoized(), 5); + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func(length => Generate.RandomString(length))), memoized => memoized(3), 3); + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func((a, b) => Generate.RandomString(a + b))), memoized => memoized(2, 3), 5); + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func((a, b, c) => Generate.RandomString(a + b + c))), memoized => memoized(1, 2, 4), 7); + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func((a, b, c, d) => Generate.RandomString(a + b + c + d))), memoized => memoized(1, 2, 3, 5), 11); + AssertMemoizeEnumerableDependencies(cache => cache.Memoize(CreateDependencies(), new Func((a, b, c, d, e) => Generate.RandomString(a + b + c + d + e))), memoized => memoized(1, 2, 3, 5, 2), 13); + } + + [Fact] + public void Memoize_ShouldUseArgumentValuesForCacheKeys_WhenArgumentsAreNullOrByteArrays() + { + var cache = CreateCacheForExtensionTests(); + var byteArrayCalls = 0; + var nullCalls = 0; + var invalidation = new CacheInvalidation(TimeSpan.FromMinutes(1)); + var memoizedBytes = cache.Memoize(invalidation, new Func(bytes => + { + byteArrayCalls++; + return Convert.ToBase64String(bytes ?? Array.Empty()); + })); + var memoizedNull = cache.Memoize(invalidation, new Func(value => + { + nullCalls++; + return value ?? "missing"; + })); + + Assert.Equal("AQID", memoizedBytes(new byte[] { 1, 2, 3 })); + Assert.Equal("AQID", memoizedBytes(new byte[] { 1, 2, 3 })); + Assert.Equal("missing", memoizedNull(null)); + Assert.Equal("missing", memoizedNull(null)); + Assert.Equal(1, byteArrayCalls); + Assert.Equal(1, nullCalls); + Assert.Equal(2, cache.Count(CacheEnumerableExtensions.MemoizationScope)); + } + private string ExpensiveRandomString() { Thread.Sleep(TimeSpan.FromSeconds(1)); return Generate.RandomString(17); } + private static SlimMemoryCache CreateCacheForExtensionTests() + { + return new SlimMemoryCache(o => o.EnableCleanup = false); + } + + private static IEnumerable CreateDependencies() + { + return new IDependency[] { new PassiveDependency(), new PassiveDependency() }; + } + + private static void AssertMemoizeEnumerableDependencies(Func factory, Func invoke, int expectedLength) + { + var cache = CreateCacheForExtensionTests(); + var memoized = factory(cache); + + var first = invoke(memoized); + var second = invoke(memoized); + + Assert.Equal(first, second); + Assert.Equal(expectedLength, first.Length); + Assert.Equal(1, cache.Count(CacheEnumerableExtensions.MemoizationScope)); + } + + private sealed class PassiveDependency : Dependency + { + public PassiveDependency() : base(_ => Array.Empty(), true) + { + } + } + public override void ConfigureServices(IServiceCollection services) { services.AddSingleton(); diff --git a/test/Cuemon.Extensions.Text.Json.Tests/Converters/JsonConverterCollectionExtensionsTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/Converters/JsonConverterCollectionExtensionsTest.cs index dd7afa2d3..d8932170e 100644 --- a/test/Cuemon.Extensions.Text.Json.Tests/Converters/JsonConverterCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.Text.Json.Tests/Converters/JsonConverterCollectionExtensionsTest.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using Cuemon.Diagnostics; using Cuemon.Extensions.IO; using Cuemon.Extensions.Text.Json.Formatters; @@ -226,5 +229,94 @@ public void AddDataPairConverter_ShouldAddDataPairConverterToConverterCollection TestOutput.WriteLine(json); }); } + + [Fact] + public void RemoveAllOf_ShouldRemoveMatchingConverters_FromGenericType() + { + var sut = new List() + { + new StringEnumConverter(), + new StringFlagsEnumConverter() + }; + + var result = sut.RemoveAllOf(); + + Assert.Same(sut, result); + Assert.Single(sut); + Assert.IsType(Assert.Single(sut)); + } + + [Fact] + public void RemoveAllOf_ShouldRemoveMatchingConverters_FromTypeCollection() + { + var sut = new List() + { + new StringEnumConverter(), + new StringFlagsEnumConverter() + }; + + var result = sut.RemoveAllOf(typeof(DayOfWeek), typeof(GuidFormats)); + + Assert.Same(sut, result); + Assert.Empty(sut); + } + + [Fact] + public void RemoveAllOf_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + ICollection sut = null; + var exception = Assert.Throws(() => sut.RemoveAllOf(typeof(DayOfWeek))); + + Assert.Equal("converters", exception.ParamName); + } + + [Fact] + public void RemoveAllOf_ShouldThrowArgumentNullException_WhenTypesIsNull() + { + ICollection sut = new List(); + var exception = Assert.Throws(() => JsonConverterCollectionExtensions.RemoveAllOf(sut, null)); + + Assert.Equal("types", exception.ParamName); + } + + [Fact] + public void AddTransientFaultExceptionConverter_ShouldAddConverterToCollection() + { + ICollection sut = new List(); + + var result = sut.AddTransientFaultExceptionConverter(); + + Assert.Same(sut, result); + Assert.IsType(Assert.Single(sut)); + } + + [Fact] + public void AddFailureConverter_ShouldAddConverterToCollection_AndSerializeFailure() + { + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = null + }; + options.Converters.AddFailureConverter(); + var sut = new Failure(new InvalidOperationException("Broken"), FaultSensitivityDetails.None); + + var json = JsonSerializer.Serialize(sut, options); + + Assert.Contains("\"Type\":\"System.InvalidOperationException\"", json); + Assert.Contains("\"Message\":\"Broken\"", json); + } + + [Fact] + public void AddExceptionConverter_ShouldAddConfiguredConverterToCollection() + { + ICollection sut = new List(); + + var result = sut.AddExceptionConverter(true, true); + + Assert.Same(sut, result); + var converter = Assert.IsType(Assert.Single(sut)); + Assert.True(converter.IncludeStackTrace); + Assert.True(converter.IncludeData); + } } } \ No newline at end of file diff --git a/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringEnumConverterTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringEnumConverterTest.cs new file mode 100644 index 000000000..8da6d4b46 --- /dev/null +++ b/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringEnumConverterTest.cs @@ -0,0 +1,36 @@ +using System; +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Text.Json.Converters +{ + public class StringEnumConverterTest : Test + { + public StringEnumConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CreateConverter_ShouldResolveOrFallback_DependingOnRuntimeSupport() + { + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = null + }; + options.Converters.Add(new StringEnumConverter()); + + var exception = Record.Exception(() => JsonSerializer.Serialize(DayOfWeek.Friday, options)); + + if (exception == null) + { + var json = JsonSerializer.Serialize(DayOfWeek.Friday, options); + Assert.Equal("\"Friday\"", json); + return; + } + + var notSupported = Assert.IsType(exception); + Assert.Equal("Unable to locate internal members required by this method.", notSupported.Message); + } + } +} diff --git a/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringFlagsEnumConverterTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringFlagsEnumConverterTest.cs new file mode 100644 index 000000000..98cd851db --- /dev/null +++ b/test/Cuemon.Extensions.Text.Json.Tests/Converters/StringFlagsEnumConverterTest.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Text.Json.Converters +{ + public class StringFlagsEnumConverterTest : Test + { + public StringFlagsEnumConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Write_ShouldWriteNullArray_WhenValueIsNull() + { + var sut = new StringFlagsEnumConverter(); + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = null + }; + var converter = sut.CreateConverter(typeof(GuidFormats), options); + + var json = InvokeWrite(converter, null, options); + + Assert.Equal("[null]", json); + } + + [Fact] + public void Write_ShouldWriteNothing_WhenEnumDoesNotHaveFlagsAttribute() + { + var sut = new StringFlagsEnumConverter(); + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = null + }; + var converter = sut.CreateConverter(typeof(DayOfWeek), options); + + var json = InvokeWrite(converter, DayOfWeek.Friday, options); + + Assert.False(sut.CanConvert(typeof(DayOfWeek))); + Assert.Equal(string.Empty, json); + } + + private static string InvokeWrite(JsonConverter converter, Enum value, JsonSerializerOptions options) + { + using (var stream = new MemoryStream()) + { + using (var writer = new Utf8JsonWriter(stream)) + { + var method = converter.GetType().GetMethod("Write", BindingFlags.Instance | BindingFlags.Public); + method.Invoke(converter, new object[] { writer, value, options }); + writer.Flush(); + } + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + } +} diff --git a/test/Cuemon.Extensions.Text.Json.Tests/DynamicJsonConverterTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/DynamicJsonConverterTest.cs new file mode 100644 index 000000000..469ab1843 --- /dev/null +++ b/test/Cuemon.Extensions.Text.Json.Tests/DynamicJsonConverterTest.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Text.Json +{ + public class DynamicJsonConverterTest : Test + { + public DynamicJsonConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldSerializeAndDeserializeUsingGenericDelegates() + { + var value = Guid.NewGuid(); + var options = new JsonSerializerOptions(); + options.Converters.Add(DynamicJsonConverter.Create( + (writer, guid, serializerOptions) => writer.WriteStringValue(guid.ToString("N")), + (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) => Guid.ParseExact(reader.GetString(), "N"))); + + var json = JsonSerializer.Serialize(value, options); + var result = JsonSerializer.Deserialize(json, options); + + Assert.Equal($"\"{value:N}\"", json); + Assert.Equal(value, result); + } + + [Fact] + public void Create_ShouldThrowNotImplementedException_WhenWriterDelegateIsNull() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(DynamicJsonConverter.Create(reader: (ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) => Guid.Parse(reader.GetString()))); + + var exception = Assert.Throws(() => JsonSerializer.Serialize(Guid.Empty, options)); + + Assert.Equal("Delegate writer is null.", exception.Message); + } + + [Fact] + public void Create_ShouldThrowNotImplementedException_WhenReaderDelegateIsNull() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(DynamicJsonConverter.Create((writer, guid, serializerOptions) => writer.WriteStringValue(guid.ToString("D")))); + + var exception = Assert.Throws(() => JsonSerializer.Deserialize("\"00000000-0000-0000-0000-000000000000\"", options)); + + Assert.Equal("Delegate reader is null.", exception.Message); + } + + [Fact] + public void Create_ShouldThrowArgumentNullException_WhenPredicateIsNull() + { + var exception = Assert.Throws(() => DynamicJsonConverter.Create(null, (writer, guid, serializerOptions) => writer.WriteStringValue(guid.ToString("D")))); + + Assert.Equal("predicate", exception.ParamName); + } + + [Fact] + public void Create_ShouldCreateConverterFactory_FromType() + { + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = null + }; + options.Converters.Add(DynamicJsonConverter.Create(typeof(DayOfWeek), (typeToConvert, serializerOptions) => new JsonStringEnumConverter().CreateConverter(typeToConvert, serializerOptions))); + + var json = JsonSerializer.Serialize(DayOfWeek.Friday, options); + + Assert.Equal("\"Friday\"", json); + } + + [Fact] + public void Create_ShouldThrowArgumentNullException_WhenTypeIsNull() + { + var exception = Assert.Throws(() => DynamicJsonConverter.Create((Type)null, (typeToConvert, serializerOptions) => new JsonStringEnumConverter().CreateConverter(typeToConvert, serializerOptions))); + + Assert.Equal("typeToConvert", exception.ParamName); + } + + [Fact] + public void Create_ShouldUseFactoryPredicate() + { + var sut = DynamicJsonConverter.Create(type => type == typeof(DayOfWeek), (typeToConvert, serializerOptions) => new JsonStringEnumConverter().CreateConverter(typeToConvert, serializerOptions)); + + Assert.True(sut.CanConvert(typeof(DayOfWeek))); + Assert.False(sut.CanConvert(typeof(Guid))); + } + + [Fact] + public void Create_ShouldThrowArgumentNullException_WhenFactoryPredicateIsNull() + { + var exception = Assert.Throws(() => DynamicJsonConverter.Create((Func)null, (typeToConvert, serializerOptions) => new JsonStringEnumConverter().CreateConverter(typeToConvert, serializerOptions))); + + Assert.Equal("predicate", exception.ParamName); + } + + [Fact] + public void Create_ShouldThrowArgumentNullException_WhenConverterFactoryIsNull() + { + var exception = Assert.Throws(() => DynamicJsonConverter.Create(type => type == typeof(Guid), (Func)null)); + + Assert.Equal("converterFactory", exception.ParamName); + } + } +} diff --git a/test/Cuemon.Extensions.Text.Json.Tests/Formatters/JsonFormatterOptionsTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/Formatters/JsonFormatterOptionsTest.cs index 22e75cb9c..e513e3b7f 100644 --- a/test/Cuemon.Extensions.Text.Json.Tests/Formatters/JsonFormatterOptionsTest.cs +++ b/test/Cuemon.Extensions.Text.Json.Tests/Formatters/JsonFormatterOptionsTest.cs @@ -26,7 +26,8 @@ public void JsonFormatterOptions_SettingsIsNull_ShouldThrowInvalidOperationExcep var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'Settings == null')", sut2.Message); - Assert.Equal("JsonFormatterOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.StartsWith("JsonFormatterOptions are not in a valid state.", sut3.Message, StringComparison.Ordinal); + Assert.Equal("sut1", sut3.ParamName); Assert.IsType(sut3.InnerException); } @@ -41,7 +42,8 @@ public void JsonFormatterOptions_SupportedMediaTypesIsNull_ShouldThrowInvalidOpe var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); Assert.Equal("Operation is not valid due to the current state of the object. (Expression 'SupportedMediaTypes == null')", sut2.Message); - Assert.Equal("JsonFormatterOptions are not in a valid state. (Parameter 'sut1')", sut3.Message); + Assert.StartsWith("JsonFormatterOptions are not in a valid state.", sut3.Message, StringComparison.Ordinal); + Assert.Equal("sut1", sut3.ParamName); Assert.IsType(sut3.InnerException); } diff --git a/test/Cuemon.Extensions.Text.Json.Tests/JsonNamingPolicyExtensionsTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/JsonNamingPolicyExtensionsTest.cs new file mode 100644 index 000000000..9c6b14aa7 --- /dev/null +++ b/test/Cuemon.Extensions.Text.Json.Tests/JsonNamingPolicyExtensionsTest.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Text.Json +{ + public class JsonNamingPolicyExtensionsTest : Test + { + public JsonNamingPolicyExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void DefaultOrConvertName_ShouldReturnOriginalName_WhenPolicyIsNull() + { + JsonNamingPolicy sut = null; + + var result = sut.DefaultOrConvertName("PascalCase"); + + Assert.Equal("PascalCase", result); + } + } +} diff --git a/test/Cuemon.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 000000000..f957cb7ab --- /dev/null +++ b/test/Cuemon.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,68 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Text.Json +{ + public class JsonSerializerOptionsExtensionsTest : Test + { + public JsonSerializerOptionsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Clone_ShouldCopySettings_AndApplySetup() + { + var sut = new JsonSerializerOptions() + { + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + sut.Converters.Add(new JsonStringEnumConverter()); + + var clone = sut.Clone(o => + { + o.WriteIndented = true; + o.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + + Assert.NotSame(sut, clone); + Assert.True(clone.AllowTrailingCommas); + Assert.True(clone.WriteIndented); + Assert.Equal(JsonIgnoreCondition.WhenWritingNull, clone.DefaultIgnoreCondition); + Assert.Equal(sut.PropertyNamingPolicy, clone.PropertyNamingPolicy); + Assert.Single(clone.Converters); + + clone.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + + Assert.Single(sut.Converters); + Assert.Equal(2, clone.Converters.Count); + } + + [Fact] + public void Clone_ShouldThrowArgumentNullException_WhenOptionsIsNull() + { + var exception = Assert.Throws(() => JsonSerializerOptionsExtensions.Clone(null)); + + Assert.Equal("options", exception.ParamName); + } + + [Theory] + [InlineData(true, "pascalCase")] + [InlineData(false, "PascalCase")] + public void SetPropertyName_ShouldHonorNamingPolicy(bool useCamelCase, string expected) + { + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = useCamelCase ? JsonNamingPolicy.CamelCase : null + }; + + var result = options.SetPropertyName("PascalCase"); + + Assert.Equal(expected, result); + } + } +} diff --git a/test/Cuemon.Extensions.Text.Json.Tests/TypeArgumentOutOfRangeExceptionTest.cs b/test/Cuemon.Extensions.Text.Json.Tests/TypeArgumentOutOfRangeExceptionTest.cs index 61553f967..f3e757664 100644 --- a/test/Cuemon.Extensions.Text.Json.Tests/TypeArgumentOutOfRangeExceptionTest.cs +++ b/test/Cuemon.Extensions.Text.Json.Tests/TypeArgumentOutOfRangeExceptionTest.cs @@ -34,6 +34,16 @@ public void TypeArgumentOutOfRangeException_ShouldBeSerializable_Json() Assert.Equal(sut1.ActualValue!.ToString(), original.ActualValue!.ToString()); Assert.Equal(sut1.Message, original.Message); Assert.Equal(sut1.ToString(), original.ToString()); +#if NET48_OR_GREATER + Assert.Equal($$""" + { + "type": "Cuemon.TypeArgumentOutOfRangeException", + "message": "{{randomMessage}}\r\nParameter name: {{randomParamName}}\r\nActual value was {{actualValue}}.", + "actualValue": {{actualValue}}, + "paramName": "{{randomParamName}}" + } + """, sut4); +#else var newline = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"\r\n" : @"\n"; Assert.Equal($$""" { @@ -43,6 +53,7 @@ public void TypeArgumentOutOfRangeException_ShouldBeSerializable_Json() "paramName": "{{randomParamName}}" } """.ReplaceLineEndings(), sut4); +#endif } } } diff --git a/test/Cuemon.Extensions.Xml.Tests/HierarchyExtensionsTest.cs b/test/Cuemon.Extensions.Xml.Tests/HierarchyExtensionsTest.cs new file mode 100644 index 000000000..7cfa90c80 --- /dev/null +++ b/test/Cuemon.Extensions.Xml.Tests/HierarchyExtensionsTest.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Serialization; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.Runtime.Serialization; +using Cuemon.Extensions.Xml.Assets; +using Cuemon.Xml.Serialization; +using Xunit; + +namespace Cuemon.Extensions.Xml +{ + public class HierarchyExtensionsTest : Test + { + public HierarchyExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void DateTimeAndHierarchyExtensions_ShouldExposeXmlMetadata() + { + var dateTime = new DateTime(2024, 6, 7, 8, 9, 10, DateTimeKind.Utc); + var nodes = new HierarchySerializer(new HierarchyExample()).Nodes; + var children = nodes.GetChildren().ToList(); + var idNode = children.Single(child => child.MemberReference?.Name == "Id"); + var animalsNode = children.Single(child => child.MemberReference?.Name == "Animals"); + var ownerNameNode = children.Single(child => child.MemberReference?.Name == "Owner").GetChildren().Single(child => child.MemberReference?.Name == "Name"); + var ignoredNode = new HierarchySerializer(new IgnoreExample()).Nodes.GetChildren().Single(child => child.MemberReference?.Name == "Hidden"); + var overrideEntity = new XmlQualifiedEntity("Override"); + var reordered = new[] { ownerNameNode, idNode }.OrderByXmlAttributes().ToList(); + + Assert.Equal(System.Xml.XmlConvert.ToString(dateTime, XmlDateTimeSerializationMode.RoundtripKind), dateTime.ToString(XmlDateTimeSerializationMode.RoundtripKind)); + Assert.False(idNode.HasXmlIgnoreAttribute()); + Assert.True(ignoredNode.HasXmlIgnoreAttribute()); + Assert.True(animalsNode.IsNodeEnumerable()); + Assert.False(ownerNameNode.IsNodeEnumerable()); + Assert.Equal("Id", idNode.GetXmlQualifiedEntity().LocalName); + Assert.Same(overrideEntity, idNode.GetXmlQualifiedEntity(overrideEntity)); + Assert.Equal("Id", reordered[0].MemberReference.Name); + Assert.Throws(() => HierarchyExtensions.HasXmlIgnoreAttribute(null)); + Assert.Throws(() => HierarchyExtensions.IsNodeEnumerable(null)); + Assert.Throws(() => HierarchyExtensions.GetXmlQualifiedEntity(null, null)); + Assert.Throws(() => HierarchyExtensions.OrderByXmlAttributes(null)); + } + + public class IgnoreExample + { + [XmlIgnore] + public string Hidden => "ignored"; + + public string Visible => "shown"; + } + } +} diff --git a/test/Cuemon.Extensions.Xml.Tests/Linq/XElementExtensionsTest.cs b/test/Cuemon.Extensions.Xml.Tests/Linq/XElementExtensionsTest.cs new file mode 100644 index 000000000..fd58f0530 --- /dev/null +++ b/test/Cuemon.Extensions.Xml.Tests/Linq/XElementExtensionsTest.cs @@ -0,0 +1,23 @@ +using System.Xml.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Extensions.Xml.Linq +{ + public class XElementExtensionsTest : Test + { + public XElementExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void StringExtensions_ShouldParseAndValidateXmlStrings() + { + Assert.True(" ".TryParseXElement(LoadOptions.PreserveWhitespace, out var element)); + Assert.Equal("root", element.Name.LocalName); + Assert.True("".IsXmlString()); + Assert.False("not-xml".TryParseXElement(out _)); + Assert.False(string.Empty.IsXmlString()); + } + } +} diff --git a/test/Cuemon.Extensions.Xml.Tests/Serialization/Converters/XmlConverterExtensionsTest.cs b/test/Cuemon.Extensions.Xml.Tests/Serialization/Converters/XmlConverterExtensionsTest.cs new file mode 100644 index 000000000..5b4b2a266 --- /dev/null +++ b/test/Cuemon.Extensions.Xml.Tests/Serialization/Converters/XmlConverterExtensionsTest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Xml; +using Codebelt.Extensions.Xunit; +using Cuemon.Diagnostics; +using Cuemon.Xml.Serialization; +using Cuemon.Xml.Serialization.Converters; +using Xunit; + +namespace Cuemon.Extensions.Xml.Serialization.Converters +{ + public class XmlConverterExtensionsTest : Test + { + public XmlConverterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void XmlConverterExtensions_ShouldAddAndLocateConverters() + { + IList converters = new List(); + + converters.InsertXmlConverter(0, (writer, value, entity) => writer.WriteElementString(entity?.LocalName ?? "Value", value), (reader, type) => "alpha", type => type == typeof(string), new XmlQualifiedEntity("String")); + converters.AddXmlConverter((writer, value, entity) => writer.WriteElementString(entity?.LocalName ?? "Value", value.ToString()), (reader, type) => 42, type => type == typeof(int), new XmlQualifiedEntity("Int32")); + converters.AddEnumerableConverter(); + converters.AddExceptionDescriptorConverter(o => { }); + converters.AddUriConverter(); + converters.AddDateTimeConverter(); + converters.AddTimeSpanConverter(); + converters.AddStringConverter(); + converters.AddExceptionConverter(true, true); + converters.AddFailureConverter(); + + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(string))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(string))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(int))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(int))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(List))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(List))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(Uri))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(Uri))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(DateTime))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(DateTime))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(TimeSpan))); + Assert.NotNull(converters.FirstOrDefaultReaderConverter(typeof(TimeSpan))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(Failure))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(Exception))); + Assert.NotNull(converters.FirstOrDefaultWriterConverter(typeof(ExceptionDescriptor))); + Assert.Throws(() => XmlConverterExtensions.FirstOrDefaultWriterConverter(null, typeof(string))); + } + } +} diff --git a/test/Cuemon.Extensions.Xml.Tests/Serialization/XmlSerializerOptionsExtensionsTest.cs b/test/Cuemon.Extensions.Xml.Tests/Serialization/XmlSerializerOptionsExtensionsTest.cs new file mode 100644 index 000000000..d5f466348 --- /dev/null +++ b/test/Cuemon.Extensions.Xml.Tests/Serialization/XmlSerializerOptionsExtensionsTest.cs @@ -0,0 +1,32 @@ +using System; +using Codebelt.Extensions.Xunit; +using Cuemon.Xml.Serialization; +using Xunit; + +namespace Cuemon.Extensions.Xml.Serialization +{ + public class XmlSerializerOptionsExtensionsTest : Test + { + public XmlSerializerOptionsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void XmlSerializerOptionsExtensions_ShouldApplyDefaultSettings() + { + var previous = XmlConvert.DefaultSettings; + var options = new XmlSerializerOptions(); + try + { + options.ApplyToDefaultSettings(); + + Assert.Same(options, XmlConvert.DefaultSettings()); + Assert.Throws(() => XmlSerializerOptionsExtensions.ApplyToDefaultSettings(null)); + } + finally + { + XmlConvert.DefaultSettings = previous; + } + } + } +} diff --git a/test/Cuemon.Extensions.Xml.Tests/XmlExtensionsTest.cs b/test/Cuemon.Extensions.Xml.Tests/XmlExtensionsTest.cs new file mode 100644 index 000000000..07a89f4b2 --- /dev/null +++ b/test/Cuemon.Extensions.Xml.Tests/XmlExtensionsTest.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using System.Text; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Xunit; + +namespace Cuemon.Extensions.Xml +{ + public class XmlExtensionsTest : Test + { + public XmlExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void XmlExtensions_ShouldCreateReadersFromBytesStreamsAndUris() + { + var xml = "42"; + var bytes = Encoding.UTF8.GetBytes(xml); + var filePath = Path.Combine(Environment.CurrentDirectory, $"xml-{Guid.NewGuid():N}.xml"); + File.WriteAllText(filePath, xml, Encoding.UTF8); + try + { + using var byteReader = bytes.ToXmlReader(o => o.IgnoreComments = true); + using var streamReader = new MemoryStream(bytes).ToXmlReader(); + using var uriReader = new Uri(filePath).ToXmlReader(); + + Assert.True(byteReader.MoveToFirstElement()); + Assert.True(streamReader.MoveToFirstElement()); + Assert.True(uriReader.MoveToFirstElement()); + Assert.Equal("root", byteReader.LocalName); + Assert.Equal("root", streamReader.LocalName); + Assert.Equal("root", uriReader.LocalName); + Assert.Throws(() => ByteArrayExtensions.ToXmlReader(null)); + Assert.Throws(() => StreamExtensions.ToXmlReader(null)); + Assert.Throws(() => UriExtensions.ToXmlReader(null)); + } + finally + { + File.Delete(filePath); + } + } + } +} diff --git a/test/Cuemon.IO.Tests/StreamFactoryTest.cs b/test/Cuemon.IO.Tests/StreamFactoryTest.cs new file mode 100644 index 000000000..97d8293bb --- /dev/null +++ b/test/Cuemon.IO.Tests/StreamFactoryTest.cs @@ -0,0 +1,198 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +#if NET9_0_OR_GREATER +using System.Buffers; +#endif +using System.Text; +using Codebelt.Extensions.Xunit; +using Cuemon.Text; +using Xunit; + +namespace Cuemon.IO +{ + public class StreamFactoryTest : Test + { + public StreamFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriter() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + var sut = StreamFactory.Create(writer => + { + Assert.Same(culture, writer.FormatProvider); + Assert.True(writer.AutoFlush); + Assert.Equal("\n", writer.NewLine); + writer.Write("{0:N2}", 42.5m); + writer.WriteLine(); + writer.Write("done"); + }, o => + { + o.AutoFlush = true; + o.FormatProvider = culture; + o.NewLine = "\n"; + o.Encoding = new UTF8Encoding(true); + }); + + Assert.Equal("42.50\ndone", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriterAndOneArgument() + { + var sut = StreamFactory.Create((writer, value) => writer.Write($"arg:{value}"), 42); + + Assert.Equal("arg:42", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriterAndTwoArguments() + { + var sut = StreamFactory.Create((writer, prefix, value) => writer.Write($"{prefix}:{value}"), "arg", 42); + + Assert.Equal("arg:42", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriterAndThreeArguments() + { + var sut = StreamFactory.Create((writer, a, b, c) => writer.Write($"{a}:{b}:{c}"), "a", "b", "c"); + + Assert.Equal("a:b:c", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriterAndFourArguments() + { + var sut = StreamFactory.Create((writer, a, b, c, d) => writer.Write($"{a}:{b}:{c}:{d}"), "a", "b", "c", "d"); + + Assert.Equal("a:b:c:d", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfStreamWriterAndFiveArguments() + { + var sut = StreamFactory.Create((writer, a, b, c, d, e) => writer.Write($"{a}:{b}:{c}:{d}:{e}"), "a", "b", "c", "d", "e"); + + Assert.Equal("a:b:c:d:e", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldRemovePreamble_WhenConfiguredForStreamWriter() + { + var encoding = Encoding.Unicode; + var sut = StreamFactory.Create(writer => writer.Write("hello world"), o => + { + o.Encoding = encoding; + o.Preamble = PreambleSequence.Remove; + }); + var bytes = Decorator.Enclose(sut).ToByteArray(o => o.LeaveOpen = true); + var preamble = encoding.GetPreamble(); + + Assert.Equal("hello world", ReadAsString(sut, encoding)); + Assert.False(bytes.Take(preamble.Length).SequenceEqual(preamble)); + } + + [Fact] + public void Create_ShouldWrapExceptionsFromWriterDelegate() + { + var ex = Assert.Throws(() => StreamFactory.Create((StreamWriter writer) => throw new FormatException("boom"))); + + Assert.Equal("There is an error in the Stream being written.", ex.Message); + Assert.IsType(ex.InnerException); + Assert.Equal("boom", ex.InnerException.Message); + } + +#if NET9_0_OR_GREATER + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriter() + { + var encoding = new UTF8Encoding(true); + var sut = StreamFactory.Create(writer => WriteBuffer(writer, encoding, "arg:zero", true), o => + { + o.BufferSize = 8; + o.Encoding = encoding; + o.Preamble = PreambleSequence.Remove; + }); + var bytes = Decorator.Enclose(sut).ToByteArray(o => o.LeaveOpen = true); + var preamble = encoding.GetPreamble(); + + Assert.Equal("arg:zero", ReadAsString(sut, encoding)); + Assert.False(bytes.Take(preamble.Length).SequenceEqual(preamble)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriterAndOneArgument() + { + var sut = StreamFactory.Create((writer, value) => WriteBuffer(writer, Encoding.UTF8, $"arg:{value}"), 42); + + Assert.Equal("arg:42", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriterAndTwoArguments() + { + var sut = StreamFactory.Create((writer, prefix, value) => WriteBuffer(writer, Encoding.UTF8, $"{prefix}:{value}"), "arg", 42); + + Assert.Equal("arg:42", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriterAndThreeArguments() + { + var sut = StreamFactory.Create((writer, a, b, c) => WriteBuffer(writer, Encoding.UTF8, $"{a}:{b}:{c}"), "a", "b", "c"); + + Assert.Equal("a:b:c", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriterAndFourArguments() + { + var sut = StreamFactory.Create((writer, a, b, c, d) => WriteBuffer(writer, Encoding.UTF8, $"{a}:{b}:{c}:{d}"), "a", "b", "c", "d"); + + Assert.Equal("a:b:c:d", ReadAsString(sut)); + } + + [Fact] + public void Create_ShouldWriteContent_WhenUsingActionOfBufferWriterAndFiveArguments() + { + var sut = StreamFactory.Create((writer, a, b, c, d, e) => WriteBuffer(writer, Encoding.UTF8, $"{a}:{b}:{c}:{d}:{e}"), "a", "b", "c", "d", "e"); + + Assert.Equal("a:b:c:d:e", ReadAsString(sut)); + } + + private static void WriteBuffer(IBufferWriter writer, Encoding encoding, string value, bool includePreamble = false) + { + if (includePreamble) + { + WriteBytes(writer, encoding.GetPreamble()); + } + + WriteBytes(writer, encoding.GetBytes(value)); + } + + private static void WriteBytes(IBufferWriter writer, byte[] bytes) + { + var span = writer.GetSpan(bytes.Length); + bytes.AsSpan().CopyTo(span); + writer.Advance(bytes.Length); + } + +#endif + + private static string ReadAsString(Stream stream, Encoding encoding = null) + { + return Decorator.Enclose(stream).ToEncodedString(o => + { + o.Encoding = encoding ?? Encoding.UTF8; + o.LeaveOpen = true; + o.Preamble = PreambleSequence.Remove; + }); + } + } +} diff --git a/test/Cuemon.IO.Tests/StreamOptionsTest.cs b/test/Cuemon.IO.Tests/StreamOptionsTest.cs new file mode 100644 index 000000000..d34a59657 --- /dev/null +++ b/test/Cuemon.IO.Tests/StreamOptionsTest.cs @@ -0,0 +1,122 @@ +using System; +using System.Globalization; +#if NET9_0_OR_GREATER +using System.Buffers; +#endif +using System.IO; +using System.Text; +using Codebelt.Extensions.Xunit; +using Cuemon.Text; +using Xunit; + +namespace Cuemon.IO +{ + public class StreamOptionsTest : Test + { + public StreamOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void StreamCopyOptions_ShouldHaveExpectedDefaultsAndValidateBufferSize() + { + var sut = new StreamCopyOptions(); + + Assert.False(sut.LeaveOpen); + Assert.Equal(81920, sut.BufferSize); + Assert.Throws(() => sut.BufferSize = 0); + } + + [Fact] + public void StreamEncodingOptions_ShouldHaveExpectedDefaults() + { + var sut = new StreamEncodingOptions(); + + Assert.Equal(EncodingOptions.DefaultEncoding, sut.Encoding); + Assert.Equal(EncodingOptions.DefaultPreambleSequence, sut.Preamble); + Assert.False(sut.LeaveOpen); + } + + [Fact] + public void StreamWriterOptions_ShouldHaveExpectedDefaultsAndAllowCustomization() + { + var sut = new StreamWriterOptions(); + var culture = CultureInfo.GetCultureInfo("da-DK"); + + Assert.False(sut.AutoFlush); + Assert.Equal(1024, sut.BufferSize); + Assert.Equal(EncodingOptions.DefaultEncoding, sut.Encoding); + Assert.Equal(PreambleSequence.Keep, sut.Preamble); + Assert.Equal(CultureInfo.InvariantCulture, sut.FormatProvider); + Assert.Equal(Environment.NewLine, sut.NewLine); + + sut.AutoFlush = true; + sut.BufferSize = 256; + sut.Encoding = Encoding.Unicode; + sut.Preamble = PreambleSequence.Remove; + sut.FormatProvider = culture; + sut.NewLine = "\n"; + + Assert.True(sut.AutoFlush); + Assert.Equal(256, sut.BufferSize); + Assert.Equal(Encoding.Unicode, sut.Encoding); + Assert.Equal(PreambleSequence.Remove, sut.Preamble); + Assert.Equal(culture, sut.FormatProvider); + Assert.Equal("\n", sut.NewLine); + } + + [Fact] + public void StreamReaderOptions_ShouldHaveExpectedDefaultsAndAllowCustomization() + { + var sut = new StreamReaderOptions(); + + Assert.Equal(81920, sut.BufferSize); + Assert.Equal(EncodingOptions.DefaultEncoding, sut.Encoding); + Assert.Equal(EncodingOptions.DefaultPreambleSequence, sut.Preamble); + + sut.BufferSize = 2048; + sut.Encoding = Encoding.Unicode; + sut.Preamble = PreambleSequence.Remove; + + Assert.Equal(2048, sut.BufferSize); + Assert.Equal(Encoding.Unicode, sut.Encoding); + Assert.Equal(PreambleSequence.Remove, sut.Preamble); + } + +#if NET9_0_OR_GREATER + + [Fact] + public void BufferWriterOptions_ShouldHaveExpectedDefaultsAndAllowCustomization() + { + var sut = new BufferWriterOptions(); + + Assert.Equal(256, sut.BufferSize); + Assert.Equal(EncodingOptions.DefaultEncoding, sut.Encoding); + Assert.Equal(PreambleSequence.Keep, sut.Preamble); + Assert.False(sut.LeaveOpen); + + sut.BufferSize = 32; + sut.Encoding = Encoding.Unicode; + sut.Preamble = PreambleSequence.Remove; + + Assert.Equal(32, sut.BufferSize); + Assert.Equal(Encoding.Unicode, sut.Encoding); + Assert.Equal(PreambleSequence.Remove, sut.Preamble); + } + +#endif + + [Fact] + public void FileInfoOptions_ShouldHaveExpectedDefaultsAndValidateBytesToRead() + { + var sut = new FileInfoOptions(); + + Assert.Equal(0, sut.BytesToRead); + + sut.BytesToRead = 128; + + Assert.Equal(128, sut.BytesToRead); + Assert.Throws(() => sut.BytesToRead = -1); + } + } +} diff --git a/test/Cuemon.IO.Tests/TextReaderDecoratorExtensionsTest.cs b/test/Cuemon.IO.Tests/TextReaderDecoratorExtensionsTest.cs new file mode 100644 index 000000000..c628c8428 --- /dev/null +++ b/test/Cuemon.IO.Tests/TextReaderDecoratorExtensionsTest.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.IO +{ + public class TextReaderDecoratorExtensionsTest : Test + { + public TextReaderDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task CopyToAsync_ShouldCopyTextToWriter() + { + using (var reader = new StringReader("Alpha Beta Gamma")) + using (var writer = new StringWriter()) + { + await Decorator.Enclose(reader).CopyToAsync(writer, 4); + + Assert.Equal("Alpha Beta Gamma", writer.ToString()); + } + } + + [Fact] + public async Task CopyToAsync_ShouldThrowArgumentNullException_WhenDecoratorIsNull() + { + IDecorator sut = null; + + await Assert.ThrowsAsync(() => sut.CopyToAsync(new StringWriter())); + } + + [Fact] + public async Task CopyToAsync_ShouldThrowArgumentNullException_WhenWriterIsNull() + { + using (var reader = new StringReader("Alpha Beta Gamma")) + { + await Assert.ThrowsAsync(() => Decorator.Enclose(reader).CopyToAsync(null)); + } + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task CopyToAsync_ShouldThrowArgumentOutOfRangeException_WhenBufferSizeIsInvalid(int bufferSize) + { + using (var reader = new StringReader("Alpha Beta Gamma")) + using (var writer = new StringWriter()) + { + await Assert.ThrowsAsync(() => Decorator.Enclose(reader).CopyToAsync(writer, bufferSize)); + } + } + } +} diff --git a/test/Cuemon.Net.Tests/ByteArrayDecoratorExtensionsTest.cs b/test/Cuemon.Net.Tests/ByteArrayDecoratorExtensionsTest.cs new file mode 100644 index 000000000..778a3c0ad --- /dev/null +++ b/test/Cuemon.Net.Tests/ByteArrayDecoratorExtensionsTest.cs @@ -0,0 +1,29 @@ +using System; +using System.Text; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Net +{ + public class ByteArrayDecoratorExtensionsTest : Test + { + public ByteArrayDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ByteArrayDecoratorExtensions_ShouldEncodeBytesAndValidateRanges() + { + var bytes = Encoding.ASCII.GetBytes("a &"); + IDecorator decorator = null; + + var encoded = Decorator.Enclose(bytes).UrlEncode(0, bytes.Length); + + Assert.Equal("a+%26", Encoding.ASCII.GetString(encoded)); + Assert.Empty(Decorator.Enclose(Array.Empty()).UrlEncode()); + Assert.Throws(() => ByteArrayDecoratorExtensions.UrlEncode(decorator, 0, 0)); + Assert.Throws(() => Decorator.Enclose(bytes).UrlEncode(-1, bytes.Length)); + Assert.Throws(() => Decorator.Enclose(bytes).UrlEncode(0, bytes.Length + 1)); + } + } +} diff --git a/test/Cuemon.Net.Tests/Collections/Specialized/NameValueCollectionDecoratorExtensionsTest.cs b/test/Cuemon.Net.Tests/Collections/Specialized/NameValueCollectionDecoratorExtensionsTest.cs new file mode 100644 index 000000000..0d20373a9 --- /dev/null +++ b/test/Cuemon.Net.Tests/Collections/Specialized/NameValueCollectionDecoratorExtensionsTest.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Specialized; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Net.Collections.Specialized +{ + public class NameValueCollectionDecoratorExtensionsTest : Test + { + public NameValueCollectionDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void NameValueCollectionDecoratorExtensions_ShouldFormatCollectionsWithDifferentSeparators() + { + var values = new NameValueCollection() + { + { "name", "Jane Doe" }, + { "tags", "one,two" } + }; + IDecorator decorator = null; + + var ampersand = Decorator.Enclose(values).ToString(FieldValueSeparator.Ampersand, true); + var semicolon = Decorator.Enclose(values).ToString(FieldValueSeparator.Semicolon, false); + + Assert.Equal("?name=Jane+Doe&tags=one&tags=two", ampersand); + Assert.Equal("name=Jane Doe;tags=one;tags=two;", semicolon); + Assert.Throws(() => NameValueCollectionDecoratorExtensions.ToString(decorator, FieldValueSeparator.Ampersand, false)); + Assert.Throws(() => Decorator.Enclose(values).ToString((FieldValueSeparator)99, false)); + } + } +} diff --git a/test/Cuemon.Net.Tests/Http/HttpDependencyTest.cs b/test/Cuemon.Net.Tests/Http/HttpDependencyTest.cs new file mode 100644 index 000000000..2a2de1f41 --- /dev/null +++ b/test/Cuemon.Net.Tests/Http/HttpDependencyTest.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Net.Http; +using Xunit; + +namespace Cuemon.Net.Http +{ + /// + /// Tests for the class. + /// + public class HttpDependencyTest : Test + { + public HttpDependencyTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task HttpDependency_ShouldRaiseDependencyChanged_WhenWatcherSignals() + { + var handler = new SequenceHttpMessageHandler(_ => ResponseWithHeaders("\"v1\"", null), _ => ResponseWithHeaders("\"v2\"", null)); + var watcher = new TestHttpWatcher(new Uri("https://example.com/dependency"), o => + { + o.ClientFactory = () => new HttpClient(handler, false); + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + await watcher.SignalAsync(); + + var dependency = new HttpDependency(new Lazy(() => watcher)); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + dependency.DependencyChanged += (_, e) => changed.TrySetResult(e.UtcLastModified); + + await dependency.StartAsync(); + watcher.ChangeSignaling(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + + var modified = await WaitOrThrowAsync(changed.Task, TimeSpan.FromSeconds(5)); + Assert.True(dependency.HasChanged); + Assert.Equal(modified, dependency.UtcLastModified); + Assert.Throws(() => new HttpDependency((Lazy)null)); + } + + private static async Task WaitOrThrowAsync(Task task, TimeSpan timeout) + { + var timeoutTask = Task.Delay(timeout); + if (await Task.WhenAny(task, timeoutTask) != task) { throw new TimeoutException(); } + return await task; + } + + private static HttpResponseMessage ResponseWithHeaders(string entityTag, DateTimeOffset? lastModified) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(System.Array.Empty()) + }; + if (entityTag != null) { response.Headers.ETag = new EntityTagHeaderValue(entityTag); } + if (lastModified.HasValue) { response.Content.Headers.LastModified = lastModified; } + return response; + } + + private sealed class SequenceHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses; + + public SequenceHttpMessageHandler(params Func[] responses) + { + _responses = new Queue>(responses); + } + + public List Requests { get; } = new List(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responses.Dequeue().Invoke(request)); + } + } + + private sealed class TestHttpWatcher : HttpWatcher + { + public TestHttpWatcher(Uri location, Action setup = null) : base(location, setup) + { + } + + public Task SignalAsync() + { + return HandleSignalingAsync(); + } + } + } +} diff --git a/test/Cuemon.Net.Tests/Http/HttpManagerTest.cs b/test/Cuemon.Net.Tests/Http/HttpManagerTest.cs new file mode 100644 index 000000000..068460eb5 --- /dev/null +++ b/test/Cuemon.Net.Tests/Http/HttpManagerTest.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Net.Http; +using Xunit; + +namespace Cuemon.Net.Http +{ + /// + /// Tests for the class. + /// + public class HttpManagerTest : Test + { + public HttpManagerTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task HttpManager_ShouldSendRequestsAndValidateArguments() + { + var handler = new RecordingHttpMessageHandler(); + using var manager = new HttpManager(() => new HttpClient(handler, false)); + var location = new Uri("https://example.com/resource"); + + using (await manager.HttpDeleteAsync(location)) { } + using (await manager.HttpGetAsync(location)) { } + using (await manager.HttpHeadAsync(location)) { } + using (await manager.HttpOptionsAsync(location)) { } + using (await manager.HttpTraceAsync(location)) { } + using (await manager.HttpPostAsync(location, "text/plain", ToStream("alpha"))) { } + using (await manager.HttpPostAsync(location, MediaTypeHeaderValue.Parse("application/json"), ToStream("{}"))) { } + using (await manager.HttpPutAsync(location, "text/plain", ToStream("beta"))) { } + using (await manager.HttpPutAsync(location, MediaTypeHeaderValue.Parse("application/json"), ToStream("{}"))) { } + using (await manager.HttpPatchAsync(location, "text/plain", ToStream("gamma"))) { } + using (await manager.HttpPatchAsync(location, MediaTypeHeaderValue.Parse("application/json"), ToStream("{}"))) { } + using (await manager.HttpAsync(HttpMethod.Post, location, "application/xml", ToStream(""))) { } + using (await manager.HttpAsync(HttpMethod.Put, location, MediaTypeHeaderValue.Parse("application/octet-stream"), ToStream("bin"))) { } + using (await manager.HttpAsync(location, o => o.Request.Method = HttpMethod.Get)) { } + + Assert.Equal(new[] + { + HttpMethod.Delete.Method, + HttpMethod.Get.Method, + HttpMethod.Head.Method, + HttpMethod.Options.Method, + HttpMethod.Trace.Method, + HttpMethod.Post.Method, + HttpMethod.Post.Method, + HttpMethod.Put.Method, + HttpMethod.Put.Method, + "PATCH", + "PATCH", + HttpMethod.Post.Method, + HttpMethod.Put.Method, + HttpMethod.Get.Method + }, handler.Requests.Select(r => r.Method.Method).ToArray()); + Assert.Equal("text/plain", handler.Requests[5].Content.Headers.ContentType.MediaType); + Assert.Equal("application/json", handler.Requests[6].Content.Headers.ContentType.MediaType); + Assert.Equal("application/octet-stream", handler.Requests[12].Content.Headers.ContentType.MediaType); + Assert.Throws(() => new HttpManager((Func)null)); + await Assert.ThrowsAsync(() => manager.HttpAsync((Uri)null, o => o.Request.Method = HttpMethod.Get)); + await Assert.ThrowsAsync(() => manager.HttpAsync(location, (Action)null)); + await Assert.ThrowsAsync(() => manager.HttpAsync(null, location, MediaTypeHeaderValue.Parse("text/plain"), ToStream("x"))); + await Assert.ThrowsAsync(() => manager.HttpAsync(HttpMethod.Get, location, (MediaTypeHeaderValue)null, ToStream("x"))); + await Assert.ThrowsAsync(() => manager.HttpAsync(HttpMethod.Get, location, MediaTypeHeaderValue.Parse("text/plain"), null)); + await Assert.ThrowsAsync(() => manager.HttpAsync(HttpMethod.Get, location, (string)null, ToStream("x"))); + } + + [Fact] + public async Task HttpManager_ShouldApplyOptionsToCreatedClient() + { + var handler = new RecordingHttpMessageHandler(); + using var manager = new HttpManager(o => + { + o.HandlerFactory = () => handler; + o.DefaultRequestHeaders.Add("X-Test", "alpha"); + o.Timeout = TimeSpan.FromSeconds(15); + }); + var options = new HttpManagerOptions(); + var watcherOptions = new HttpWatcherOptions(); + + options.ValidateOptions(); + watcherOptions.ValidateOptions(); + + Assert.True(manager.DefaultRequestHeaders.Contains("Connection")); + Assert.True(manager.DefaultRequestHeaders.Contains("X-Test")); + Assert.Equal(TimeSpan.FromSeconds(15), manager.Timeout); + Assert.False(options.DisposeHandler); + Assert.False(watcherOptions.ReadResponseBody); + + using (await manager.HttpGetAsync(new Uri("https://example.com/headers"))) { } + Assert.Equal("alpha", handler.Requests.Single().Headers.GetValues("X-Test").Single()); + } + + private static MemoryStream ToStream(string value) + { + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(value)); + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + public List Requests { get; } = new List(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }); + } + } + } +} diff --git a/test/Cuemon.Net.Tests/Http/HttpMethodConverterTest.cs b/test/Cuemon.Net.Tests/Http/HttpMethodConverterTest.cs new file mode 100644 index 000000000..ff0860278 --- /dev/null +++ b/test/Cuemon.Net.Tests/Http/HttpMethodConverterTest.cs @@ -0,0 +1,41 @@ +using System; +using System.Net.Http; +using Codebelt.Extensions.Xunit; +using Cuemon.Net.Http; +using Xunit; + +namespace Cuemon.Net.Http +{ + /// + /// Tests for the class and . + /// + public class HttpMethodConverterTest : Test + { + public HttpMethodConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void HttpMethodConverterAndRequestOptions_ShouldResolveKnownValues() + { + Assert.Equal(HttpMethods.Get, HttpMethodConverter.ToHttpMethod(HttpMethod.Get)); + Assert.Equal(HttpMethods.Post, HttpMethodConverter.ToHttpMethod(HttpMethod.Post)); + Assert.Equal(HttpMethods.Put, HttpMethodConverter.ToHttpMethod(HttpMethod.Put)); + Assert.Equal(HttpMethods.Delete, HttpMethodConverter.ToHttpMethod(HttpMethod.Delete)); + Assert.Equal(HttpMethods.Head, HttpMethodConverter.ToHttpMethod(HttpMethod.Head)); + Assert.Equal(HttpMethods.Options, HttpMethodConverter.ToHttpMethod(HttpMethod.Options)); + Assert.Equal(HttpMethods.Trace, HttpMethodConverter.ToHttpMethod(HttpMethod.Trace)); + Assert.Equal(HttpMethods.Patch, HttpMethodConverter.ToHttpMethod(new HttpMethod("PATCH"))); + Assert.Equal(HttpMethods.Get, HttpMethodConverter.ToHttpMethod(new HttpMethod("CUSTOM"))); + Assert.Throws(() => HttpMethodConverter.ToHttpMethod(null)); + + var options = new HttpRequestOptions(); + Assert.NotNull(options.Request); + Assert.Equal(HttpCompletionOption.ResponseContentRead, options.CompletionOption); + options.Request.Method = HttpMethod.Head; + Assert.Equal(HttpCompletionOption.ResponseHeadersRead, options.CompletionOption); + options.Request.Method = HttpMethod.Trace; + Assert.Equal(HttpCompletionOption.ResponseHeadersRead, options.CompletionOption); + } + } +} diff --git a/test/Cuemon.Net.Tests/Http/HttpWatcherTest.cs b/test/Cuemon.Net.Tests/Http/HttpWatcherTest.cs new file mode 100644 index 000000000..a0b06cc9a --- /dev/null +++ b/test/Cuemon.Net.Tests/Http/HttpWatcherTest.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Net.Http; +using Xunit; + +namespace Cuemon.Net.Http +{ + /// + /// Tests for the class. + /// + public class HttpWatcherTest : Test + { + public HttpWatcherTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task HttpWatcher_ShouldReactToChecksumEntityTagAndLastModifiedChanges() + { + var bodyHandler = new SequenceHttpMessageHandler(_ => ResponseWithBody("first"), _ => ResponseWithBody("second")); + var bodyWatcher = new TestHttpWatcher(new Uri("https://example.com/body"), o => + { + o.ReadResponseBody = true; + o.ClientFactory = () => new HttpClient(bodyHandler, false); + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + var bodyChanges = 0; + bodyWatcher.Changed += (_, _) => bodyChanges++; + + await bodyWatcher.SignalAsync(); + await bodyWatcher.SignalAsync(); + + Assert.NotNull(bodyWatcher.Checksum); + Assert.Equal(1, bodyChanges); + Assert.All(bodyHandler.Requests, request => Assert.True(request.Headers.Contains("Listener-Object"))); + + var etagHandler = new SequenceHttpMessageHandler(_ => ResponseWithHeaders("\"v1\"", null), _ => ResponseWithHeaders("\"v2\"", null)); + var etagWatcher = new TestHttpWatcher(new Uri("https://example.com/etag"), o => + { + o.ClientFactory = () => new HttpClient(etagHandler, false); + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + var etagChanges = 0; + etagWatcher.Changed += (_, _) => etagChanges++; + + await etagWatcher.SignalAsync(); + await etagWatcher.SignalAsync(); + + Assert.Equal(1, etagChanges); + + var expectedUtc = DateTimeOffset.UtcNow.AddMinutes(-5); + var lastModifiedHandler = new SequenceHttpMessageHandler(_ => ResponseWithHeaders(null, expectedUtc)); + var lastModifiedWatcher = new TestHttpWatcher(new Uri("https://example.com/head"), o => + { + o.ClientFactory = () => new HttpClient(lastModifiedHandler, false); + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + var lastModifiedChanges = 0; + lastModifiedWatcher.Changed += (_, _) => lastModifiedChanges++; + + await lastModifiedWatcher.SignalAsync(); + + Assert.Equal(1, lastModifiedChanges); + Assert.Equal(expectedUtc.UtcDateTime, lastModifiedWatcher.UtcLastModified); + + var invalidHandler = new SequenceHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(System.Array.Empty()) }); + var invalidWatcher = new TestHttpWatcher(new Uri("https://example.com/invalid"), o => + { + o.ClientFactory = () => new HttpClient(invalidHandler, false); + o.DueTime = Timeout.InfiniteTimeSpan; + o.Period = Timeout.InfiniteTimeSpan; + }); + + await Assert.ThrowsAsync(() => invalidWatcher.SignalAsync()); + Assert.Throws(() => new HttpWatcher(null)); + } + + private static HttpResponseMessage ResponseWithBody(string value) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(value)) + }; + } + + private static HttpResponseMessage ResponseWithHeaders(string entityTag, DateTimeOffset? lastModified) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(System.Array.Empty()) + }; + if (entityTag != null) { response.Headers.ETag = new EntityTagHeaderValue(entityTag); } + if (lastModified.HasValue) { response.Content.Headers.LastModified = lastModified; } + return response; + } + + private sealed class SequenceHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses; + + public SequenceHttpMessageHandler(params Func[] responses) + { + _responses = new Queue>(responses); + } + + public List Requests { get; } = new List(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responses.Dequeue().Invoke(request)); + } + } + + private sealed class TestHttpWatcher : HttpWatcher + { + public TestHttpWatcher(Uri location, Action setup = null) : base(location, setup) + { + } + + public Task SignalAsync() + { + return HandleSignalingAsync(); + } + } + } +} diff --git a/test/Cuemon.Net.Tests/Mail/MailDistributorTest.cs b/test/Cuemon.Net.Tests/Mail/MailDistributorTest.cs new file mode 100644 index 000000000..90f0a4797 --- /dev/null +++ b/test/Cuemon.Net.Tests/Mail/MailDistributorTest.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mail; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Net.Mail +{ + public class MailDistributorTest : Test + { + public MailDistributorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task SendAsync_ShouldBatchMessagesAndRespectFilter() + { + var pickupDirectory = Path.Combine(Environment.CurrentDirectory, "MailPickup", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(pickupDirectory); + try + { + var carrierInvocations = 0; + var sut = new MailDistributor(() => + { + carrierInvocations++; + return new SmtpClient() + { + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory, + PickupDirectoryLocation = pickupDirectory + }; + }, 2); + var mails = Enumerable.Range(1, 5).Select(CreateMailMessage).ToList(); + + await sut.SendAsync(mails, message => message.Subject != "3"); + + Assert.Equal(4, Directory.GetFiles(pickupDirectory).Length); + Assert.Equal(3, carrierInvocations); + } + finally + { + Directory.Delete(pickupDirectory, true); + } + } + + [Fact] + public async Task SendAsync_ShouldSkipRejectedShipmentsAndValidateArguments() + { + var pickupDirectory = Path.Combine(Environment.CurrentDirectory, "MailPickup", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(pickupDirectory); + try + { + var carrierInvocations = 0; + var sut = new MailDistributor(() => + { + carrierInvocations++; + return new SmtpClient() + { + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory, + PickupDirectoryLocation = pickupDirectory + }; + }, 1); + + await sut.SendAsync(new[] { CreateMailMessage(1), CreateMailMessage(2) }, _ => false); + + Assert.Empty(Directory.GetFiles(pickupDirectory)); + Assert.Equal(0, carrierInvocations); + Assert.Throws(() => new MailDistributor(null)); + Assert.Throws(() => new MailDistributor(() => new SmtpClient(), 0)); + await Assert.ThrowsAsync(() => sut.SendAsync((IEnumerable)null)); + await Assert.ThrowsAsync(() => sut.SendOneAsync(null)); + } + finally + { + Directory.Delete(pickupDirectory, true); + } + } + + private static MailMessage CreateMailMessage(int id) + { + return new MailMessage("sender@example.com", $"receiver{id}@example.com", id.ToString(), $"body-{id}"); + } + } +} diff --git a/test/Cuemon.Resilience.Tests/LatencyExceptionTest.cs b/test/Cuemon.Resilience.Tests/LatencyExceptionTest.cs new file mode 100644 index 000000000..0e0f5f3de --- /dev/null +++ b/test/Cuemon.Resilience.Tests/LatencyExceptionTest.cs @@ -0,0 +1,45 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Resilience; + +public class LatencyExceptionTest : Test +{ + public LatencyExceptionTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldCreateInstance_WhenCalledWithoutParameters() + { + var sut = new LatencyException(); + + Assert.NotNull(sut); + Assert.Null(sut.InnerException); + Assert.False(string.IsNullOrWhiteSpace(sut.Message)); + } + + [Fact] + public void Ctor_ShouldSetMessage_WhenCalledWithMessage() + { + var message = Guid.NewGuid().ToString(); + + var sut = new LatencyException(message); + + Assert.Equal(message, sut.Message); + Assert.Null(sut.InnerException); + } + + [Fact] + public void Ctor_ShouldSetMessageAndInnerException_WhenCalledWithMessageAndInnerException() + { + var message = Guid.NewGuid().ToString(); + var innerException = new InvalidOperationException(); + + var sut = new LatencyException(message, innerException); + + Assert.Equal(message, sut.Message); + Assert.Same(innerException, sut.InnerException); + } +} diff --git a/test/Cuemon.Resilience.Tests/TransientOperationOverloadTest.cs b/test/Cuemon.Resilience.Tests/TransientOperationOverloadTest.cs new file mode 100644 index 000000000..9b73f0a9d --- /dev/null +++ b/test/Cuemon.Resilience.Tests/TransientOperationOverloadTest.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Resilience; + +public class TransientOperationOverloadTest : Test +{ + public TransientOperationOverloadTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void WithFunc_ShouldReturnExpectedValues_WhenUsingZeroOneFourAndFiveArgumentOverloads() + { + var zero = TransientOperation.WithFunc(() => "zero"); + var one = TransientOperation.WithFunc((string arg1) => arg1, "one"); + var four = TransientOperation.WithFunc((string arg1, string arg2, string arg3, string arg4) => string.Concat(arg1, arg2, arg3, arg4), "one", "two", "three", "four"); + var five = TransientOperation.WithFunc((string arg1, string arg2, string arg3, string arg4, string arg5) => string.Concat(arg1, arg2, arg3, arg4, arg5), "one", "two", "three", "four", "five"); + + Assert.Equal("zero", zero); + Assert.Equal("one", one); + Assert.Equal("onetwothreefour", four); + Assert.Equal("onetwothreefourfive", five); + } + + [Fact] + public void WithAction_ShouldCaptureExpectedValues_WhenUsingZeroOneFourAndFiveArgumentOverloads() + { + var zeroInvoked = false; + var one = string.Empty; + var four = string.Empty; + var five = string.Empty; + + TransientOperation.WithAction(() => zeroInvoked = true); + TransientOperation.WithAction((string arg1) => one = arg1, "one"); + TransientOperation.WithAction((string arg1, string arg2, string arg3, string arg4) => four = string.Concat(arg1, arg2, arg3, arg4), "one", "two", "three", "four"); + TransientOperation.WithAction((string arg1, string arg2, string arg3, string arg4, string arg5) => five = string.Concat(arg1, arg2, arg3, arg4, arg5), "one", "two", "three", "four", "five"); + + Assert.True(zeroInvoked); + Assert.Equal("one", one); + Assert.Equal("onetwothreefour", four); + Assert.Equal("onetwothreefourfive", five); + } + + [Fact] + public async Task WithFuncAsync_ShouldReturnExpectedValues_WhenUsingZeroOneFourAndFiveArgumentOverloads() + { + var token = new CancellationTokenSource().Token; + var zeroToken = CancellationToken.None; + var oneToken = CancellationToken.None; + var fourToken = CancellationToken.None; + var fiveToken = CancellationToken.None; + Action setup = o => o.CancellationToken = token; + + var zero = await TransientOperation.WithFuncAsync(ct => + { + zeroToken = ct; + return Task.FromResult("zero"); + }, setup); + var one = await TransientOperation.WithFuncAsync((string arg1, CancellationToken ct) => + { + oneToken = ct; + return Task.FromResult(arg1); + }, "one", setup); + var four = await TransientOperation.WithFuncAsync((string arg1, string arg2, string arg3, string arg4, CancellationToken ct) => + { + fourToken = ct; + return Task.FromResult(string.Concat(arg1, arg2, arg3, arg4)); + }, "one", "two", "three", "four", setup); + var five = await TransientOperation.WithFuncAsync((string arg1, string arg2, string arg3, string arg4, string arg5, CancellationToken ct) => + { + fiveToken = ct; + return Task.FromResult(string.Concat(arg1, arg2, arg3, arg4, arg5)); + }, "one", "two", "three", "four", "five", setup); + + Assert.Equal("zero", zero); + Assert.Equal("one", one); + Assert.Equal("onetwothreefour", four); + Assert.Equal("onetwothreefourfive", five); + Assert.Equal(token, zeroToken); + Assert.Equal(token, oneToken); + Assert.Equal(token, fourToken); + Assert.Equal(token, fiveToken); + } + + [Fact] + public async Task WithActionAsync_ShouldCaptureExpectedValues_WhenUsingZeroOneFourAndFiveArgumentOverloads() + { + var token = new CancellationTokenSource().Token; + var zeroToken = CancellationToken.None; + var oneToken = CancellationToken.None; + var fourToken = CancellationToken.None; + var fiveToken = CancellationToken.None; + var zeroInvoked = false; + var one = string.Empty; + var four = string.Empty; + var five = string.Empty; + Action setup = o => o.CancellationToken = token; + + await TransientOperation.WithActionAsync(ct => + { + zeroInvoked = true; + zeroToken = ct; + return Task.CompletedTask; + }, setup); + await TransientOperation.WithActionAsync((string arg1, CancellationToken ct) => + { + one = arg1; + oneToken = ct; + return Task.CompletedTask; + }, "one", setup); + await TransientOperation.WithActionAsync((string arg1, string arg2, string arg3, string arg4, CancellationToken ct) => + { + four = string.Concat(arg1, arg2, arg3, arg4); + fourToken = ct; + return Task.CompletedTask; + }, "one", "two", "three", "four", setup); + await TransientOperation.WithActionAsync((string arg1, string arg2, string arg3, string arg4, string arg5, CancellationToken ct) => + { + five = string.Concat(arg1, arg2, arg3, arg4, arg5); + fiveToken = ct; + return Task.CompletedTask; + }, "one", "two", "three", "four", "five", setup); + + Assert.True(zeroInvoked); + Assert.Equal("one", one); + Assert.Equal("onetwothreefour", four); + Assert.Equal("onetwothreefourfive", five); + Assert.Equal(token, zeroToken); + Assert.Equal(token, oneToken); + Assert.Equal(token, fourToken); + Assert.Equal(token, fiveToken); + } +} diff --git a/test/Cuemon.Runtime.Caching.Tests/CacheEntryEventArgsTest.cs b/test/Cuemon.Runtime.Caching.Tests/CacheEntryEventArgsTest.cs new file mode 100644 index 000000000..32ba6b089 --- /dev/null +++ b/test/Cuemon.Runtime.Caching.Tests/CacheEntryEventArgsTest.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Runtime; +using Xunit; + +namespace Cuemon.Runtime.Caching +{ + public class CacheEntryEventArgsTest : Test + { + public CacheEntryEventArgsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Expired_ShouldRaiseCacheEntryEventArgs_WhenDependencyChanges() + { + var cache = new SlimMemoryCache(); + var dependency = new DependencyStub(); + var sut = new CacheEntry("key", "value"); + object sender = null; + CacheEntryEventArgs eventArgs = null; + + sut.Expired += (s, e) => + { + sender = s; + eventArgs = e; + }; + + cache.Add(sut, new CacheInvalidation(new[] { dependency })); + dependency.SignalChanged(); + + Assert.Same(sut, sender); + Assert.NotNull(eventArgs); + } + + private sealed class DependencyStub : IDependency + { + public event EventHandler DependencyChanged; + + public DateTime? UtcLastModified { get; private set; } + + public bool HasChanged { get; private set; } + + public void Start() + { + } + + public Task StartAsync() + { + return Task.CompletedTask; + } + + public void SignalChanged() + { + UtcLastModified = DateTime.UtcNow; + HasChanged = true; + DependencyChanged?.Invoke(this, new DependencyEventArgs(UtcLastModified.Value)); + } + } + } +} diff --git a/test/Cuemon.Runtime.Caching.Tests/CacheEntryTest.cs b/test/Cuemon.Runtime.Caching.Tests/CacheEntryTest.cs new file mode 100644 index 000000000..3a2188317 --- /dev/null +++ b/test/Cuemon.Runtime.Caching.Tests/CacheEntryTest.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Runtime; +using Xunit; + +namespace Cuemon.Runtime.Caching +{ + public class CacheEntryTest : Test + { + public CacheEntryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenKeyIsNull() + { + var sut = Assert.Throws(() => new CacheEntry(null, "value")); + + Assert.Equal("key", sut.ParamName); + } + + [Fact] + public void ToString_ShouldIncludeKeyAndValue_WhenCalled() + { + var cache = new SlimMemoryCache(); + var sut = new CacheEntry("key", "value", "ns"); + + cache.Add(sut, new CacheInvalidation((IEnumerable)null)); + + var result = sut.ToString(); + + Assert.StartsWith("Cuemon.Runtime.Caching.CacheEntry", result); + Assert.Contains("Key=key", result); + Assert.Contains("Namespace=ns", result); + } + + [Fact] + public void CanExpire_ShouldReturnFalse_WhenInvalidationHasNoExpirationDetails() + { + var cache = new SlimMemoryCache(); + var sut = new CacheEntry("key", "value"); + + cache.Add(sut, new CacheInvalidation((IEnumerable)null)); + + Assert.False(sut.CanExpire); + Assert.False(sut.HasExpired(DateTime.UtcNow)); + } + + [Theory] + [InlineData(-1, false)] + [InlineData(0, true)] + public void HasExpired_ShouldResolveAbsoluteExpiration(int tickOffset, bool expected) + { + var cache = new SlimMemoryCache(); + var sut = new CacheEntry("key", "value"); + var expiration = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + cache.Add(sut, new CacheInvalidation(expiration)); + + var result = sut.HasExpired(expiration.AddTicks(tickOffset)); + + Assert.True(sut.CanExpire); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(-1, false)] + [InlineData(0, true)] + public void HasExpired_ShouldResolveSlidingExpiration(long tickOffset, bool expected) + { + var cache = new SlimMemoryCache(); + var sut = new CacheEntry("key", "value"); + var slidingExpiration = TimeSpan.FromSeconds(30); + + cache.Add(sut, new CacheInvalidation(slidingExpiration)); + + var result = sut.HasExpired(sut.Accessed.Add(slidingExpiration).AddTicks(tickOffset)); + + Assert.True(sut.CanExpire); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HasExpired_ShouldResolveDependencyState(bool hasChanged) + { + var cache = new SlimMemoryCache(); + var dependency = new DependencyStub(hasChanged); + var sut = new CacheEntry("key", "value"); + + cache.Add(sut, new CacheInvalidation(new[] { dependency })); + + var result = sut.HasExpired(DateTime.UtcNow); + + Assert.True(sut.CanExpire); + Assert.Equal(hasChanged, result); + } + + private sealed class DependencyStub : IDependency + { + public DependencyStub(bool hasChanged) + { + HasChanged = hasChanged; + } + + public event EventHandler DependencyChanged; + + public DateTime? UtcLastModified { get; private set; } + + public bool HasChanged { get; private set; } + + public void Start() + { + } + + public Task StartAsync() + { + return Task.CompletedTask; + } + + public void SignalChanged() + { + UtcLastModified = DateTime.UtcNow; + HasChanged = true; + DependencyChanged?.Invoke(this, new DependencyEventArgs(UtcLastModified.Value)); + } + } + } +} diff --git a/test/Cuemon.Runtime.Caching.Tests/CacheInvalidationTest.cs b/test/Cuemon.Runtime.Caching.Tests/CacheInvalidationTest.cs new file mode 100644 index 000000000..2e54c2d1d --- /dev/null +++ b/test/Cuemon.Runtime.Caching.Tests/CacheInvalidationTest.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Runtime; +using Xunit; + +namespace Cuemon.Runtime.Caching +{ + public class CacheInvalidationTest : Test + { + public CacheInvalidationTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldInitializeAbsoluteExpiration_WhenDateTimeIsProvided() + { + var absoluteExpiration = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local); + + var sut = new CacheInvalidation(absoluteExpiration); + + Assert.Equal(absoluteExpiration.ToUniversalTime(), sut.AbsoluteExpiration); + Assert.True(sut.UseAbsoluteExpiration); + Assert.False(sut.UseSlidingExpiration); + Assert.False(sut.UseDependency); + Assert.Null(sut.SlidingExpiration); + Assert.Null(sut.Dependencies); + } + + [Fact] + public void Constructor_ShouldInitializeDependencies_WhenSequenceIsProvided() + { + var dependencies = new List { new DependencyStub(), new DependencyStub() }; + + var sut = new CacheInvalidation(dependencies); + + Assert.False(sut.UseAbsoluteExpiration); + Assert.False(sut.UseSlidingExpiration); + Assert.True(sut.UseDependency); + Assert.Equal(2, sut.Dependencies.Count()); + } + + [Fact] + public void Constructor_ShouldUseEmptyDependencies_WhenSequenceIsNull() + { + var sut = new CacheInvalidation((IEnumerable)null); + + Assert.False(sut.UseAbsoluteExpiration); + Assert.False(sut.UseSlidingExpiration); + Assert.False(sut.UseDependency); + Assert.Empty(sut.Dependencies); + } + + [Fact] + public void Constructor_ShouldInitializeSlidingExpiration_WhenTimeSpanIsProvided() + { + var slidingExpiration = TimeSpan.FromMinutes(5); + + var sut = new CacheInvalidation(slidingExpiration); + + Assert.Equal(slidingExpiration, sut.SlidingExpiration); + Assert.False(sut.UseAbsoluteExpiration); + Assert.True(sut.UseSlidingExpiration); + Assert.False(sut.UseDependency); + Assert.Null(sut.AbsoluteExpiration); + Assert.Null(sut.Dependencies); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_ShouldThrowArgumentOutOfRangeException_WhenSlidingExpirationIsLessThanOrEqualToZero(long ticks) + { + var sut = Assert.Throws(() => new CacheInvalidation(TimeSpan.FromTicks(ticks))); + + Assert.Equal("slidingExpiration", sut.ParamName); + } + + [Fact] + public void Constructor_ShouldThrowArgumentOutOfRangeException_WhenSlidingExpirationExceedsOneYear() + { + var sut = Assert.Throws(() => new CacheInvalidation(TimeSpan.FromDays(366))); + + Assert.Equal("slidingExpiration", sut.ParamName); + } + + private sealed class DependencyStub : IDependency + { + public event EventHandler DependencyChanged; + + public DateTime? UtcLastModified { get; } + + public bool HasChanged { get; } + + public void Start() + { + } + + public Task StartAsync() + { + return Task.CompletedTask; + } + } + } +} diff --git a/test/Cuemon.Runtime.Caching.Tests/SlimMemoryCacheTest.cs b/test/Cuemon.Runtime.Caching.Tests/SlimMemoryCacheTest.cs index 50e0d840a..a55e5a91e 100644 --- a/test/Cuemon.Runtime.Caching.Tests/SlimMemoryCacheTest.cs +++ b/test/Cuemon.Runtime.Caching.Tests/SlimMemoryCacheTest.cs @@ -265,6 +265,61 @@ public void Add_VerifyBothLogicalAndActualCacheRemovalUponExpirationForSixtySeco Assert.Equal(0, _cache.Where(pair => pair.Value.Namespace == Absolute60Namespace).ToList().Count); } + [Fact] + public void MissingMembers_ShouldReturnFalseOrNull_WhenEntryDoesNotExist() + { + var sut = new SlimMemoryCache(); + + var result = sut.TryGet("missing", out var value); + + Assert.False(sut.Contains("missing")); + Assert.Null(sut.Get("missing")); + Assert.Null(sut.GetCacheEntry("missing")); + Assert.Null(sut.Remove("missing")); + Assert.False(result); + Assert.Null(value); + } + + [Fact] + public void Set_ShouldInsertAndUpdateEntry_WhenCalled() + { + var sut = new SlimMemoryCache(); + var invalidation = new CacheInvalidation(DateTime.UtcNow.AddMinutes(1)); + + sut.Set("key", "value", invalidation); + var beforeUpdate = sut.GetCacheEntry("key"); + var accessed = beforeUpdate.Accessed; + + Thread.Sleep(20); + sut.Set("key", "updated", invalidation); + var afterUpdate = sut.GetCacheEntry("key"); + + Assert.NotNull(beforeUpdate); + Assert.NotNull(afterUpdate); + Assert.Equal("updated", sut.Get("key")); + Assert.Equal(1, sut.Count()); + Assert.True(afterUpdate.Accessed >= accessed); + } + + [Fact] + public void RemoveAll_ShouldRemoveEntriesWithinSpecifiedNamespace() + { + var sut = new SlimMemoryCache(); + var invalidation = new CacheInvalidation(DateTime.UtcNow.AddMinutes(1)); + + sut.Set("key1", "value1", invalidation, "ns1"); + sut.Set("key2", "value2", invalidation, "ns1"); + sut.Set("key3", "value3", invalidation, "ns2"); + + sut.RemoveAll("ns1"); + + Assert.Equal(0, sut.Count("ns1")); + Assert.Equal(1, sut.Count("ns2")); + Assert.False(sut.Contains("key1", "ns1")); + Assert.False(sut.Contains("key2", "ns1")); + Assert.True(sut.Contains("key3", "ns2")); + } + public override void ConfigureServices(IServiceCollection services) { services.AddSingleton>(o => diff --git a/test/Cuemon.Threading.Tests/AdvancedParallelFactoryTest.cs b/test/Cuemon.Threading.Tests/AdvancedParallelFactoryTest.cs new file mode 100644 index 000000000..dcf3626a7 --- /dev/null +++ b/test/Cuemon.Threading.Tests/AdvancedParallelFactoryTest.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Threading +{ + [Trait("Category", "Threading")] + public class AdvancedParallelFactoryTest : Test + { + private readonly TimeSpan _maxAllowedTestTime = TimeSpan.FromMinutes(1); + + public AdvancedParallelFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Condition_ShouldEvaluateSupportedOperators_WhenInvoked() + { + Assert.True(AdvancedParallelFactory.Condition(8, RelationalOperator.Equal, 8)); + Assert.True(AdvancedParallelFactory.Condition(9, RelationalOperator.GreaterThan, 8)); + Assert.True(AdvancedParallelFactory.Condition(8, RelationalOperator.GreaterThanOrEqual, 8)); + Assert.True(AdvancedParallelFactory.Condition(7, RelationalOperator.LessThan, 8)); + Assert.True(AdvancedParallelFactory.Condition(8, RelationalOperator.LessThanOrEqual, 8)); + Assert.True(AdvancedParallelFactory.Condition(7, RelationalOperator.NotEqual, 8)); + Assert.Equal(10, AdvancedParallelFactory.Iterator(7, AssignmentOperator.Addition, 3)); + + Assert.Throws(() => AdvancedParallelFactory.Condition(8, (RelationalOperator)int.MaxValue, 8)); + } + + [Fact] + public void For_ShouldExecuteAllOverloads_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, i => bag.Add(i), setup))); + AssertEquivalent(expected.Select(i => i + 10), ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, (i, a) => bag.Add(i + a), 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, (i, a, b) => bag.Add(i + a + b), 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, (i, a, b, c) => bag.Add(i + a + b + c), 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, (i, a, b, c, d) => bag.Add(i + a + b + c + d), 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), ExecuteFor(count, (rules, bag, setup) => AdvancedParallelFactory.For(rules, (i, a, b, c, d, e) => bag.Add(i + a + b + c + d + e), 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public void ForResult_ShouldReturnExpectedResults_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, AdvancedParallelFactory.ForResult(CreateRules(count), i => i, CreateSyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 10), AdvancedParallelFactory.ForResult(CreateRules(count), (i, a) => i + a, 10, CreateSyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 30), AdvancedParallelFactory.ForResult(CreateRules(count), (i, a, b) => i + a + b, 10, 20, CreateSyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 60), AdvancedParallelFactory.ForResult(CreateRules(count), (i, a, b, c) => i + a + b + c, 10, 20, 30, CreateSyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 100), AdvancedParallelFactory.ForResult(CreateRules(count), (i, a, b, c, d) => i + a + b + c + d, 10, 20, 30, 40, CreateSyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 150), AdvancedParallelFactory.ForResult(CreateRules(count), (i, a, b, c, d, e) => i + a + b + c + d + e, 10, 20, 30, 40, 50, CreateSyncSetup(CancellationToken.None))); + } + + [Fact] + public void While_ShouldExecuteAllOverloads_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, i => bag.Add(i), setup))); + AssertEquivalent(expected.Select(i => i + 10), ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, (i, a) => bag.Add(i + a), 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, (i, a, b) => bag.Add(i + a + b), 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, (i, a, b, c) => bag.Add(i + a + b + c), 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, (i, a, b, c, d) => bag.Add(i + a + b + c + d), 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), ExecuteWhile(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.While(reader, condition, provider, (i, a, b, c, d, e) => bag.Add(i + a + b + c + d + e), 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public void WhileResult_ShouldReturnExpectedResults_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, i => i, setup))); + AssertEquivalent(expected.Select(i => i + 10), ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, (i, a) => i + a, 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, (i, a, b) => i + a + b, 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, (i, a, b, c) => i + a + b + c, 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, (i, a, b, c, d) => i + a + b + c + d, 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), ExecuteWhileResult(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResult(reader, condition, provider, (i, a, b, c, d, e) => i + a + b + c + d + e, 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public void For_ShouldRunConcurrently_WhenConfiguredWithMultiplePartitions() + { + var ready = new CountdownEvent(3); + var active = 0; + var maxActive = 0; + + AdvancedParallelFactory.For(CreateRules(3), i => + { + var current = Interlocked.Increment(ref active); + CaptureMax(ref maxActive, current); + ready.Signal(); + SpinWait.SpinUntil(() => ready.IsSet, _maxAllowedTestTime); + Thread.Sleep(25); + Interlocked.Decrement(ref active); + }, CreateSyncSetup(CancellationToken.None, 3)); + + Assert.True(maxActive > 1, $"Expected more than one concurrent worker but observed {maxActive}."); + } + + [Fact] + public void For_ShouldThrowAggregateException_WhenWorkerFaults() + { + var exception = Assert.Throws(() => AdvancedParallelFactory.For(CreateRules(6), i => + { + if (i == 3) { throw new InvalidOperationException("boom"); } + }, CreateSyncSetup(CancellationToken.None))); + + Assert.IsType(Assert.Single(exception.InnerExceptions)); + } + + [Fact] + public void While_ShouldThrowAggregateException_WhenWorkerFaults() + { + var queue = CreateQueue(6); + var exception = Assert.Throws(() => AdvancedParallelFactory.While(queue, () => queue.Count > 0, q => q.Dequeue(), i => + { + if (i == 3) { throw new InvalidOperationException("boom"); } + }, CreateSyncSetup(CancellationToken.None))); + + Assert.IsType(Assert.Single(exception.InnerExceptions)); + } + + [Fact] + public void For_ShouldThrowArgumentNullException_WhenWorkerIsNull() + { + Action worker = null; + + Assert.Throws(() => AdvancedParallelFactory.For(CreateRules(1), worker)); + } + + [Fact] + public void While_ShouldThrowArgumentNullException_WhenConditionIsNull() + { + Func condition = null; + var queue = CreateQueue(1); + + Assert.Throws(() => AdvancedParallelFactory.While(queue, condition, q => q.Dequeue(), i => { })); + } + + [Fact] + public async Task ForAsync_ShouldExecuteAllOverloads_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, ct) => + { + await Task.Yield(); + bag.Add(i); + }, setup))); + AssertEquivalent(expected.Select(i => i + 10), await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, a, ct) => + { + await Task.Yield(); + bag.Add(i + a); + }, 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, a, b, ct) => + { + await Task.Yield(); + bag.Add(i + a + b); + }, 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, a, b, c, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c); + }, 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, a, b, c, d, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c + d); + }, 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), await ExecuteForAsync(count, (rules, bag, setup) => AdvancedParallelFactory.ForAsync(rules, async (i, a, b, c, d, e, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c + d + e); + }, 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public async Task ForResultAsync_ShouldReturnExpectedResults_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, ct) => + { + await Task.Yield(); + return i; + }, CreateAsyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 10), await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, a, ct) => + { + await Task.Yield(); + return i + a; + }, 10, CreateAsyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 30), await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, a, b, ct) => + { + await Task.Yield(); + return i + a + b; + }, 10, 20, CreateAsyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 60), await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, a, b, c, ct) => + { + await Task.Yield(); + return i + a + b + c; + }, 10, 20, 30, CreateAsyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 100), await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, a, b, c, d, ct) => + { + await Task.Yield(); + return i + a + b + c + d; + }, 10, 20, 30, 40, CreateAsyncSetup(CancellationToken.None))); + AssertEquivalent(expected.Select(i => i + 150), await AdvancedParallelFactory.ForResultAsync(CreateRules(count), async (i, a, b, c, d, e, ct) => + { + await Task.Yield(); + return i + a + b + c + d + e; + }, 10, 20, 30, 40, 50, CreateAsyncSetup(CancellationToken.None))); + } + + [Fact] + public async Task WhileAsync_ShouldExecuteAllOverloads_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, ct) => + { + await Task.Yield(); + bag.Add(i); + }, setup))); + AssertEquivalent(expected.Select(i => i + 10), await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, a, ct) => + { + await Task.Yield(); + bag.Add(i + a); + }, 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, a, b, ct) => + { + await Task.Yield(); + bag.Add(i + a + b); + }, 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, a, b, c, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c); + }, 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, a, b, c, d, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c + d); + }, 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), await ExecuteWhileAsync(count, (reader, condition, provider, bag, setup) => AdvancedParallelFactory.WhileAsync(reader, condition, provider, async (i, a, b, c, d, e, ct) => + { + await Task.Yield(); + bag.Add(i + a + b + c + d + e); + }, 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public async Task WhileResultAsync_ShouldReturnExpectedResults_WhenInvoked() + { + var count = 6; + var expected = Enumerable.Range(0, count).ToArray(); + + AssertEquivalent(expected, await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, ct) => + { + await Task.Yield(); + return i; + }, setup))); + AssertEquivalent(expected.Select(i => i + 10), await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, a, ct) => + { + await Task.Yield(); + return i + a; + }, 10, setup))); + AssertEquivalent(expected.Select(i => i + 30), await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, a, b, ct) => + { + await Task.Yield(); + return i + a + b; + }, 10, 20, setup))); + AssertEquivalent(expected.Select(i => i + 60), await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, a, b, c, ct) => + { + await Task.Yield(); + return i + a + b + c; + }, 10, 20, 30, setup))); + AssertEquivalent(expected.Select(i => i + 100), await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, a, b, c, d, ct) => + { + await Task.Yield(); + return i + a + b + c + d; + }, 10, 20, 30, 40, setup))); + AssertEquivalent(expected.Select(i => i + 150), await ExecuteWhileResultAsync(count, (reader, condition, provider, setup) => AdvancedParallelFactory.WhileResultAsync(reader, condition, provider, async (i, a, b, c, d, e, ct) => + { + await Task.Yield(); + return i + a + b + c + d + e; + }, 10, 20, 30, 40, 50, setup))); + } + + [Fact] + public async Task ForAsync_ShouldRunConcurrently_WhenConfiguredWithMultiplePartitions() + { + var ready = new CountdownEvent(3); + var active = 0; + var maxActive = 0; + + await AdvancedParallelFactory.ForAsync(CreateRules(3), async (i, ct) => + { + var current = Interlocked.Increment(ref active); + CaptureMax(ref maxActive, current); + ready.Signal(); + while (!ready.IsSet) + { + await Task.Delay(1, ct); + } + await Task.Delay(25, ct); + Interlocked.Decrement(ref active); + }, CreateAsyncSetup(CancellationToken.None, 3)); + + Assert.True(maxActive > 1, $"Expected more than one concurrent worker but observed {maxActive}."); + } + + [Fact] + public async Task ForAsync_ShouldThrowInvalidOperationException_WhenWorkerFaults() + { + await Assert.ThrowsAsync(() => AdvancedParallelFactory.ForAsync(CreateRules(6), (i, ct) => + { + if (i == 3) { return Task.FromException(new InvalidOperationException("boom")); } + return Task.CompletedTask; + }, CreateAsyncSetup(CancellationToken.None))); + } + + [Fact] + public async Task WhileAsync_ShouldThrowInvalidOperationException_WhenWorkerFaults() + { + var queue = CreateQueue(6); + + await Assert.ThrowsAsync(() => AdvancedParallelFactory.WhileAsync(queue, () => Task.FromResult(queue.Count > 0), q => q.Dequeue(), (i, ct) => + { + if (i == 3) { return Task.FromException(new InvalidOperationException("boom")); } + return Task.CompletedTask; + }, CreateAsyncSetup(CancellationToken.None))); + } + + [Fact] + public async Task ForAsync_ShouldThrowArgumentNullException_WhenWorkerIsNull() + { + Func worker = null; + + await Assert.ThrowsAsync(() => AdvancedParallelFactory.ForAsync(CreateRules(1), worker)); + } + + [Fact] + public async Task WhileAsync_ShouldThrowArgumentNullException_WhenConditionIsNull() + { + Func> condition = null; + var queue = CreateQueue(1); + + await Assert.ThrowsAsync(() => AdvancedParallelFactory.WhileAsync(queue, condition, q => q.Dequeue(), (i, ct) => Task.CompletedTask)); + } + + private IEnumerable ExecuteFor(int count, Action, ConcurrentBag, Action> execute) + { + var bag = new ConcurrentBag(); + execute(CreateRules(count), bag, CreateSyncSetup(CancellationToken.None)); + return bag; + } + + private async Task> ExecuteForAsync(int count, Func, ConcurrentBag, Action, Task> execute) + { + var bag = new ConcurrentBag(); + await execute(CreateRules(count), bag, CreateAsyncSetup(CancellationToken.None)); + return bag; + } + + private IEnumerable ExecuteWhile(int count, Action, Func, Func, int>, ConcurrentBag, Action> execute) + { + var reader = CreateQueue(count); + var bag = new ConcurrentBag(); + execute(reader, () => reader.Count > 0, q => q.Dequeue(), bag, CreateSyncSetup(CancellationToken.None)); + return bag; + } + + private async Task> ExecuteWhileAsync(int count, Func, Func>, Func, int>, ConcurrentBag, Action, Task> execute) + { + var reader = CreateQueue(count); + var bag = new ConcurrentBag(); + await execute(reader, () => Task.FromResult(reader.Count > 0), q => q.Dequeue(), bag, CreateAsyncSetup(CancellationToken.None)); + return bag; + } + + private IEnumerable ExecuteWhileResult(int count, Func, Func, Func, int>, Action, IReadOnlyCollection> execute) + { + var reader = CreateQueue(count); + return execute(reader, () => reader.Count > 0, q => q.Dequeue(), CreateSyncSetup(CancellationToken.None)); + } + + private async Task> ExecuteWhileResultAsync(int count, Func, Func>, Func, int>, Action, Task>> execute) + { + var reader = CreateQueue(count); + return await execute(reader, () => Task.FromResult(reader.Count > 0), q => q.Dequeue(), CreateAsyncSetup(CancellationToken.None)); + } + + private static void AssertEquivalent(IEnumerable expected, IEnumerable actual) + { + Assert.Equal(expected.OrderBy(i => i), actual.OrderBy(i => i)); + } + + private static ForLoopRuleset CreateRules(int count) + { + return new ForLoopRuleset(0, count, 1); + } + + private static Queue CreateQueue(int count) + { + return new Queue(Enumerable.Range(0, count)); + } + + private Action CreateSyncSetup(CancellationToken cancellationToken, int partitionSize = 3) + { + return o => + { + o.CancellationToken = cancellationToken; + o.CreationOptions = TaskCreationOptions.None; + o.PartitionSize = partitionSize; + }; + } + + private static Action CreateAsyncSetup(CancellationToken cancellationToken, int partitionSize = 3) + { + return o => + { + o.CancellationToken = cancellationToken; + o.PartitionSize = partitionSize; + }; + } + + private static void CaptureMax(ref int target, int candidate) + { + while (true) + { + var snapshot = target; + if (snapshot >= candidate) { return; } + if (Interlocked.CompareExchange(ref target, candidate, snapshot) == snapshot) { return; } + } + } + } +} diff --git a/test/Cuemon.Threading.Tests/AsyncPatternsTest.cs b/test/Cuemon.Threading.Tests/AsyncPatternsTest.cs new file mode 100644 index 000000000..8b3f9d3f8 --- /dev/null +++ b/test/Cuemon.Threading.Tests/AsyncPatternsTest.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Threading +{ + [Trait("Category", "Threading")] + public class AsyncPatternsTest : Test + { + public AsyncPatternsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Use_ShouldReturnSingleton() + { + Assert.Same(AsyncPatterns.Use, AsyncPatterns.Use); + } + + [Fact] + public async Task SafeInvokeAsync_ShouldSupportAllOverloads_WhenTesterSucceeds() + { + var actual = new List(); + var cts = new CancellationTokenSource(); + + var result0 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("0"), async (probe, ct) => + { + await Task.Yield(); + actual.Add($"0:{probe.Value}:{ct.CanBeCanceled}"); + return probe; + }, ct: cts.Token); + + var result1 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("1"), async (probe, arg, ct) => + { + await Task.Yield(); + actual.Add($"1:{probe.Value}:{arg}:{ct.CanBeCanceled}"); + return probe; + }, "a", ct: cts.Token); + + var result2 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("2"), async (probe, arg1, arg2, ct) => + { + await Task.Yield(); + actual.Add($"2:{probe.Value}:{arg1}:{arg2}:{ct.CanBeCanceled}"); + return probe; + }, "a", "b", ct: cts.Token); + + var result3 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("3"), async (probe, arg1, arg2, arg3, ct) => + { + await Task.Yield(); + actual.Add($"3:{probe.Value}:{arg1}:{arg2}:{arg3}:{ct.CanBeCanceled}"); + return probe; + }, "a", "b", "c", ct: cts.Token); + + var result4 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("4"), async (probe, arg1, arg2, arg3, arg4, ct) => + { + await Task.Yield(); + actual.Add($"4:{probe.Value}:{arg1}:{arg2}:{arg3}:{arg4}:{ct.CanBeCanceled}"); + return probe; + }, "a", "b", "c", "d", ct: cts.Token); + + var result5 = await AsyncPatterns.SafeInvokeAsync(() => new DisposableProbe("5"), async (probe, arg1, arg2, arg3, arg4, arg5, ct) => + { + await Task.Yield(); + actual.Add($"5:{probe.Value}:{arg1}:{arg2}:{arg3}:{arg4}:{arg5}:{ct.CanBeCanceled}"); + return probe; + }, "a", "b", "c", "d", "e", ct: cts.Token); + + Assert.Equal(new[] + { + "0:0:True", + "1:1:a:True", + "2:2:a:b:True", + "3:3:a:b:c:True", + "4:4:a:b:c:d:True", + "5:5:a:b:c:d:e:True" + }, actual); + + Assert.False(result0.IsDisposed); + Assert.False(result1.IsDisposed); + Assert.False(result2.IsDisposed); + Assert.False(result3.IsDisposed); + Assert.False(result4.IsDisposed); + Assert.False(result5.IsDisposed); + + result0.Dispose(); + result1.Dispose(); + result2.Dispose(); + result3.Dispose(); + result4.Dispose(); + result5.Dispose(); + } + + [Fact] + public async Task SafeInvokeAsync_ShouldInvokeCatcherAndDisposeInitializer_WhenTesterThrows() + { + var actual = new List(); + DisposableProbe initializer0 = null; + DisposableProbe initializer1 = null; + DisposableProbe initializer2 = null; + DisposableProbe initializer3 = null; + DisposableProbe initializer4 = null; + DisposableProbe initializer5 = null; + + var result0 = await AsyncPatterns.SafeInvokeAsync(() => initializer0 = new DisposableProbe("0"), async (probe, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("0"); + }, async (ex, ct) => + { + await Task.Yield(); + actual.Add($"0:{ex.Message}"); + }); + + var result1 = await AsyncPatterns.SafeInvokeAsync(() => initializer1 = new DisposableProbe("1"), async (probe, arg, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("1"); + }, "a", async (ex, arg, ct) => + { + await Task.Yield(); + actual.Add($"1:{arg}:{ex.Message}"); + }); + + var result2 = await AsyncPatterns.SafeInvokeAsync(() => initializer2 = new DisposableProbe("2"), async (probe, arg1, arg2, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("2"); + }, "a", "b", async (ex, arg1, arg2, ct) => + { + await Task.Yield(); + actual.Add($"2:{arg1}:{arg2}:{ex.Message}"); + }); + + var result3 = await AsyncPatterns.SafeInvokeAsync(() => initializer3 = new DisposableProbe("3"), async (probe, arg1, arg2, arg3, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("3"); + }, "a", "b", "c", async (ex, arg1, arg2, arg3, ct) => + { + await Task.Yield(); + actual.Add($"3:{arg1}:{arg2}:{arg3}:{ex.Message}"); + }); + + var result4 = await AsyncPatterns.SafeInvokeAsync(() => initializer4 = new DisposableProbe("4"), async (probe, arg1, arg2, arg3, arg4, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("4"); + }, "a", "b", "c", "d", async (ex, arg1, arg2, arg3, arg4, ct) => + { + await Task.Yield(); + actual.Add($"4:{arg1}:{arg2}:{arg3}:{arg4}:{ex.Message}"); + }); + + var result5 = await AsyncPatterns.SafeInvokeAsync(() => initializer5 = new DisposableProbe("5"), async (probe, arg1, arg2, arg3, arg4, arg5, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("5"); + }, "a", "b", "c", "d", "e", async (ex, arg1, arg2, arg3, arg4, arg5, ct) => + { + await Task.Yield(); + actual.Add($"5:{arg1}:{arg2}:{arg3}:{arg4}:{arg5}:{ex.Message}"); + }); + + Assert.Null(result0); + Assert.Null(result1); + Assert.Null(result2); + Assert.Null(result3); + Assert.Null(result4); + Assert.Null(result5); + + Assert.Equal(new[] + { + "0:0", + "1:a:1", + "2:a:b:2", + "3:a:b:c:3", + "4:a:b:c:d:4", + "5:a:b:c:d:e:5" + }, actual); + + Assert.True(initializer0.IsDisposed); + Assert.True(initializer1.IsDisposed); + Assert.True(initializer2.IsDisposed); + Assert.True(initializer3.IsDisposed); + Assert.True(initializer4.IsDisposed); + Assert.True(initializer5.IsDisposed); + } + + [Fact] + public async Task SafeInvokeAsync_ShouldRethrowAndDisposeInitializer_WhenNoCatcherIsProvided() + { + DisposableProbe initializer = null; + + await Assert.ThrowsAsync(() => AsyncPatterns.SafeInvokeAsync(() => initializer = new DisposableProbe("boom"), async (probe, ct) => + { + await Task.Yield(); + throw new InvalidOperationException("boom"); + })); + + Assert.True(initializer.IsDisposed); + } + + private sealed class DisposableProbe : IDisposable + { + public DisposableProbe(string value) + { + Value = value; + } + + public bool IsDisposed { get; private set; } + + public string Value { get; } + + public void Dispose() + { + IsDisposed = true; + } + } + } +} diff --git a/test/Cuemon.Threading.Tests/ParallelFactoryAsyncTest.cs b/test/Cuemon.Threading.Tests/ParallelFactoryAsyncTest.cs index 2566f17ad..07052dac2 100644 --- a/test/Cuemon.Threading.Tests/ParallelFactoryAsyncTest.cs +++ b/test/Cuemon.Threading.Tests/ParallelFactoryAsyncTest.cs @@ -369,7 +369,7 @@ public async Task WhileAsync_ShouldRunConcurrent() var ic = new Queue(expected); var cb = new ConcurrentBag(); - await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(50, ct); cb.Add(i); @@ -394,7 +394,7 @@ public async Task WhileAsync_ShouldRunConcurrent_IgniteCancellation() await Assert.ThrowsAnyAsync(async () => { - await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { if (i > 450) { cts.Cancel(); } await Task.Delay(Generate.RandomNumber(25, 75), ct); @@ -416,7 +416,7 @@ public async Task WhileAsync_ShouldRunConcurrent_LongRunning_SystemPartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(_longRunningTaskWaitTime, ct); cb.Add(i); @@ -435,7 +435,7 @@ public async Task WhileAsync_ShouldRunConcurrent_LongRunning_ExtremePartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + await AdvancedParallelFactory.WhileAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(5, ct); cb.Add(i); @@ -458,7 +458,7 @@ public async Task WhileResultAsync_ShouldRunConcurrent() var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(50, ct); cb.Add(i); @@ -484,7 +484,7 @@ public async Task WhileResultAsync_ShouldRunConcurrent_IgniteCancellation() await Assert.ThrowsAnyAsync(async () => { - await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { if (i > 450) { cts.Cancel(); } await Task.Delay(Generate.RandomNumber(25, 75), ct); @@ -507,7 +507,7 @@ public async Task WhileResultAsync_ShouldRunConcurrent_LongRunning_SystemPartiti var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(_longRunningTaskWaitTime, ct); cb.Add(i); @@ -527,7 +527,7 @@ public async Task WhileResultAsync_ShouldRunConcurrent_LongRunning_ExtremePartit var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.TryPeek(out _)), intProvider => intProvider.Dequeue(), async (i, ct) => + var result = await AdvancedParallelFactory.WhileResultAsync(ic, () => Task.FromResult(ic.Count > 0), intProvider => intProvider.Dequeue(), async (i, ct) => { await Task.Delay(5, ct); cb.Add(i); @@ -544,4 +544,4 @@ public async Task WhileResultAsync_ShouldRunConcurrent_LongRunning_ExtremePartit private static int MaxThreadCount => IsLinux ? 1024 : 4096; } -} \ No newline at end of file +} diff --git a/test/Cuemon.Threading.Tests/ParallelFactoryOverloadTest.cs b/test/Cuemon.Threading.Tests/ParallelFactoryOverloadTest.cs new file mode 100644 index 000000000..7bc275f55 --- /dev/null +++ b/test/Cuemon.Threading.Tests/ParallelFactoryOverloadTest.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Threading +{ + [Trait("Category", "Threading")] + public class ParallelFactoryOverloadTest : Test + { + public ParallelFactoryOverloadTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void For_ShouldSupportAllOverloads() + { + var actual = new ConcurrentBag(); + var expected = new List(); + + ParallelFactory.For(0, 3, i => actual.Add($"i0:{i}"), ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i0:{i}")); + + ParallelFactory.For(0, 3, (i, a) => actual.Add($"i1:{a}:{i}"), "a", ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i1:a:{i}")); + + ParallelFactory.For(0, 3, (i, a, b) => actual.Add($"i2:{a}:{b}:{i}"), "a", "b", ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i2:a:b:{i}")); + + ParallelFactory.For(0, 3, (i, a, b, c) => actual.Add($"i3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i3:a:b:c:{i}")); + + ParallelFactory.For(0, 3, (i, a, b, c, d) => actual.Add($"i4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i4:a:b:c:d:{i}")); + + ParallelFactory.For(0, 3, (i, a, b, c, d, e) => actual.Add($"i5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureSync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i5:a:b:c:d:e:{i}")); + + ParallelFactory.For(0L, 3L, i => actual.Add($"l0:{i}"), ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l0:{i}")); + + ParallelFactory.For(0L, 3L, (i, a) => actual.Add($"l1:{a}:{i}"), "a", ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l1:a:{i}")); + + ParallelFactory.For(0L, 3L, (i, a, b) => actual.Add($"l2:{a}:{b}:{i}"), "a", "b", ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l2:a:b:{i}")); + + ParallelFactory.For(0L, 3L, (i, a, b, c) => actual.Add($"l3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l3:a:b:c:{i}")); + + ParallelFactory.For(0L, 3L, (i, a, b, c, d) => actual.Add($"l4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l4:a:b:c:d:{i}")); + + ParallelFactory.For(0L, 3L, (i, a, b, c, d, e) => actual.Add($"l5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureSync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l5:a:b:c:d:e:{i}")); + + AssertEquivalent(expected, actual); + } + + [Fact] + public async Task ForAsync_ShouldSupportAllOverloads() + { + var actual = new ConcurrentBag(); + var expected = new List(); + + await ParallelFactory.ForAsync(0, 3, (i, ct) => + { + actual.Add($"i0:{i}"); + return Task.CompletedTask; + }, ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i0:{i}")); + + await ParallelFactory.ForAsync(0, 3, (i, a, ct) => + { + actual.Add($"i1:{a}:{i}"); + return Task.CompletedTask; + }, "a", ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i1:a:{i}")); + + await ParallelFactory.ForAsync(0, 3, (i, a, b, ct) => + { + actual.Add($"i2:{a}:{b}:{i}"); + return Task.CompletedTask; + }, "a", "b", ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i2:a:b:{i}")); + + await ParallelFactory.ForAsync(0, 3, (i, a, b, c, ct) => + { + actual.Add($"i3:{a}:{b}:{c}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i3:a:b:c:{i}")); + + await ParallelFactory.ForAsync(0, 3, (i, a, b, c, d, ct) => + { + actual.Add($"i4:{a}:{b}:{c}:{d}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i4:a:b:c:d:{i}")); + + await ParallelFactory.ForAsync(0, 3, (i, a, b, c, d, e, ct) => + { + actual.Add($"i5:{a}:{b}:{c}:{d}:{e}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", "e", ConfigureAsync()); + expected.AddRange(Enumerable.Range(0, 3).Select(i => $"i5:a:b:c:d:e:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, ct) => + { + actual.Add($"l0:{i}"); + return Task.CompletedTask; + }, ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l0:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, a, ct) => + { + actual.Add($"l1:{a}:{i}"); + return Task.CompletedTask; + }, "a", ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l1:a:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, a, b, ct) => + { + actual.Add($"l2:{a}:{b}:{i}"); + return Task.CompletedTask; + }, "a", "b", ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l2:a:b:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, a, b, c, ct) => + { + actual.Add($"l3:{a}:{b}:{c}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l3:a:b:c:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, a, b, c, d, ct) => + { + actual.Add($"l4:{a}:{b}:{c}:{d}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l4:a:b:c:d:{i}")); + + await ParallelFactory.ForAsync(0L, 3L, (i, a, b, c, d, e, ct) => + { + actual.Add($"l5:{a}:{b}:{c}:{d}:{e}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", "e", ConfigureAsync()); + expected.AddRange(new long[] { 0, 1, 2 }.Select(i => $"l5:a:b:c:d:e:{i}")); + + AssertEquivalent(expected, actual); + } + + [Fact] + public void ForResult_ShouldSupportAllOverloads() + { + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i0:{i}"), ParallelFactory.ForResult(0, 3, i => $"i0:{i}", ConfigureSync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i1:a:{i}"), ParallelFactory.ForResult(0, 3, (i, a) => $"i1:{a}:{i}", "a", ConfigureSync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i2:a:b:{i}"), ParallelFactory.ForResult(0, 3, (i, a, b) => $"i2:{a}:{b}:{i}", "a", "b", ConfigureSync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i3:a:b:c:{i}"), ParallelFactory.ForResult(0, 3, (i, a, b, c) => $"i3:{a}:{b}:{c}:{i}", "a", "b", "c", ConfigureSync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i4:a:b:c:d:{i}"), ParallelFactory.ForResult(0, 3, (i, a, b, c, d) => $"i4:{a}:{b}:{c}:{d}:{i}", "a", "b", "c", "d", ConfigureSync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i5:a:b:c:d:e:{i}"), ParallelFactory.ForResult(0, 3, (i, a, b, c, d, e) => $"i5:{a}:{b}:{c}:{d}:{e}:{i}", "a", "b", "c", "d", "e", ConfigureSync())); + + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l0:{i}"), ParallelFactory.ForResult(0L, 3L, i => $"l0:{i}", ConfigureSync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l1:a:{i}"), ParallelFactory.ForResult(0L, 3L, (i, a) => $"l1:{a}:{i}", "a", ConfigureSync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l2:a:b:{i}"), ParallelFactory.ForResult(0L, 3L, (i, a, b) => $"l2:{a}:{b}:{i}", "a", "b", ConfigureSync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l3:a:b:c:{i}"), ParallelFactory.ForResult(0L, 3L, (i, a, b, c) => $"l3:{a}:{b}:{c}:{i}", "a", "b", "c", ConfigureSync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l4:a:b:c:d:{i}"), ParallelFactory.ForResult(0L, 3L, (i, a, b, c, d) => $"l4:{a}:{b}:{c}:{d}:{i}", "a", "b", "c", "d", ConfigureSync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l5:a:b:c:d:e:{i}"), ParallelFactory.ForResult(0L, 3L, (i, a, b, c, d, e) => $"l5:{a}:{b}:{c}:{d}:{e}:{i}", "a", "b", "c", "d", "e", ConfigureSync())); + } + + [Fact] + public async Task ForResultAsync_ShouldSupportAllOverloads() + { + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i0:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, ct) => Task.FromResult($"i0:{i}"), ConfigureAsync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i1:a:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, a, ct) => Task.FromResult($"i1:{a}:{i}"), "a", ConfigureAsync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i2:a:b:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, a, b, ct) => Task.FromResult($"i2:{a}:{b}:{i}"), "a", "b", ConfigureAsync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i3:a:b:c:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, a, b, c, ct) => Task.FromResult($"i3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureAsync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i4:a:b:c:d:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, a, b, c, d, ct) => Task.FromResult($"i4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureAsync())); + Assert.Equal(Enumerable.Range(0, 3).Select(i => $"i5:a:b:c:d:e:{i}"), await ParallelFactory.ForResultAsync(0, 3, (i, a, b, c, d, e, ct) => Task.FromResult($"i5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureAsync())); + + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l0:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, ct) => Task.FromResult($"l0:{i}"), ConfigureAsync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l1:a:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, a, ct) => Task.FromResult($"l1:{a}:{i}"), "a", ConfigureAsync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l2:a:b:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, a, b, ct) => Task.FromResult($"l2:{a}:{b}:{i}"), "a", "b", ConfigureAsync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l3:a:b:c:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, a, b, c, ct) => Task.FromResult($"l3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureAsync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l4:a:b:c:d:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, a, b, c, d, ct) => Task.FromResult($"l4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureAsync())); + Assert.Equal(new long[] { 0, 1, 2 }.Select(i => $"l5:a:b:c:d:e:{i}"), await ParallelFactory.ForResultAsync(0L, 3L, (i, a, b, c, d, e, ct) => Task.FromResult($"l5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureAsync())); + } + + [Fact] + public void ForEach_ShouldSupportAllOverloads() + { + var source = new[] { 0, 1, 2 }; + var actual = new ConcurrentBag(); + var expected = new List(); + + ParallelFactory.ForEach(source, i => actual.Add($"s0:{i}"), ConfigureSync()); + expected.AddRange(source.Select(i => $"s0:{i}")); + + ParallelFactory.ForEach(source, (i, a) => actual.Add($"s1:{a}:{i}"), "a", ConfigureSync()); + expected.AddRange(source.Select(i => $"s1:a:{i}")); + + ParallelFactory.ForEach(source, (i, a, b) => actual.Add($"s2:{a}:{b}:{i}"), "a", "b", ConfigureSync()); + expected.AddRange(source.Select(i => $"s2:a:b:{i}")); + + ParallelFactory.ForEach(source, (i, a, b, c) => actual.Add($"s3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureSync()); + expected.AddRange(source.Select(i => $"s3:a:b:c:{i}")); + + ParallelFactory.ForEach(source, (i, a, b, c, d) => actual.Add($"s4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureSync()); + expected.AddRange(source.Select(i => $"s4:a:b:c:d:{i}")); + + ParallelFactory.ForEach(source, (i, a, b, c, d, e) => actual.Add($"s5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureSync()); + expected.AddRange(source.Select(i => $"s5:a:b:c:d:e:{i}")); + + AssertEquivalent(expected, actual); + } + + [Fact] + public async Task ForEachAsync_ShouldSupportAllOverloads() + { + var source = new[] { 0, 1, 2 }; + var actual = new ConcurrentBag(); + var expected = new List(); + + await ParallelFactory.ForEachAsync(source, (i, ct) => + { + actual.Add($"s0:{i}"); + return Task.CompletedTask; + }, ConfigureAsync()); + expected.AddRange(source.Select(i => $"s0:{i}")); + + await ParallelFactory.ForEachAsync(source, (i, a, ct) => + { + actual.Add($"s1:{a}:{i}"); + return Task.CompletedTask; + }, "a", ConfigureAsync()); + expected.AddRange(source.Select(i => $"s1:a:{i}")); + + await ParallelFactory.ForEachAsync(source, (i, a, b, ct) => + { + actual.Add($"s2:{a}:{b}:{i}"); + return Task.CompletedTask; + }, "a", "b", ConfigureAsync()); + expected.AddRange(source.Select(i => $"s2:a:b:{i}")); + + await ParallelFactory.ForEachAsync(source, (i, a, b, c, ct) => + { + actual.Add($"s3:{a}:{b}:{c}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", ConfigureAsync()); + expected.AddRange(source.Select(i => $"s3:a:b:c:{i}")); + + await ParallelFactory.ForEachAsync(source, (i, a, b, c, d, ct) => + { + actual.Add($"s4:{a}:{b}:{c}:{d}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", ConfigureAsync()); + expected.AddRange(source.Select(i => $"s4:a:b:c:d:{i}")); + + await ParallelFactory.ForEachAsync(source, (i, a, b, c, d, e, ct) => + { + actual.Add($"s5:{a}:{b}:{c}:{d}:{e}:{i}"); + return Task.CompletedTask; + }, "a", "b", "c", "d", "e", ConfigureAsync()); + expected.AddRange(source.Select(i => $"s5:a:b:c:d:e:{i}")); + + AssertEquivalent(expected, actual); + } + + [Fact] + public void ForEachResult_ShouldSupportAllOverloads() + { + var source = new[] { 0, 1, 2 }; + + Assert.Equal(source.Select(i => $"s0:{i}"), ParallelFactory.ForEachResult(source, i => $"s0:{i}", ConfigureSync())); + Assert.Equal(source.Select(i => $"s1:a:{i}"), ParallelFactory.ForEachResult(source, (i, a) => $"s1:{a}:{i}", "a", ConfigureSync())); + Assert.Equal(source.Select(i => $"s2:a:b:{i}"), ParallelFactory.ForEachResult(source, (i, a, b) => $"s2:{a}:{b}:{i}", "a", "b", ConfigureSync())); + Assert.Equal(source.Select(i => $"s3:a:b:c:{i}"), ParallelFactory.ForEachResult(source, (i, a, b, c) => $"s3:{a}:{b}:{c}:{i}", "a", "b", "c", ConfigureSync())); + Assert.Equal(source.Select(i => $"s4:a:b:c:d:{i}"), ParallelFactory.ForEachResult(source, (i, a, b, c, d) => $"s4:{a}:{b}:{c}:{d}:{i}", "a", "b", "c", "d", ConfigureSync())); + Assert.Equal(source.Select(i => $"s5:a:b:c:d:e:{i}"), ParallelFactory.ForEachResult(source, (i, a, b, c, d, e) => $"s5:{a}:{b}:{c}:{d}:{e}:{i}", "a", "b", "c", "d", "e", ConfigureSync())); + } + + [Fact] + public async Task ForEachResultAsync_ShouldSupportAllOverloads() + { + var source = new[] { 0, 1, 2 }; + + Assert.Equal(source.Select(i => $"s0:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, ct) => Task.FromResult($"s0:{i}"), ConfigureAsync())); + Assert.Equal(source.Select(i => $"s1:a:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, a, ct) => Task.FromResult($"s1:{a}:{i}"), "a", ConfigureAsync())); + Assert.Equal(source.Select(i => $"s2:a:b:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, a, b, ct) => Task.FromResult($"s2:{a}:{b}:{i}"), "a", "b", ConfigureAsync())); + Assert.Equal(source.Select(i => $"s3:a:b:c:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, a, b, c, ct) => Task.FromResult($"s3:{a}:{b}:{c}:{i}"), "a", "b", "c", ConfigureAsync())); + Assert.Equal(source.Select(i => $"s4:a:b:c:d:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, a, b, c, d, ct) => Task.FromResult($"s4:{a}:{b}:{c}:{d}:{i}"), "a", "b", "c", "d", ConfigureAsync())); + Assert.Equal(source.Select(i => $"s5:a:b:c:d:e:{i}"), await ParallelFactory.ForEachResultAsync(source, (i, a, b, c, d, e, ct) => Task.FromResult($"s5:{a}:{b}:{c}:{d}:{e}:{i}"), "a", "b", "c", "d", "e", ConfigureAsync())); + } + + private static Action ConfigureSync() + { + return options => + { + options.CreationOptions = TaskCreationOptions.None; + options.PartitionSize = 2; + }; + } + + private static Action ConfigureAsync() + { + return options => options.PartitionSize = 2; + } + + private static void AssertEquivalent(IEnumerable expected, IEnumerable actual) + { + Assert.Equal(expected.OrderBy(item => item), actual.OrderBy(item => item)); + } + } +} diff --git a/test/Cuemon.Threading.Tests/ParallelFactoryTest.cs b/test/Cuemon.Threading.Tests/ParallelFactoryTest.cs index b32f887ee..950d27250 100644 --- a/test/Cuemon.Threading.Tests/ParallelFactoryTest.cs +++ b/test/Cuemon.Threading.Tests/ParallelFactoryTest.cs @@ -406,7 +406,7 @@ public void While_ShouldRunConcurrent() var ic = new Queue(expected); var cb = new ConcurrentBag(); - AdvancedParallelFactory.While(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + AdvancedParallelFactory.While(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(50); cb.Add(i); @@ -431,7 +431,7 @@ public void While_ShouldRunConcurrent_IgniteCancellation() var x = 0; var ae = Assert.Throws(() => { - AdvancedParallelFactory.While(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + AdvancedParallelFactory.While(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Interlocked.Increment(ref x); if (i > 450) { cts.Cancel(); } @@ -460,7 +460,7 @@ public void While_ShouldRunConcurrent_LongRunning_SystemPartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - AdvancedParallelFactory.While(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + AdvancedParallelFactory.While(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(_longRunningTaskWaitTime); cb.Add(i); @@ -479,7 +479,7 @@ public void While_ShouldRunConcurrent_LongRunning_ExtremePartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - AdvancedParallelFactory.While(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + AdvancedParallelFactory.While(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(5); cb.Add(i); @@ -502,7 +502,7 @@ public void WhileResult_ShouldRunConcurrent() var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = AdvancedParallelFactory.WhileResult(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + var result = AdvancedParallelFactory.WhileResult(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(50); cb.Add(i); @@ -528,7 +528,7 @@ public void WhileResult_ShouldRunConcurrent_IgniteCancellation() var x = 0; var ae = Assert.Throws(() => { - AdvancedParallelFactory.WhileResult(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + AdvancedParallelFactory.WhileResult(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Interlocked.Increment(ref x); if (i > 450) { cts.Cancel(); } @@ -559,7 +559,7 @@ public void WhileResult_ShouldRunConcurrent_LongRunning_SystemPartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = AdvancedParallelFactory.WhileResult(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + var result = AdvancedParallelFactory.WhileResult(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(_longRunningTaskWaitTime); cb.Add(i); @@ -579,7 +579,7 @@ public void WhileResult_ShouldRunConcurrent_LongRunning_ExtremePartition() var ic = new Queue(expected); var cb = new ConcurrentBag(); - var result = AdvancedParallelFactory.WhileResult(ic, () => ic.TryPeek(out _), intProvider => intProvider.Dequeue(), i => + var result = AdvancedParallelFactory.WhileResult(ic, () => ic.Count > 0, intProvider => intProvider.Dequeue(), i => { Thread.Sleep(5); cb.Add(i); @@ -596,4 +596,4 @@ public void WhileResult_ShouldRunConcurrent_LongRunning_ExtremePartition() private static int MaxThreadCount => IsLinux ? 1024 : 4096; } -} \ No newline at end of file +} diff --git a/test/Cuemon.Xml.Tests/Extensions/Linq/StringDecoratorExtensionsTest.cs b/test/Cuemon.Xml.Tests/Extensions/Linq/StringDecoratorExtensionsTest.cs new file mode 100644 index 000000000..f8c9cf39b --- /dev/null +++ b/test/Cuemon.Xml.Tests/Extensions/Linq/StringDecoratorExtensionsTest.cs @@ -0,0 +1,76 @@ +using System.Xml.Linq; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml.Linq +{ + public class StringDecoratorExtensionsTest : Test + { + public StringDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TryParseXElement_ShouldReturnTrue_WhenValidXml() + { + var result = Decorator.Enclose("value").TryParseXElement(out var element); + Assert.True(result); + Assert.NotNull(element); + Assert.Equal("root", element.Name.LocalName); + TestOutput.WriteLine(element.ToString()); + } + + [Fact] + public void TryParseXElement_ShouldReturnFalse_WhenInvalidXml() + { + var result = Decorator.Enclose("unclosed").TryParseXElement(out var element); + Assert.False(result); + Assert.Null(element); + } + + [Fact] + public void TryParseXElement_ShouldReturnFalse_WhenNotStartingWithAngleBracket() + { + var result = Decorator.Enclose("not-xml").TryParseXElement(out var element); + Assert.False(result); + Assert.Null(element); + } + + [Fact] + public void TryParseXElement_ShouldReturnFalse_WhenWhitespace() + { + var result = Decorator.Enclose(" ").TryParseXElement(out var element); + Assert.False(result); + Assert.Null(element); + } + + [Fact] + public void TryParseXElement_WithLoadOptions_ShouldReturnTrue_WhenValidXml() + { + var result = Decorator.Enclose("value").TryParseXElement(LoadOptions.None, out var element); + Assert.True(result); + Assert.NotNull(element); + } + + [Fact] + public void IsXmlString_ShouldReturnTrue_WhenValidXml() + { + var result = Decorator.Enclose("").IsXmlString(); + Assert.True(result); + } + + [Fact] + public void IsXmlString_ShouldReturnFalse_WhenNotXml() + { + var result = Decorator.Enclose("plain text").IsXmlString(); + Assert.False(result); + } + + [Fact] + public void IsXmlString_ShouldReturnFalse_WhenEmpty() + { + var result = Decorator.Enclose("").IsXmlString(); + Assert.False(result); + } + } +} diff --git a/test/Cuemon.Xml.Tests/Extensions/StreamDecoratorExtensionsTest.cs b/test/Cuemon.Xml.Tests/Extensions/StreamDecoratorExtensionsTest.cs new file mode 100644 index 000000000..fb9bbfd9c --- /dev/null +++ b/test/Cuemon.Xml.Tests/Extensions/StreamDecoratorExtensionsTest.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml +{ + public class StreamDecoratorExtensionsTest : Test + { + public StreamDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ToXmlReader_ShouldReturnXmlReader_WhenValidXmlStream() + { + var xml = "hello"; + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(xml))) + { + var reader = Decorator.Enclose(ms).ToXmlReader(); + Assert.NotNull(reader); + reader.MoveToContent(); + Assert.Equal("root", reader.LocalName); + TestOutput.WriteLine(reader.LocalName); + } + } + + [Fact] + public void ToXmlReader_WithExplicitEncoding_ShouldReturnXmlReader() + { + var xml = "hello"; + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(xml))) + { + var reader = Decorator.Enclose(ms).ToXmlReader(Encoding.UTF8); + Assert.NotNull(reader); + reader.MoveToContent(); + Assert.Equal("root", reader.LocalName); + } + } + + [Fact] + public void ToXmlReader_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StreamDecoratorExtensions.ToXmlReader(null)); + } + + [Fact] + public void TryDetectXmlEncoding_ShouldReturnTrueWithUtf8_WhenXmlDeclarationPresent() + { + var xml = ""; + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(xml))) + { + var result = Decorator.Enclose(ms).TryDetectXmlEncoding(out var encoding); + Assert.True(result); + Assert.NotNull(encoding); + TestOutput.WriteLine(encoding.EncodingName); + } + } + + [Fact] + public void TryDetectXmlEncoding_ShouldReturnFalse_WhenNoEncodingInfo() + { + var xml = ""; + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(xml))) + { + var result = Decorator.Enclose(ms).TryDetectXmlEncoding(out var encoding); + Assert.False(result); + Assert.NotNull(encoding); + } + } + + [Fact] + public void TryDetectXmlEncoding_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StreamDecoratorExtensions.TryDetectXmlEncoding(null, out _)); + } + } +} diff --git a/test/Cuemon.Xml.Tests/Extensions/StringDecoratorExtensionsTest.cs b/test/Cuemon.Xml.Tests/Extensions/StringDecoratorExtensionsTest.cs new file mode 100644 index 000000000..77e74a987 --- /dev/null +++ b/test/Cuemon.Xml.Tests/Extensions/StringDecoratorExtensionsTest.cs @@ -0,0 +1,130 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml +{ + public class StringDecoratorExtensionsTest : Test + { + public StringDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void EscapeXml_ShouldEscapeSpecialCharacters() + { + var result = Decorator.Enclose("").EscapeXml(); + Assert.Equal("<hello & 'world' "test">", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void EscapeXml_ShouldReturnSameString_WhenNoSpecialChars() + { + var result = Decorator.Enclose("hello world").EscapeXml(); + Assert.Equal("hello world", result); + } + + [Fact] + public void EscapeXml_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StringDecoratorExtensions.EscapeXml(null)); + } + + [Fact] + public void UnescapeXml_ShouldUnescapeEntities() + { + var result = Decorator.Enclose("<hello & 'world' "test">").UnescapeXml(); + Assert.Equal("", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void UnescapeXml_ShouldReturnSameString_WhenNoEntities() + { + var result = Decorator.Enclose("hello world").UnescapeXml(); + Assert.Equal("hello world", result); + } + + [Fact] + public void UnescapeXml_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StringDecoratorExtensions.UnescapeXml(null)); + } + + [Fact] + public void SanitizeXmlElementName_ShouldRemoveInvalidCharacters() + { + var result = Decorator.Enclose("hello world! @#test").SanitizeXmlElementName(); + Assert.Equal("helloworldtest", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void SanitizeXmlElementName_ShouldTrimLeadingNumbers() + { + var result = Decorator.Enclose("123abc").SanitizeXmlElementName(); + Assert.Equal("abc", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void SanitizeXmlElementName_ShouldTrimLeadingDots() + { + var result = Decorator.Enclose(".abc").SanitizeXmlElementName(); + Assert.Equal("abc", result); + } + + [Fact] + public void SanitizeXmlElementName_ShouldAllowValidCharacters() + { + var result = Decorator.Enclose("valid-element_name.123").SanitizeXmlElementName(); + Assert.Equal("valid-element_name.123", result); + } + + [Fact] + public void SanitizeXmlElementName_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StringDecoratorExtensions.SanitizeXmlElementName(null)); + } + + [Fact] + public void SanitizeXmlElementText_ShouldRemoveControlCharacters() + { + var input = "hello\x0001\x0002\x0005world"; + var result = Decorator.Enclose(input).SanitizeXmlElementText(); + Assert.Equal("helloworld", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void SanitizeXmlElementText_ShouldReturnEmpty_WhenInputIsEmpty() + { + var result = Decorator.Enclose("").SanitizeXmlElementText(); + Assert.Equal("", result); + } + + [Fact] + public void SanitizeXmlElementText_WithCdataSection_ShouldRemoveCdataClosingSequence() + { + var input = "hello]]>world"; + var result = Decorator.Enclose(input).SanitizeXmlElementText(cdataSection: true); + Assert.Equal("helloworld", result); + TestOutput.WriteLine(result); + } + + [Fact] + public void SanitizeXmlElementText_WithoutCdataSection_ShouldPreserveCdataSequence() + { + var input = "hello]]>world"; + var result = Decorator.Enclose(input).SanitizeXmlElementText(cdataSection: false); + Assert.Equal("hello]]>world", result); + } + + [Fact] + public void SanitizeXmlElementText_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => StringDecoratorExtensions.SanitizeXmlElementText(null)); + } + } +} diff --git a/test/Cuemon.Xml.Tests/Extensions/XmlReaderDecoratorExtensionsTest.cs b/test/Cuemon.Xml.Tests/Extensions/XmlReaderDecoratorExtensionsTest.cs new file mode 100644 index 000000000..03682f8c0 --- /dev/null +++ b/test/Cuemon.Xml.Tests/Extensions/XmlReaderDecoratorExtensionsTest.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml +{ + public class XmlReaderDecoratorExtensionsTest : Test + { + public XmlReaderDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + private static XmlReader CreateReaderFromXml(string xml) + { + var settings = new XmlReaderSettings { IgnoreWhitespace = true }; + return XmlReader.Create(new StringReader(xml), settings); + } + + [Fact] + public void MoveToFirstElement_ShouldReturnTrue_WhenElementExists() + { + using (var reader = CreateReaderFromXml("")) + { + var result = Decorator.Enclose(reader).MoveToFirstElement(); + Assert.True(result); + Assert.Equal("root", reader.LocalName); + TestOutput.WriteLine(reader.LocalName); + } + } + + [Fact] + public void MoveToFirstElement_ShouldReturnFalse_WhenNoElements() + { + var settings = new XmlReaderSettings { IgnoreWhitespace = true, ConformanceLevel = System.Xml.ConformanceLevel.Fragment }; + using (var reader = XmlReader.Create(new StringReader(""), settings)) + { + var result = Decorator.Enclose(reader).MoveToFirstElement(); + Assert.False(result); + } + } + + [Fact] + public void MoveToFirstElement_ShouldThrowArgumentException_WhenReaderAlreadyRead() + { + using (var reader = CreateReaderFromXml("")) + { + reader.Read(); + Assert.Throws(() => Decorator.Enclose(reader).MoveToFirstElement()); + } + } + + [Fact] + public void MoveToFirstElement_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XmlReaderDecoratorExtensions.MoveToFirstElement(null)); + } + + [Fact] + public void Chunk_ShouldSplitXmlIntoChunks_WhenSizeIsOne() + { + const string xml = ""; + using (var reader = CreateReaderFromXml(xml)) + { + var chunks = new List(Decorator.Enclose(reader).Chunk(size: 1)); + Assert.Equal(3, chunks.Count); + TestOutput.WriteLine($"Chunk count: {chunks.Count}"); + } + } + + [Fact] + public void Chunk_ShouldReturnSingleChunk_WhenAllFitInSize() + { + const string xml = ""; + using (var reader = CreateReaderFromXml(xml)) + { + var chunks = new List(Decorator.Enclose(reader).Chunk(size: 128)); + Assert.Equal(1, chunks.Count); + } + } + + [Fact] + public void Chunk_ShouldThrowArgumentNullException_WhenDecoratorIsNull() + { + Assert.Throws(() => + { + var _ = new List(XmlReaderDecoratorExtensions.Chunk(null)); + }); + } + + [Fact] + public void Chunk_ShouldThrowArgumentException_WhenReaderAlreadyRead() + { + using (var reader = CreateReaderFromXml("")) + { + reader.Read(); + Assert.Throws(() => + { + var _ = new List(Decorator.Enclose(reader).Chunk()); + }); + } + } + + [Fact] + public void ToHierarchy_ShouldConvertXmlToHierarchy() + { + const string xml = "Alice30"; + using (var reader = XmlReader.Create(new StringReader(xml))) + { + var hierarchy = Decorator.Enclose(reader).ToHierarchy(); + Assert.NotNull(hierarchy); + TestOutput.WriteLine(hierarchy.ToString()); + } + } + + [Fact] + public void ToHierarchy_ShouldHandleAttributes() + { + const string xml = ""; + using (var reader = XmlReader.Create(new StringReader(xml))) + { + var hierarchy = Decorator.Enclose(reader).ToHierarchy(); + Assert.NotNull(hierarchy); + } + } + + [Fact] + public void ToHierarchy_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XmlReaderDecoratorExtensions.ToHierarchy(null)); + } + } +} diff --git a/test/Cuemon.Xml.Tests/Extensions/XmlWriterDecoratorExtensionsTest.cs b/test/Cuemon.Xml.Tests/Extensions/XmlWriterDecoratorExtensionsTest.cs new file mode 100644 index 000000000..d379a00fb --- /dev/null +++ b/test/Cuemon.Xml.Tests/Extensions/XmlWriterDecoratorExtensionsTest.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Xml; +using Codebelt.Extensions.Xunit; +using Cuemon.Xml.Serialization; +using Xunit; + +namespace Cuemon.Xml +{ + public class XmlWriterDecoratorExtensionsTest : Test + { + public XmlWriterDecoratorExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + private static (MemoryStream Stream, XmlWriter Writer) CreateWriter() + { + var ms = new MemoryStream(); + var settings = new XmlWriterSettings { Indent = false, OmitXmlDeclaration = true }; + return (ms, XmlWriter.Create(ms, settings)); + } + + [Fact] + public void WriteObject_Generic_ShouldSerializeObject() + { + var (ms, writer) = CreateWriter(); + using (writer) + { + Decorator.Enclose(writer).WriteObject("hello"); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("hello", xml); + TestOutput.WriteLine(xml); + } + + [Fact] + public void WriteObject_WithType_ShouldSerializeObject() + { + var (ms, writer) = CreateWriter(); + using (writer) + { + Decorator.Enclose(writer).WriteObject("hello", typeof(string)); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("hello", xml); + } + + [Fact] + public void WriteObject_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + XmlWriterDecoratorExtensions.WriteObject(null, "value")); + } + + [Fact] + public void WriteObject_WithType_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + XmlWriterDecoratorExtensions.WriteObject(null, "value", typeof(string))); + } + + [Fact] + public void WriteStartElement_ShouldWriteElement() + { + var (ms, writer) = CreateWriter(); + using (writer) + { + var entity = new XmlQualifiedEntity("myElement"); + Decorator.Enclose(writer).WriteStartElement(entity); + writer.WriteEndElement(); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("myElement", xml); + TestOutput.WriteLine(xml); + } + + [Fact] + public void WriteStartElement_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + XmlWriterDecoratorExtensions.WriteStartElement(null, new XmlQualifiedEntity("test"))); + } + + [Fact] + public void WriteEncapsulatingElementIfNotNull_WithElementName_ShouldWrapInElement() + { + var (ms, writer) = CreateWriter(); + using (writer) + { + var entity = new XmlQualifiedEntity("wrapper"); + Decorator.Enclose(writer).WriteEncapsulatingElementIfNotNull("content", entity, (w, v) => w.WriteString(v)); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("wrapper", xml); + Assert.Contains("content", xml); + TestOutput.WriteLine(xml); + } + + [Fact] + public void WriteEncapsulatingElementIfNotNull_WithNullElementName_ShouldNotWrap() + { + var ms = new MemoryStream(); + var settings = new XmlWriterSettings { Indent = false, OmitXmlDeclaration = true, ConformanceLevel = System.Xml.ConformanceLevel.Fragment }; + using (var writer = XmlWriter.Create(ms, settings)) + { + Decorator.Enclose(writer).WriteEncapsulatingElementIfNotNull("content", null, (w, v) => w.WriteString(v)); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("content", xml); + } + + [Fact] + public void WriteEncapsulatingElementIfNotNull_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + XmlWriterDecoratorExtensions.WriteEncapsulatingElementIfNotNull( + null, "value", new XmlQualifiedEntity("x"), (w, v) => w.WriteString(v))); + } + + [Fact] + public void WriteXmlRootElement_ShouldWriteRootElement() + { + var (ms, writer) = CreateWriter(); + using (writer) + { + Decorator.Enclose(writer).WriteXmlRootElement("hello", (w, v, entity) => w.WriteString(v)); + } + var xml = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("hello", xml); + TestOutput.WriteLine(xml); + } + + [Fact] + public void WriteXmlRootElement_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + XmlWriterDecoratorExtensions.WriteXmlRootElement( + null, "value", (w, v, entity) => w.WriteString(v))); + } + } +} diff --git a/test/Cuemon.Xml.Tests/XPath/XPathDocumentFactoryTest.cs b/test/Cuemon.Xml.Tests/XPath/XPathDocumentFactoryTest.cs new file mode 100644 index 000000000..be277ba67 --- /dev/null +++ b/test/Cuemon.Xml.Tests/XPath/XPathDocumentFactoryTest.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.XPath; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml.XPath +{ + public class XPathDocumentFactoryTest : Test + { + public XPathDocumentFactoryTest(ITestOutputHelper output) : base(output) + { + } + + private const string SampleXml = "value"; + + [Fact] + public void CreateDocument_FromString_ShouldReturnXPathDocument() + { + var doc = XPathDocumentFactory.CreateDocument(SampleXml); + var nav = doc.CreateNavigator(); + Assert.True(nav.MoveToChild("root", "")); + TestOutput.WriteLine(nav.OuterXml); + } + + [Fact] + public void CreateDocument_FromString_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument((string)null)); + } + + [Fact] + public void CreateDocument_FromStringAndEncoding_ShouldReturnXPathDocument() + { + var doc = XPathDocumentFactory.CreateDocument(SampleXml, Encoding.UTF8); + var nav = doc.CreateNavigator(); + Assert.True(nav.MoveToChild("root", "")); + } + + [Fact] + public void CreateDocument_FromStringAndEncoding_NullString_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument(null, Encoding.UTF8)); + } + + [Fact] + public void CreateDocument_FromStringAndEncoding_NullEncoding_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument(SampleXml, null)); + } + + [Fact] + public void CreateDocument_FromStream_ShouldReturnXPathDocument() + { + using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(SampleXml))) + { + var doc = XPathDocumentFactory.CreateDocument(ms); + var nav = doc.CreateNavigator(); + Assert.True(nav.MoveToChild("root", "")); + } + } + + [Fact] + public void CreateDocument_FromStream_WithLeaveOpen_ShouldReturnXPathDocument() + { + var ms = new MemoryStream(Encoding.UTF8.GetBytes(SampleXml)); + var doc = XPathDocumentFactory.CreateDocument(ms, leaveOpen: true); + var nav = doc.CreateNavigator(); + Assert.True(nav.MoveToChild("root", "")); + Assert.Equal(0, ms.Position); + ms.Dispose(); + } + + [Fact] + public void CreateDocument_FromStream_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument((Stream)null)); + } + + [Fact] + public void CreateDocument_FromXmlReader_ShouldReturnXPathDocument() + { + using (var reader = XmlReader.Create(new StringReader(SampleXml))) + { + var doc = XPathDocumentFactory.CreateDocument(reader); + var nav = doc.CreateNavigator(); + Assert.True(nav.MoveToChild("root", "")); + } + } + + [Fact] + public void CreateDocument_FromXmlReader_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument((XmlReader)null)); + } + + [Fact] + public void CreateDocument_FromUri_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XPathDocumentFactory.CreateDocument((Uri)null)); + } + } +} diff --git a/test/Cuemon.Xml.Tests/XmlDocumentFactoryTest.cs b/test/Cuemon.Xml.Tests/XmlDocumentFactoryTest.cs new file mode 100644 index 000000000..d00bc1281 --- /dev/null +++ b/test/Cuemon.Xml.Tests/XmlDocumentFactoryTest.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Cuemon.Xml +{ + public class XmlDocumentFactoryTest : Test + { + public XmlDocumentFactoryTest(ITestOutputHelper output) : base(output) + { + } + + private static MemoryStream XmlStream(string xml = "value") + { + return new MemoryStream(Encoding.UTF8.GetBytes(xml)); + } + + [Fact] + public void CreateDocument_FromStream_ShouldReturnXmlDocument() + { + using (var ms = XmlStream()) + { + var doc = XmlDocumentFactory.CreateDocument(ms); + Assert.Equal("root", doc.DocumentElement.LocalName); + Assert.Equal("value", doc.DocumentElement.FirstChild.InnerText); + TestOutput.WriteLine(doc.DocumentElement.OuterXml); + } + } + + [Fact] + public void CreateDocument_FromStream_WithLeaveOpen_ShouldKeepStreamOpen() + { + var ms = XmlStream(); + var doc = XmlDocumentFactory.CreateDocument(ms, leaveOpen: true); + Assert.Equal("root", doc.DocumentElement.LocalName); + Assert.Equal(0, ms.Position); + ms.Dispose(); + } + + [Fact] + public void CreateDocument_FromStream_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XmlDocumentFactory.CreateDocument((Stream)null)); + } + + [Fact] + public void CreateDocument_FromXmlReader_ShouldReturnXmlDocument() + { + using (var reader = XmlReader.Create(new StringReader("value"))) + { + var doc = XmlDocumentFactory.CreateDocument(reader); + Assert.Equal("root", doc.DocumentElement.LocalName); + } + } + + [Fact] + public void CreateDocument_FromXmlReader_WithLeaveOpen_ShouldReturnXmlDocument() + { + var reader = XmlReader.Create(new StringReader("value")); + var doc = XmlDocumentFactory.CreateDocument(reader, leaveOpen: true); + Assert.Equal("root", doc.DocumentElement.LocalName); + reader.Dispose(); + } + + [Fact] + public void CreateDocument_FromUri_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XmlDocumentFactory.CreateDocument((Uri)null)); + } + + [Fact] + public void CreateDocument_FromString_ShouldReturnXmlDocument() + { + var doc = XmlDocumentFactory.CreateDocument("value"); + Assert.Equal("root", doc.DocumentElement.LocalName); + Assert.Equal("value", doc.DocumentElement.FirstChild.InnerText); + TestOutput.WriteLine(doc.OuterXml); + } + + [Fact] + public void CreateDocument_FromString_Null_ShouldThrowArgumentNullException() + { + Assert.Throws(() => XmlDocumentFactory.CreateDocument((string)null)); + } + + [Fact] + public void CreateDocument_FromString_Whitespace_ShouldThrowArgumentException() + { + Assert.Throws(() => XmlDocumentFactory.CreateDocument(" ")); + } + + [Fact] + public void CreateDocument_FromString_InvalidXml_ShouldThrowArgumentException() + { + Assert.Throws(() => XmlDocumentFactory.CreateDocument("this-is-not-xml")); + } + } +}