From bcbad22c4a2586b2dbe9085adcda1d101bcfd747 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 5 Aug 2026 11:41:05 -0700 Subject: [PATCH 1/5] Fix DI, JSON, cache, query, and numeric edge cases - HybridCacheEntryOptions.CreateFor: use typeof(T).Name for correct config lookup; add tests. - ExecutionContext.GetKeyedService: use IKeyedServiceProvider for interface-based DI; add tests. - HttpRequestMessage extensions: URI-encode query values to handle special chars; add tests. - HostedServiceBase.StopAsync: always transition through Stopping status; add tests. - JsonExceptionConverterFactory: exclude [JsonIgnore] and TargetSite from serialization; add tests. - JsonSubstituteNamingPolicy: use configured FallbackPolicy for missing substitutions; add tests. - RuntimeMetadata.AreEqual: fully enumerate and compare sequence lengths; add tests. - DecimalRuleHelper.CalcIntegralPartLength: fix digit count for large decimals near power-of-ten; add edge case tests. - Add or expand unit tests for all above fixes. --- src/CoreEx/Caching/HybridCacheEntryOptions.cs | 2 +- src/CoreEx/ExecutionContext.Infra.cs | 4 +- src/CoreEx/Extensions.HttpRequestMessage.cs | 2 +- src/CoreEx/Hosting/HostedServiceBase.cs | 2 - .../Json/JsonExceptionConverterFactory.cs | 2 +- src/CoreEx/Json/JsonSubstituteNamingPolicy.cs | 2 +- .../Metadata/RuntimeMetadata.AreEqual.cs | 3 +- .../Metadata/RuntimeMetadata.Internal.cs | 3 +- src/CoreEx/Results/ResultsExtensions.When.cs | 2 +- src/CoreEx/Validation/DecimalRuleHelper.cs | 14 +++- .../Caching/HybridCacheEntryOptionsTests.cs | 59 +++++++++++++++ .../CoreEx.Test.Unit/ExecutionContextTests.cs | 26 +++++++ .../Hosting/HostedServiceBaseTests.cs | 75 +++++++++++++++++++ .../Http/HttpRequestMessageExtensionsTests.cs | 45 +++++++++++ .../JsonExceptionConverterFactoryTests.cs | 60 +++++++++++++++ .../Json/JsonSubstituteNamingPolicyTests.cs | 14 ++++ .../Results/ExtensionsWhenTests.cs | 50 +++++++++++++ .../Runtime/RuntimeMetadataTests.cs | 34 +++++++++ .../Validation/DecimalRuleHelperTests.cs | 50 +++++++++++++ 19 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 tests/CoreEx.Test.Unit/Caching/HybridCacheEntryOptionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs create mode 100644 tests/CoreEx.Test.Unit/Http/HttpRequestMessageExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Json/JsonExceptionConverterFactoryTests.cs create mode 100644 tests/CoreEx.Test.Unit/Validation/DecimalRuleHelperTests.cs diff --git a/src/CoreEx/Caching/HybridCacheEntryOptions.cs b/src/CoreEx/Caching/HybridCacheEntryOptions.cs index b2847809..f28aecfa 100644 --- a/src/CoreEx/Caching/HybridCacheEntryOptions.cs +++ b/src/CoreEx/Caching/HybridCacheEntryOptions.cs @@ -81,7 +81,7 @@ public static HybridCacheEntryOptions CreateForName(string name, TimeSpan? local /// A instance associated with the specified type. /// The is used as the name; see . public static HybridCacheEntryOptions CreateFor(TimeSpan? localExpiration = null, TimeSpan? distributedExpiration = null, CacheStrategy? strategy = null) - => CreateForName(nameof(T), localExpiration, distributedExpiration, strategy); + => CreateForName(typeof(T).Name, localExpiration, distributedExpiration, strategy); /// /// Gets or sets the . diff --git a/src/CoreEx/ExecutionContext.Infra.cs b/src/CoreEx/ExecutionContext.Infra.cs index dd40f9cb..792942e9 100644 --- a/src/CoreEx/ExecutionContext.Infra.cs +++ b/src/CoreEx/ExecutionContext.Infra.cs @@ -146,8 +146,8 @@ public static T GetRequiredKeyedService(object? serviceKey) where T : notnull public static object? GetKeyedService(Type type, object? serviceKey) { type.ThrowIfNull(); - if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is not null) - return executionContext.ServiceProvider.GetKeyedServices(type, serviceKey).FirstOrDefault(s => s?.GetType() == type); + if (TryGetCurrent(out var executionContext) && executionContext.ServiceProvider is IKeyedServiceProvider ksp) + return ksp.GetKeyedService(type, serviceKey); return null; } diff --git a/src/CoreEx/Extensions.HttpRequestMessage.cs b/src/CoreEx/Extensions.HttpRequestMessage.cs index 581cf725..d2aa1b16 100644 --- a/src/CoreEx/Extensions.HttpRequestMessage.cs +++ b/src/CoreEx/Extensions.HttpRequestMessage.cs @@ -169,7 +169,7 @@ private static StringBuilder AddQuery(this StringBuilder sb, string name, string if (sb.Length > 0) sb.Append('&'); - sb.Append($"{name}={value}"); + sb.Append(name).Append('=').Append(Uri.EscapeDataString(value)); return sb; } } diff --git a/src/CoreEx/Hosting/HostedServiceBase.cs b/src/CoreEx/Hosting/HostedServiceBase.cs index 8a412386..eeee2c6c 100644 --- a/src/CoreEx/Hosting/HostedServiceBase.cs +++ b/src/CoreEx/Hosting/HostedServiceBase.cs @@ -323,8 +323,6 @@ public async Task StopAsync(CancellationToken cancellationToken) { lock (SyncLock) { - if (Status.IsStop) - Status = ServiceStatus.Stopping; if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("{ServiceName} stop requested.", ServiceName); diff --git a/src/CoreEx/Json/JsonExceptionConverterFactory.cs b/src/CoreEx/Json/JsonExceptionConverterFactory.cs index be38b9da..ccbb63a6 100644 --- a/src/CoreEx/Json/JsonExceptionConverterFactory.cs +++ b/src/CoreEx/Json/JsonExceptionConverterFactory.cs @@ -32,7 +32,7 @@ public override void Write(Utf8JsonWriter writer, TException value, JsonSerializ Ignore = uu.GetCustomAttribute(), JsonName = uu.GetCustomAttribute()?.Name }) - .Where(uu => uu.Ignore is not null && uu.Name != nameof(Exception.TargetSite)); + .Where(uu => uu.Ignore is null && uu.Name != nameof(Exception.TargetSite)); if (options?.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull) serializableProperties = serializableProperties.Where(uu => uu.Value is not null); diff --git a/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs b/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs index f12209c6..9726a1b6 100644 --- a/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs +++ b/src/CoreEx/Json/JsonSubstituteNamingPolicy.cs @@ -28,5 +28,5 @@ public JsonSubstituteNamingPolicy() /// /// Converts using the then the . - public override string ConvertName(string name) => Substitutions.TryGetValue(name, out var substitution) ? substitution : CamelCase.ConvertName(name); + public override string ConvertName(string name) => Substitutions.TryGetValue(name, out var substitution) ? substitution : FallbackPolicy.ConvertName(name); } \ No newline at end of file diff --git a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs index f3d717b2..f66d7785 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs @@ -146,7 +146,8 @@ static bool EnumerateObjectAreEqual(IEnumerable l, IEnumerable r) return false; } - return true; + // Ensure the right-hand sequence does not have additional trailing elements. + return !er.MoveNext(); } return (left, right) switch diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs b/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs index 1504caaf..a47f3ab5 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.Internal.cs @@ -70,7 +70,8 @@ private static bool TypedEnumerateAreEqual(IEnumerable l, IEnumerable r return false; } - return true; + // Ensure the right-hand sequence does not have additional trailing elements. + return !er.MoveNext(); } } \ No newline at end of file diff --git a/src/CoreEx/Results/ResultsExtensions.When.cs b/src/CoreEx/Results/ResultsExtensions.When.cs index 12516cdd..9876cf64 100644 --- a/src/CoreEx/Results/ResultsExtensions.When.cs +++ b/src/CoreEx/Results/ResultsExtensions.When.cs @@ -129,7 +129,7 @@ public static Result When(this Result result, Predicate condition, F if (condition(result.Value)) return func(result.Value).Combine(result); else - return otherwise is null ? result : func(result.Value).Combine(result); + return otherwise is null ? result : otherwise(result.Value).Combine(result); } /// diff --git a/src/CoreEx/Validation/DecimalRuleHelper.cs b/src/CoreEx/Validation/DecimalRuleHelper.cs index 118ee3fa..008e4ada 100644 --- a/src/CoreEx/Validation/DecimalRuleHelper.cs +++ b/src/CoreEx/Validation/DecimalRuleHelper.cs @@ -83,8 +83,18 @@ public static int CalcIntegralPartLength(decimal value) if (absValue == 0m) return 0; - // Use Log10 for O(1) performance; cast to double is safe here as we only need the magnitude for digit counting. - return (int)Math.Floor(Math.Log10((double)absValue)) + 1; + // Use Log10 for O(1) performance as an estimate; the cast to double can lose precision for values with 16+ + // significant digits (decimal supports up to 29), which can round the magnitude up or down across a + // power-of-ten boundary (e.g. 99999999999999999m rounds to 1e17 as a double). Correct any such drift below. + var length = (int)Math.Floor(Math.Log10((double)absValue)) + 1; + + while (length > 0 && GetPowerOf10(length - 1) > absValue) + length--; + + while (length < 29 && GetPowerOf10(length) <= absValue) + length++; + + return length; } /// diff --git a/tests/CoreEx.Test.Unit/Caching/HybridCacheEntryOptionsTests.cs b/tests/CoreEx.Test.Unit/Caching/HybridCacheEntryOptionsTests.cs new file mode 100644 index 00000000..800c4e6c --- /dev/null +++ b/tests/CoreEx.Test.Unit/Caching/HybridCacheEntryOptionsTests.cs @@ -0,0 +1,59 @@ +using CoreEx.Caching; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreEx.Test.Unit.Caching; + +[TestFixture] +public class HybridCacheEntryOptionsTests +{ + private class Widget { } + + [TearDown] + public void TearDown() => ExecutionContext.Reset(); + + [Test] + public void CreateFor_UsesActualTypeName_NotLiteralGenericParameterName() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "CoreEx:Caching:Widget:LocalExpiration", "00:10:00" }, + { "CoreEx:Caching:T:LocalExpiration", "00:20:00" } // Decoy: must NOT be picked up. + }) + .Build(); + + var sc = new ServiceCollection(); + sc.AddSingleton(config); + using var sp = sc.BuildServiceProvider(); + + ExecutionContext.SetCurrent(new ExecutionContext { ServiceProvider = sp }); + + var options = HybridCacheEntryOptions.CreateFor(); + + options.LocalExpiration.Should().Be(TimeSpan.FromMinutes(10)); + } + + [Test] + public void CreateFor_DifferentTypes_ResolveDistinctConfiguration() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "CoreEx:Caching:Widget:LocalExpiration", "00:10:00" }, + { "CoreEx:Caching:Gadget:LocalExpiration", "00:15:00" } + }) + .Build(); + + var sc = new ServiceCollection(); + sc.AddSingleton(config); + using var sp = sc.BuildServiceProvider(); + + ExecutionContext.SetCurrent(new ExecutionContext { ServiceProvider = sp }); + + HybridCacheEntryOptions.CreateFor().LocalExpiration.Should().Be(TimeSpan.FromMinutes(10)); + HybridCacheEntryOptions.CreateFor().LocalExpiration.Should().Be(TimeSpan.FromMinutes(15)); + } + + private class Gadget { } +} diff --git a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs index 9bfe5a58..7c32e124 100644 --- a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs +++ b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs @@ -1,5 +1,6 @@ using CoreEx.Entities; using CoreEx.Localization; +using Microsoft.Extensions.DependencyInjection; using System.Globalization; namespace CoreEx.Test.Unit; @@ -143,6 +144,31 @@ public void OperationType_Read() ec.OperationType.IsRead.Should().BeTrue(); } + [Test] + public void GetKeyedService_NonGeneric_ResolvesInterfaceRegisteredImplementation() + { + // The registered implementation type (FooImpl) intentionally differs from the requested service type (IFoo); + // this is the common case (interface-based DI) that the buggy GetType()==type comparison failed to match. + var sc = new ServiceCollection(); + sc.AddKeyedSingleton("key1"); + using var sp = sc.BuildServiceProvider(); + + ExecutionContext.SetCurrent(new ExecutionContext { ServiceProvider = sp }); + + var result = ExecutionContext.GetKeyedService(typeof(IFoo), "key1"); + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Test] + public void GetKeyedService_NonGeneric_NoCurrent_ReturnsNull() + => ExecutionContext.GetKeyedService(typeof(IFoo), "key1").Should().BeNull(); + + private interface IFoo { } + + private class FooImpl : IFoo { } + private class TestServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; diff --git a/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs new file mode 100644 index 00000000..c5c70faf --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs @@ -0,0 +1,75 @@ +using CoreEx.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class HostedServiceBaseTests +{ + private static ServiceProvider CreateServiceProvider() + { + var sc = new ServiceCollection(); + sc.AddSingleton(new ConfigurationBuilder().Build()); + return sc.BuildServiceProvider(); + } + + [Test] + public async Task StopAsync_TransitionsThroughStoppingBeforeStopped() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + svc.Status.Should().Be(ServiceStatus.Running); + + // OnStopAsync captures the Status synchronously before awaiting the gate, so by the time StopAsync + // suspends (on the incomplete gate), StatusDuringStop already reflects the mid-stop state. + var stopTask = svc.StopAsync(CancellationToken.None); + svc.StatusDuringStop.Should().Be(ServiceStatus.Stopping); + + svc.StopGate.SetResult(); + await stopTask; + + svc.Status.Should().Be(ServiceStatus.Stopped); + } + + [Test] + public async Task StopAsync_FromAlreadyStoppedStatus_StillCompletes() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + svc.StopGate.SetResult(); + await svc.StopAsync(CancellationToken.None); + + // A second stop, from an already-Stopped status, must still transition through Stopping and complete. + svc.StopGate = new TaskCompletionSource(); + svc.StopGate.SetResult(); + await svc.StopAsync(CancellationToken.None); + + svc.StatusDuringStop.Should().Be(ServiceStatus.Stopping); + svc.Status.Should().Be(ServiceStatus.Stopped); + } + + private class TestHostedService(IServiceProvider serviceProvider, ILogger logger) : HostedServiceBase(serviceProvider, logger) + { + public TaskCompletionSource StopGate { get; set; } = new(); + + public ServiceStatus StatusDuringStop { get; private set; } + + protected override Task OnStartAsync(CancellationToken cancellationToken) => Task.FromResult(ServiceStatus.Running); + + protected override async Task OnStopAsync(CancellationToken cancellationToken) + { + StatusDuringStop = Status; + await StopGate.Task; + } + + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) => HealthCheckResult.Healthy(); + } +} diff --git a/tests/CoreEx.Test.Unit/Http/HttpRequestMessageExtensionsTests.cs b/tests/CoreEx.Test.Unit/Http/HttpRequestMessageExtensionsTests.cs new file mode 100644 index 00000000..0695762b --- /dev/null +++ b/tests/CoreEx.Test.Unit/Http/HttpRequestMessageExtensionsTests.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Test.Unit.Http; + +[TestFixture] +public class HttpRequestMessageExtensionsTests +{ + [Test] + public void WithQuery_EncodesAmpersandInValue_DoesNotInjectExtraParameter() + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + request.WithQuery(filter: "a=1&b=2"); + + var query = request.RequestUri!.Query.TrimStart('?'); + var pairs = query.Split('&'); + + // Without encoding, the raw '&' in the value would be misinterpreted as a second query parameter. + pairs.Should().HaveCount(1); + + var parts = pairs[0].Split('=', 2); + parts[0].Should().Be("$filter"); + Uri.UnescapeDataString(parts[1]).Should().Be("a=1&b=2"); + } + + [Test] + public void WithQuery_EncodesSpacesAndSpecialCharacters_RoundTrips() + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + request.WithQuery(filter: "name eq 'John & Jane'", orderBy: "name desc"); + + var query = request.RequestUri!.Query.TrimStart('?'); + var pairs = query.Split('&').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1])); + + pairs.Should().HaveCount(2); + pairs["$filter"].Should().Be("name eq 'John & Jane'"); + pairs["$orderby"].Should().Be("name desc"); + } + + [Test] + public void WithIdempotencyKey_AddsHeader() + { + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.com/api"); + request.WithIdempotencyKey("abc-123"); + + request.Headers.GetValues(CoreEx.Http.HttpNames.IdempotencyKeyHeaderName).Should().ContainSingle().Which.Should().Be("abc-123"); + } +} diff --git a/tests/CoreEx.Test.Unit/Json/JsonExceptionConverterFactoryTests.cs b/tests/CoreEx.Test.Unit/Json/JsonExceptionConverterFactoryTests.cs new file mode 100644 index 00000000..7e8e14fb --- /dev/null +++ b/tests/CoreEx.Test.Unit/Json/JsonExceptionConverterFactoryTests.cs @@ -0,0 +1,60 @@ +using CoreEx.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CoreEx.Test.Unit.Json; + +[TestFixture] +public class JsonExceptionConverterFactoryTests +{ + private static JsonSerializerOptions CreateOptions() => new() { Converters = { new JsonExceptionConverterFactory() } }; + + [Test] + public void Write_IncludesNonIgnoredProperties() + { + var ex = new TestException("boom") { Extra = "info" }; + var json = JsonSerializer.Serialize(ex, CreateOptions()); + + json.Should().Contain("\"Message\":\"boom\""); + json.Should().Contain("\"Extra\":\"info\""); + } + + [Test] + public void Write_ExcludesJsonIgnoreDecoratedProperties() + { + var ex = new TestException("boom") { Hidden = "secret" }; + var json = JsonSerializer.Serialize(ex, CreateOptions()); + + json.Should().NotContain("Hidden"); + json.Should().NotContain("secret"); + } + + [Test] + public void Write_ExcludesTargetSite() + { + // TargetSite is only populated once thrown; explicitly excluded regardless. Note: the exception message and + // resulting stack trace deliberately avoid the substring "TargetSite" so it can't leak in via unrelated + // property values (e.g. StackTrace) and produce a false positive. + Exception ex; + try + { + throw new TestException("boom"); + } + catch (TestException caught) + { + ex = caught; + } + + var json = JsonSerializer.Serialize(ex, CreateOptions()); + using var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty(nameof(Exception.TargetSite), out _).Should().BeFalse(); + } + + private class TestException(string message) : Exception(message) + { + public string? Extra { get; set; } + + [JsonIgnore] + public string? Hidden { get; set; } + } +} diff --git a/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs b/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs index 69a68fed..83a449d1 100644 --- a/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs +++ b/tests/CoreEx.Test.Unit/Json/JsonSubstituteNamingPolicyTests.cs @@ -16,6 +16,20 @@ public void Default_Test() json.Should().Be("""{"id":"abc","years":55,"etag":"xyz"}"""); } + [Test] + public void ConvertName_NoSubstitution_UsesFallbackPolicy_NotHardcodedCamelCase() + { + var policy = new JsonSubstituteNamingPolicy { FallbackPolicy = JsonNamingPolicy.SnakeCaseLower }; + policy.ConvertName("FirstName").Should().Be("first_name"); + } + + [Test] + public void ConvertName_Substitution_TakesPrecedenceOverFallbackPolicy() + { + var policy = new JsonSubstituteNamingPolicy { FallbackPolicy = JsonNamingPolicy.SnakeCaseLower }; + policy.ConvertName("ETag").Should().Be("etag"); + } + private class Person : IIdentifier, IReadOnlyETag { public string? Id { get; set; } diff --git a/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs b/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs index 5107f9a2..e8f54882 100644 --- a/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs +++ b/tests/CoreEx.Test.Unit/Results/ExtensionsWhenTests.cs @@ -144,6 +144,56 @@ public void ResultT_When_FuncResultT_Failure() ret.Should().Be(result); } + [Test] + public void ResultT_When_FuncResult_ConditionTrue_Success() + { + var result = new Result(5); + var ret = result.When(i => i == 5, i => Result.Success); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(5); // Value is preserved (not lost) on success. + } + + [Test] + public void ResultT_When_FuncResult_ConditionFalse_InvokesOtherwise_NotFunc() + { + var result = new Result(5); + var funcCalled = false; + var otherwiseCalled = false; + + var ret = result.When(i => i == 0, i => { funcCalled = true; return Result.Success; }, i => { otherwiseCalled = true; return Result.Success; }); + + funcCalled.Should().BeFalse(); + otherwiseCalled.Should().BeTrue(); + ret.IsSuccess.Should().BeTrue(); + ret.Value.Should().Be(5); + } + + [Test] + public void ResultT_When_FuncResult_ConditionFalse_NoOtherwise_ReturnsOriginal() + { + var result = new Result(5); + var ret = result.When(i => i == 0, i => Result.Success); + ret.Should().Be(result); + } + + [Test] + public void ResultT_When_FuncResult_ConditionFalse_OtherwiseFailure_PropagatesError() + { + var result = new Result(5); + var ex = new Exception("otherwise-fail"); + var ret = result.When(i => i == 0, i => Result.Success, i => new Result(ex)); + ret.IsFailure.Should().BeTrue(); + ret.Error.Should().Be(ex); + } + + [Test] + public void ResultT_When_FuncResult_Failure() + { + var result = new Result(new Exception("fail")); + var ret = result.When(i => true, i => Result.Success, i => Result.Success); + ret.Should().Be(result); + } + [Test] public void ResultT_WhenAs_FuncT_ConditionTrue_Success() { diff --git a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs index 64b3f9ae..b1135b01 100644 --- a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -131,6 +131,40 @@ public void AreEqual_IEnumerable_ReferenceType() RuntimeMetadata.AreEqual(arr1, arr4).Should().BeFalse(); } + // Local iterator methods (not backed by ICollection) so the AreEqual ICollection.Count short-circuit is bypassed + // and TypedEnumerateAreEqual/EnumerateObjectAreEqual are exercised directly. + private static IEnumerable LazyInts(params int[] values) + { + foreach (var v in values) + yield return v; + } + + private static IEnumerable LazyEntities(params EntityA[] values) + { + foreach (var v in values) + yield return v; + } + + [Test] + public void AreEqual_IEnumerable_NonCollection_RightLonger_ValueType_IsNotEqual() + { + // Left is a prefix of right; without a right-hand-exhaustion check this incorrectly returns true. + RuntimeMetadata.AreEqual(LazyInts(1, 2), LazyInts(1, 2, 3)).Should().BeFalse(); + RuntimeMetadata.AreEqual(LazyInts(1, 2, 3), LazyInts(1, 2)).Should().BeFalse(); + RuntimeMetadata.AreEqual(LazyInts(1, 2, 3), LazyInts(1, 2, 3)).Should().BeTrue(); + } + + [Test] + public void AreEqual_IEnumerable_NonCollection_RightLonger_ReferenceType_IsNotEqual() + { + var bob = new EntityA { Name = "Bob" }; + var jen = new EntityA { Name = "Jen" }; + + RuntimeMetadata.AreEqual(LazyEntities(bob), LazyEntities(bob, jen)).Should().BeFalse(); + RuntimeMetadata.AreEqual(LazyEntities(bob, jen), LazyEntities(bob)).Should().BeFalse(); + RuntimeMetadata.AreEqual(LazyEntities(bob, jen), LazyEntities(new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" })).Should().BeTrue(); + } + [Test] public void AreEqual_Dictionary() { diff --git a/tests/CoreEx.Test.Unit/Validation/DecimalRuleHelperTests.cs b/tests/CoreEx.Test.Unit/Validation/DecimalRuleHelperTests.cs new file mode 100644 index 00000000..bcd0c4a1 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Validation/DecimalRuleHelperTests.cs @@ -0,0 +1,50 @@ +using CoreEx.Validation; + +namespace CoreEx.Test.Unit.Validation; + +[TestFixture] +public class DecimalRuleHelperTests +{ + [Test] + public void CalcIntegralPartLength_Zero_ReturnsZero() + => DecimalRuleHelper.CalcIntegralPartLength(0m).Should().Be(0); + + [TestCase(1, 1)] + [TestCase(9, 1)] + [TestCase(10, 2)] + [TestCase(99, 2)] + [TestCase(100, 3)] + public void CalcIntegralPartLength_SmallValues(int value, int expectedLength) + => DecimalRuleHelper.CalcIntegralPartLength(value).Should().Be(expectedLength); + + [Test] + public void CalcIntegralPartLength_PowerOfTenBoundary_ValueJustBelow_IsNotOverCounted() + { + // 17 nines: casting to double rounds this up to exactly 1e17, which previously caused an off-by-one + // overcount (18 instead of the correct 17) via the Log10-based estimate. + DecimalRuleHelper.CalcIntegralPartLength(99999999999999999m).Should().Be(17); + } + + [Test] + public void CalcIntegralPartLength_ExactPowerOfTen_IsCorrect() + { + DecimalRuleHelper.CalcIntegralPartLength(10000000000000000m).Should().Be(17); // 1e16 + DecimalRuleHelper.CalcIntegralPartLength(100000000000000000m).Should().Be(18); // 1e17 + } + + [Test] + public void CalcIntegralPartLength_LargeValue_AboveDoublePrecision_IsCorrect() + { + DecimalRuleHelper.CalcIntegralPartLength(1000000000000000000m).Should().Be(19); // 1e18 + DecimalRuleHelper.CalcIntegralPartLength(9999999999999999999999999999m).Should().Be(28); + } + + [Test] + public void CheckPrecisionAndScale_BoundaryValue_PreviouslyMiscountedAsOverPrecision_IsValid() + { + // With the Log10 overcount bug, 99999999999999999m (17 digits) was measured as 18 digits and would + // incorrectly fail precision-17 validation. + DecimalRuleHelper.CheckPrecisionAndScale(99999999999999999m, precision: 17, scale: 0).Should().BeTrue(); + DecimalRuleHelper.CheckPrecisionAndScale(999999999999999999m, precision: 17, scale: 0).Should().BeFalse(); // 18 digits, exceeds precision 17 + } +} From 3246588fc4f4fce9e51ea28add2533127f822886 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 5 Aug 2026 12:21:16 -0700 Subject: [PATCH 2/5] Improve error handling, DI, and service lifecycle - Add robust type loading in `AddDynamicServicesUsing` with `GetLoadableTypes` to tolerate `ReflectionTypeLoadException`. - Throw `FormatException` in `EncodedStringToUInt32Converter` if decoded Base64 is not 4 bytes. - Fix infinite recursion in `IReferenceDataCollection.GetById(object?)` by correct casting. - Copy `OperationType` and `IncludeRelatedText` in `ExecutionContext.CreateCopy()`. - Await `OnStopAsync` in `HostedServiceBase` and add pause/resume support with new lifecycle tests. - Make `Provider` and `JsonSerializerOptions` read-only in `WorkOrchestrator`. - Prevent `InvalidCastException` in `RuntimeMetadata.AreEqual` for mismatched collections. - Ensure `OperationCanceledException` is not swallowed in `HttpResponseMessageExtensions.ToProblemDetailsAsync`. - Add/extend unit tests for DI, hosted services, HTTP extensions, invoker behaviors, converters, reference data, runtime metadata, and execution context. - Minor code style and test coverage improvements. --- .../CoreExExtensions.DependencyInjection.cs | 17 +- src/CoreEx/ExecutionContext.cs | 2 + src/CoreEx/Extensions.HttpResponseMessage.cs | 1 + src/CoreEx/Hosting/HostedServiceBase.cs | 2 +- src/CoreEx/Hosting/Work/WorkOrchestrator.cs | 4 +- .../EncodedStringToUInt32Converter.cs | 11 +- src/CoreEx/Mapping/IntoMapperT3.cs | 1 - .../Metadata/RuntimeMetadata.AreEqual.cs | 2 +- .../RefData/IReferenceDataCollectionT.cs | 2 +- .../ReferenceDataOrchestratorTests.cs | 101 ++++++++++ ...oreExExtensionsDependencyInjectionTests.cs | 37 ++++ .../CoreEx.Test.Unit/ExecutionContextTests.cs | 6 +- .../Hosting/HostedServiceBaseTests.cs | 173 +++++++++++++++++- .../Hosting/TimerHostedServiceBaseTests.cs | 158 ++++++++++++++++ .../HttpResponseMessageExtensionsTests.cs | 53 ++++++ .../Invokers/InvokerBaseTests.cs | 141 ++++++++++++++ .../Invokers/InvokerNameAttributeTests.cs | 39 ++++ .../CoreEx.Test.Unit/Invokers/InvokerTests.cs | 74 ++++++++ .../EncodedStringToUInt32ConverterTests.cs | 20 ++ .../Runtime/RuntimeMetadataTests.cs | 13 ++ 20 files changed, 844 insertions(+), 13 deletions(-) create mode 100644 tests/CoreEx.Test.Unit/DependencyInjection/CoreExExtensionsDependencyInjectionTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/TimerHostedServiceBaseTests.cs create mode 100644 tests/CoreEx.Test.Unit/Http/HttpResponseMessageExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Invokers/InvokerBaseTests.cs create mode 100644 tests/CoreEx.Test.Unit/Invokers/InvokerNameAttributeTests.cs create mode 100644 tests/CoreEx.Test.Unit/Invokers/InvokerTests.cs diff --git a/src/CoreEx/CoreExExtensions.DependencyInjection.cs b/src/CoreEx/CoreExExtensions.DependencyInjection.cs index 37d3a9ba..42d6ecd0 100644 --- a/src/CoreEx/CoreExExtensions.DependencyInjection.cs +++ b/src/CoreEx/CoreExExtensions.DependencyInjection.cs @@ -110,7 +110,7 @@ public static IServiceCollection AddDynamicServicesUsing(this IServiceCollection { foreach (var assembly in assemblies.Distinct()) { - foreach (var match in from type in assembly.GetTypes() + foreach (var match in from type in GetLoadableTypes(assembly) where !type.IsAbstract && !type.IsGenericTypeDefinition let sla = ServiceLifetimeAttribute.GetCustomAttribute(type) where sla is not null @@ -123,6 +123,21 @@ where sla is not null return services; } + /// + /// Gets the types from the specified , tolerating types that fail to load (e.g. due to missing dependencies). + /// + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } + /// /// Adds a singleton service for the internal . /// diff --git a/src/CoreEx/ExecutionContext.cs b/src/CoreEx/ExecutionContext.cs index e5f144e2..e0f6e023 100644 --- a/src/CoreEx/ExecutionContext.cs +++ b/src/CoreEx/ExecutionContext.cs @@ -103,6 +103,8 @@ public virtual ExecutionContext CreateCopy() ec.User = User; ec.TenantId = TenantId; ec.UICulture = UICulture; + ec.OperationType = OperationType; + ec.IncludeRelatedText = IncludeRelatedText; ec._isCopied = true; if (_attributes.IsValueCreated) diff --git a/src/CoreEx/Extensions.HttpResponseMessage.cs b/src/CoreEx/Extensions.HttpResponseMessage.cs index a8b0e273..33a86a54 100644 --- a/src/CoreEx/Extensions.HttpResponseMessage.cs +++ b/src/CoreEx/Extensions.HttpResponseMessage.cs @@ -23,6 +23,7 @@ public static partial class Extensions if (pd is not null) return new ProblemDetailsException(pd, new HttpRequestException($"{CreateMessage(response)} Problem details:{content}")); } + catch (OperationCanceledException) { throw; } // Let cancellation propagate; do not treat as "not a problem details". catch { } // Swallow and assume not a problem details. return null; diff --git a/src/CoreEx/Hosting/HostedServiceBase.cs b/src/CoreEx/Hosting/HostedServiceBase.cs index eeee2c6c..e2bb68f6 100644 --- a/src/CoreEx/Hosting/HostedServiceBase.cs +++ b/src/CoreEx/Hosting/HostedServiceBase.cs @@ -328,7 +328,7 @@ public async Task StopAsync(CancellationToken cancellationToken) Logger.LogInformation("{ServiceName} stop requested.", ServiceName); } - await OnStopAsync(cancellationToken); + await OnStopAsync(cancellationToken).ConfigureAwait(false); lock (SyncLock) { diff --git a/src/CoreEx/Hosting/Work/WorkOrchestrator.cs b/src/CoreEx/Hosting/Work/WorkOrchestrator.cs index fc8a1377..c7983067 100644 --- a/src/CoreEx/Hosting/Work/WorkOrchestrator.cs +++ b/src/CoreEx/Hosting/Work/WorkOrchestrator.cs @@ -19,13 +19,13 @@ public class WorkOrchestrator(IWorkProvider provider, JsonSerializerOptions? jso /// /// Gets the . /// - public IWorkProvider Provider = provider.ThrowIfNull(nameof(provider)); + public IWorkProvider Provider { get; } = provider.ThrowIfNull(nameof(provider)); /// /// Gets the . /// /// Defaults to . - public JsonSerializerOptions JsonSerializerOptions = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; + public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? JsonDefaults.SerializerOptions; /// /// Gets or sets the work expiry . diff --git a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs b/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs index 0460b95b..51b3df5d 100644 --- a/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs +++ b/src/CoreEx/Mapping/Converters/EncodedStringToUInt32Converter.cs @@ -5,7 +5,16 @@ namespace CoreEx.Mapping.Converters; /// public readonly struct EncodedStringToUInt32Converter : IConverter { - private static readonly ValueConverter _convertToDestination = new(s => s == null ? 0 : BitConverter.ToUInt32(Convert.FromBase64String(s))); + private static readonly ValueConverter _convertToDestination = new(s => + { + if (s == null) + return 0; + + var bytes = Convert.FromBase64String(s); + return bytes.Length == 4 + ? BitConverter.ToUInt32(bytes) + : throw new FormatException($"The decoded value must be exactly 4 bytes to convert to a {nameof(UInt32)}; the specified value decoded to {bytes.Length} byte(s)."); + }); private static readonly ValueConverter _convertToSource = new(d => d == 0 ? null : Convert.ToBase64String(BitConverter.GetBytes(d))); /// diff --git a/src/CoreEx/Mapping/IntoMapperT3.cs b/src/CoreEx/Mapping/IntoMapperT3.cs index 2e15faa7..5185597b 100644 --- a/src/CoreEx/Mapping/IntoMapperT3.cs +++ b/src/CoreEx/Mapping/IntoMapperT3.cs @@ -20,7 +20,6 @@ namespace CoreEx.Mapping; /// /// The source value. /// The destination value. - [return: NotNullIfNotNull(nameof(source))] public static new void MapInto(TSource source, TDestination destination) { Default.OnMapInto(source.ThrowIfNull(), destination.ThrowIfNull()); diff --git a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs index f66d7785..c9069814 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs @@ -67,7 +67,7 @@ public static bool AreEqual(T? left, T? right) // Short circuit arrays, collections, lists, and dictionaries based on count difference. if (left is ICollection lc) { - if (lc.Count != ((ICollection)right).Count) + if (right is not ICollection rc || lc.Count != rc.Count) return false; } diff --git a/src/CoreEx/RefData/IReferenceDataCollectionT.cs b/src/CoreEx/RefData/IReferenceDataCollectionT.cs index 3d99b1a0..8cea0cca 100644 --- a/src/CoreEx/RefData/IReferenceDataCollectionT.cs +++ b/src/CoreEx/RefData/IReferenceDataCollectionT.cs @@ -45,7 +45,7 @@ bool IReferenceDataCollection.TryGetByCode(string code, [NotNullWhen(true)] out } /// - IReferenceData? IReferenceDataCollection.GetById(object? id) => GetById(id); + IReferenceData? IReferenceDataCollection.GetById(object? id) => GetById((TId)(id ?? default(TId)!)); /// IReferenceData? IReferenceDataCollection.GetByCode(string code) => GetByCode(code); diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs index c43f5934..e00f54b8 100644 --- a/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorTests.cs @@ -155,6 +155,22 @@ public void GetByType_And_GetByTypeRequired() req.Should().NotBeNull(); } + [Test] + public void NonGenericCollection_GetById_ReturnsItem_DoesNotStackOverflow() + { + // Regression guard: IReferenceDataCollection's explicit IReferenceDataCollection.GetById(object?) + // implementation previously called GetById(id) unconverted, which bound back to itself (object? being an + // exact match vs. the typed TRef? GetById(TId) requiring a conversion), recursing infinitely and crashing + // the process with an uncatchable StackOverflowException. Must go through the non-generic interface here. + var orch = CreateOrchestrator(); + IReferenceDataCollection coll = orch.GetByType()!; + + var item = coll.GetById(1); + + item.Should().NotBeNull(); + item!.Code.Should().Be("A"); + } + [Test] public void GetByName_And_GetByNameRequired() { @@ -501,6 +517,91 @@ public async Task GetNamedAsync_UnknownName_SilentlyIgnored() mc.Should().BeEmpty(); } + // ── PrefetchAsync ────────────────────────────────────────────────────── + + [Test] + public async Task PrefetchAsync_ReturnsKnownNames_AndPopulatesCache() + { + var orch = CreateOrchestrator(); + var names = await orch.PrefetchAsync([nameof(DummyRefData), "Unknown", nameof(DummyRefData2)]); + + names.Should().BeEquivalentTo([nameof(DummyRefData), nameof(DummyRefData2)]); + + // Now retrievable synchronously without triggering a fresh (uncached) load. + orch.GetByType().Should().NotBeNull(); + orch.GetByType().Should().NotBeNull(); + } + + [Test] + public async Task PrefetchAsync_DuplicateNames_CaseInsensitive_ReturnsDistinct() + { + var orch = CreateOrchestrator(); + var names = await orch.PrefetchAsync([nameof(DummyRefData), nameof(DummyRefData).ToLowerInvariant()]); + + names.Should().ContainSingle().Which.Should().Be(nameof(DummyRefData)); + } + + [Test] + public async Task PrefetchAsync_AllUnknownNames_ReturnsEmpty() + { + var orch = CreateOrchestrator(); + var names = await orch.PrefetchAsync(["not-a-real-name", "also-not-real"]); + + names.Should().BeEmpty(); + } + + // ── ConvertFromMapping ───────────────────────────────────────────────── + + private class MappingProvider(IReferenceDataCollection collection) : IReferenceDataProvider + { + public IEnumerable<(Type, Type)> Types => [(typeof(DummyRefData), typeof(DummyRefDataCollection))]; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) => Task.FromResult(collection); + } + + private static ReferenceDataOrchestrator CreateOrchestratorWithMapping(out DummyRefData item) + { + // The mapping must be set BEFORE the item is added to the collection - ReferenceDataCollectionCore indexes + // mappings at Add() time, so mutating an already-added item's mappings would not update the index. + item = new DummyRefData { Id = 99, Code = "M" }; + item.SetMapping("ext", "M-ext"); + var collection = new DummyRefDataCollection { item }; + + var sc = new ServiceCollection(); + sc.AddExecutionContext(sp => new ExecutionContext { ServiceProvider = sp }); + sc.AddSingleton(new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache())); + sc.AddSingleton(new MappingProvider(collection)); + var sp2 = sc.BuildServiceProvider(); + + var ro = new ReferenceDataOrchestrator(sp2, Mock.Of>()); + ro.Register(); + return ro; + } + + [Test] + public void ConvertFromMapping_FindsItemByMapping() + { + var ro = CreateOrchestratorWithMapping(out var item); + ReferenceDataOrchestrator.SetCurrent(ro); + + var found = ReferenceDataOrchestrator.ConvertFromMapping("ext", "M-ext"); + + found.Should().NotBeNull(); + found.Id.Should().Be(item.Id); + ((IReferenceData)found).IsValid.Should().BeTrue(); + } + + [Test] + public void ConvertFromMapping_NotFound_ReturnsInvalidItem() + { + var ro = CreateOrchestratorWithMapping(out _); + ReferenceDataOrchestrator.SetCurrent(ro); + + var found = ReferenceDataOrchestrator.ConvertFromMapping("ext", "does-not-exist"); + + ((IReferenceData)found).IsValid.Should().BeFalse(); + } + // Entity source generation tests. [Test] diff --git a/tests/CoreEx.Test.Unit/DependencyInjection/CoreExExtensionsDependencyInjectionTests.cs b/tests/CoreEx.Test.Unit/DependencyInjection/CoreExExtensionsDependencyInjectionTests.cs new file mode 100644 index 00000000..d4a32618 --- /dev/null +++ b/tests/CoreEx.Test.Unit/DependencyInjection/CoreExExtensionsDependencyInjectionTests.cs @@ -0,0 +1,37 @@ +using CoreEx.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreEx.Test.Unit.DependencyInjection; + +[TestFixture] +public class CoreExExtensionsDependencyInjectionTests +{ + [ScopedService] + private class ScopedImpl { } + + [SingletonService] + private class SingletonImpl { } + + [TransientService] + private class TransientImpl { } + + [Test] + public void AddDynamicServicesUsing_RegistersDecoratedTypesWithCorrectLifetimes() + { + var services = new ServiceCollection(); + services.AddDynamicServicesUsing(typeof(CoreExExtensionsDependencyInjectionTests).Assembly); + + services.Should().Contain(sd => sd.ServiceType == typeof(ScopedImpl) && sd.Lifetime == ServiceLifetime.Scoped); + services.Should().Contain(sd => sd.ServiceType == typeof(SingletonImpl) && sd.Lifetime == ServiceLifetime.Singleton); + services.Should().Contain(sd => sd.ServiceType == typeof(TransientImpl) && sd.Lifetime == ServiceLifetime.Transient); + } + + [Test] + public void AddDynamicServicesUsing_GenericAssemblyOverload_MatchesExplicitAssembly() + { + var services = new ServiceCollection(); + services.AddDynamicServicesUsing(); + + services.Should().Contain(sd => sd.ServiceType == typeof(ScopedImpl) && sd.Lifetime == ServiceLifetime.Scoped); + } +} diff --git a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs index 7c32e124..263b67eb 100644 --- a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs +++ b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs @@ -113,7 +113,9 @@ public void CreateCopy_CopiesPropertiesAndSharesMessagesAndAttributes() { User = new Security.AuthenticationUser { Type = Security.AuthenticationType.AccountUser, UserName = "user" }, TenantId = "tenant", - UICulture = new CultureInfo("en-US") + UICulture = new CultureInfo("en-US"), + OperationType = OperationType.Update, + IncludeRelatedText = true }; ec.AddInfoMessage(new LText("msg")); ec.Attributes["k"] = "v"; @@ -123,6 +125,8 @@ public void CreateCopy_CopiesPropertiesAndSharesMessagesAndAttributes() copy.User.Type.Should().Be(Security.AuthenticationType.AccountUser); copy.TenantId.Should().Be("tenant"); copy.UICulture.Should().Be(new CultureInfo("en-US")); + copy.OperationType.Should().Be(OperationType.Update); + copy.IncludeRelatedText.Should().BeTrue(); copy.Messages.Should().BeSameAs(ec.Messages); copy.Attributes.Should().NotBeNull(); copy.Attributes["k"].Should().Be("v"); diff --git a/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs index c5c70faf..c833fa7e 100644 --- a/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs +++ b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs @@ -4,19 +4,57 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; namespace CoreEx.Test.Unit.Hosting; [TestFixture] public class HostedServiceBaseTests { - private static ServiceProvider CreateServiceProvider() + private static ServiceProvider CreateServiceProvider(IConfiguration? configuration = null) { var sc = new ServiceCollection(); - sc.AddSingleton(new ConfigurationBuilder().Build()); + sc.AddSingleton(configuration ?? new ConfigurationBuilder().Build()); return sc.BuildServiceProvider(); } + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + [Test] + public async Task StartAsync_TransitionsToRunning() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + + svc.Status.Should().Be(ServiceStatus.Running); + svc.OnStartAsyncCalled.Should().BeTrue(); + } + + [Test] + public async Task StartAsync_NoOpConfigured_SetsNoOpStatus_SkipsNormalStart() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { HostedServiceBase.NoOpArgument, "true" } }).Build(); + using var sp = CreateServiceProvider(config); + var svc = new TestHostedService(sp, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + + svc.Status.Should().Be(ServiceStatus.NoOp); + svc.OnStartAsyncCalled.Should().BeFalse(); + } + [Test] public async Task StopAsync_TransitionsThroughStoppingBeforeStopped() { @@ -56,13 +94,132 @@ public async Task StopAsync_FromAlreadyStoppedStatus_StillCompletes() svc.Status.Should().Be(ServiceStatus.Stopped); } + [Test] + public async Task PauseAsync_NotSupported_Throws() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); // ArePauseAndResumeSupported defaults to false. + await svc.StartAsync(CancellationToken.None); + + Func act = () => svc.PauseAsync(CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task PauseAsync_ThenResumeAsync_TransitionsCorrectly() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await svc.StartAsync(CancellationToken.None); + svc.Status.Should().Be(ServiceStatus.Running); + + await svc.PauseAsync(CancellationToken.None); + svc.Status.Should().Be(ServiceStatus.Paused); + + await svc.ResumeAsync(CancellationToken.None); + svc.Status.Should().Be(ServiceStatus.Running); + } + + [Test] + public async Task PauseAsync_WhenNotPausable_IsNoOp() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await svc.StartAsync(CancellationToken.None); + svc.StopGate.SetResult(); + await svc.StopAsync(CancellationToken.None); // Status is now Stopped, which cannot be paused. + + await svc.PauseAsync(CancellationToken.None); + svc.Status.Should().Be(ServiceStatus.Stopped); + } + + [Test] + public async Task Pause_FireAndForget_EventuallyPauses() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await svc.StartAsync(CancellationToken.None); + + svc.Pause(); + + await WaitUntilAsync(() => svc.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(2)); + svc.Status.Should().Be(ServiceStatus.Paused); + } + + [Test] + public async Task Resume_FireAndForget_EventuallyResumes() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await svc.StartAsync(CancellationToken.None); + await svc.PauseAsync(CancellationToken.None); + + svc.Resume(); + + await WaitUntilAsync(() => svc.Status == ServiceStatus.Running, TimeSpan.FromSeconds(2)); + svc.Status.Should().Be(ServiceStatus.Running); + } + + [Test] + public async Task ServiceName_CannotBeChanged_AfterInitializing() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + await svc.StartAsync(CancellationToken.None); + + Action act = () => svc.ServiceName = "NewName"; + act.Should().Throw(); + } + + [Test] + public async Task HealthCheck_ReportsStatusOnEachChange() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + var hc = new HostedServiceHealthCheck(); + svc.HealthCheck = hc; // Only settable while Initializing. + + await svc.StartAsync(CancellationToken.None); + + svc.LastReportedStatus.Should().Be(ServiceStatus.Running); + hc.Result.Status.Should().Be(HealthStatus.Healthy); + } + + [Test] + public async Task Dispose_SetsStatusToStopped_AndIsIdempotent() + { + using var sp = CreateServiceProvider(); + var svc = new TestHostedService(sp, NullLogger.Instance); + await svc.StartAsync(CancellationToken.None); + + svc.Dispose(); + svc.Status.Should().Be(ServiceStatus.Stopped); + + Action act = () => svc.Dispose(); + act.Should().NotThrow(); + } + private class TestHostedService(IServiceProvider serviceProvider, ILogger logger) : HostedServiceBase(serviceProvider, logger) { public TaskCompletionSource StopGate { get; set; } = new(); public ServiceStatus StatusDuringStop { get; private set; } - protected override Task OnStartAsync(CancellationToken cancellationToken) => Task.FromResult(ServiceStatus.Running); + public bool OnStartAsyncCalled { get; private set; } + + public ServiceStatus? LastReportedStatus { get; private set; } + + public bool SupportsPauseAndResume + { + get => ArePauseAndResumeSupported; + set => ArePauseAndResumeSupported = value; + } + + protected override Task OnStartAsync(CancellationToken cancellationToken) + { + OnStartAsyncCalled = true; + return Task.FromResult(ServiceStatus.Running); + } protected override async Task OnStopAsync(CancellationToken cancellationToken) { @@ -70,6 +227,14 @@ protected override async Task OnStopAsync(CancellationToken cancellationToken) await StopGate.Task; } - protected override HealthCheckResult OnReportHealthStatus(Dictionary data) => HealthCheckResult.Healthy(); + protected override Task OnPauseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override Task OnResumeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) + { + LastReportedStatus = Enum.Parse(data["status"].ToString()!); + return HealthCheckResult.Healthy(); + } } } diff --git a/tests/CoreEx.Test.Unit/Hosting/TimerHostedServiceBaseTests.cs b/tests/CoreEx.Test.Unit/Hosting/TimerHostedServiceBaseTests.cs new file mode 100644 index 00000000..6dd8513c --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/TimerHostedServiceBaseTests.cs @@ -0,0 +1,158 @@ +using CoreEx.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class TimerHostedServiceBaseTests +{ + private static ServiceProvider CreateServiceProvider(IConfiguration? configuration = null) + { + var sc = new ServiceCollection(); + sc.AddSingleton(configuration ?? new ConfigurationBuilder().Build()); + sc.AddScoped(); + return sc.BuildServiceProvider(); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + [Test] + public void ArePauseAndResumeSupported_DefaultsToTrue() + { + using var sp = CreateServiceProvider(); + var svc = new TestTimerService(sp, NullLogger.Instance); + svc.ArePauseAndResumeSupported.Should().BeTrue(); + } + + [Test] + public async Task StartAsync_ReadsIntervalSettingsFromConfiguration() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + { "CoreEx:Host:Services:TestTimerService:Interval", "00:00:05" }, + { "CoreEx:Host:Services:TestTimerService:FirstInterval", "00:00:01" }, + { "CoreEx:Host:Services:TestTimerService:OnUnhandledInterval", "00:00:02" }, + { "CoreEx:Host:Services:TestTimerService:MaxConsecutiveExecutions", "42" }, + { "CoreEx:Host:Services:TestTimerService:PauseOnUnhandledException", "false" } + }).Build(); + + using var sp = CreateServiceProvider(config); + var svc = new TestTimerService(sp, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + try + { + svc.Interval.Should().Be(TimeSpan.FromSeconds(5)); + svc.FirstInterval.Should().Be(TimeSpan.FromSeconds(1)); + svc.OnUnhandledInterval.Should().Be(TimeSpan.FromSeconds(2)); + svc.MaxConsecutiveExecutions.Should().Be(42); + svc.PauseOnUnhandledException.Should().BeFalse(); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task OnExecuteAsync_IsInvokedByBackgroundLoop() + { + using var sp = CreateServiceProvider(); + var svc = new TestTimerService(sp, NullLogger.Instance) { Interval = TimeSpan.FromMilliseconds(20), FirstInterval = TimeSpan.FromMilliseconds(5) }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => svc.ExecuteCount > 0, TimeSpan.FromSeconds(5)); + svc.ExecuteCount.Should().BeGreaterThan(0); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task UnhandledException_WithPauseOnUnhandledException_PausesService() + { + using var sp = CreateServiceProvider(); + var svc = new TestTimerService(sp, NullLogger.Instance) + { + Interval = TimeSpan.FromMilliseconds(20), + FirstInterval = TimeSpan.FromMilliseconds(5), + PauseOnUnhandledException = true, + ThrowOnExecute = true + }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => svc.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(5)); + svc.Status.Should().Be(ServiceStatus.Paused); + svc.LastException.Should().NotBeNull(); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task UnhandledException_WithoutPauseOnUnhandledException_ContinuesExecuting() + { + using var sp = CreateServiceProvider(); + var svc = new TestTimerService(sp, NullLogger.Instance) + { + Interval = TimeSpan.FromMilliseconds(20), + FirstInterval = TimeSpan.FromMilliseconds(5), + PauseOnUnhandledException = false, + ThrowOnExecute = true + }; + + await svc.StartAsync(CancellationToken.None); + try + { + // Should keep retrying (not pause) despite every execution throwing. + await WaitUntilAsync(() => svc.ExecuteCount >= 2, TimeSpan.FromSeconds(5)); + svc.Status.Should().NotBe(ServiceStatus.Paused); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + private class TestTimerService(IServiceProvider serviceProvider, ILogger logger) : TimerHostedServiceBase(serviceProvider, logger) + { + private int _executeCount; + + public int ExecuteCount => _executeCount; + + public bool ThrowOnExecute { get; set; } + + protected override Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _executeCount); + + if (ThrowOnExecute) + throw new InvalidOperationException("Test failure."); + + return Task.FromResult(false); + } + } +} diff --git a/tests/CoreEx.Test.Unit/Http/HttpResponseMessageExtensionsTests.cs b/tests/CoreEx.Test.Unit/Http/HttpResponseMessageExtensionsTests.cs new file mode 100644 index 00000000..da0563f5 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Http/HttpResponseMessageExtensionsTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace CoreEx.Test.Unit.Http; + +[TestFixture] +public class HttpResponseMessageExtensionsTests +{ + [Test] + public async Task ToProblemDetailsAsync_OperationCanceled_Propagates_NotSwallowed() + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new ThrowingContent(new OperationCanceledException(), "application/problem+json") + }; + + Func act = async () => await response.ToProblemDetailsAsync(); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task ToProblemDetailsAsync_OtherException_StillSwallowedAsNotProblemDetails() + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new ThrowingContent(new InvalidOperationException("boom"), "application/problem+json") + }; + + var result = await response.ToProblemDetailsAsync(); + result.Should().BeNull(); + } + + private sealed class ThrowingContent : HttpContent + { + private readonly Exception _exception; + + public ThrowingContent(Exception exception, string mediaType) + { + _exception = exception; + Headers.ContentType = new MediaTypeHeaderValue(mediaType); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => throw _exception; + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => throw _exception; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } +} diff --git a/tests/CoreEx.Test.Unit/Invokers/InvokerBaseTests.cs b/tests/CoreEx.Test.Unit/Invokers/InvokerBaseTests.cs new file mode 100644 index 00000000..f0289b42 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Invokers/InvokerBaseTests.cs @@ -0,0 +1,141 @@ +using CoreEx.Invokers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics; + +namespace CoreEx.Test.Unit.Invokers; + +[TestFixture] +public class InvokerBaseTests +{ + [Test] + public void Constructor_SetsTypeAndName() + { + var invoker = new TestInvoker(); + invoker.Type.Should().Be(typeof(TestInvoker)); + invoker.Name.Should().Be(InvokerNameAttribute.GetName()); + } + + [Test] + public void Constructor_NoServiceProvider_LoggerAndConfigurationAreNull() + { + var invoker = new TestInvoker(); + invoker.Logger.Should().BeNull(); + invoker.Configuration.Should().BeNull(); + } + + [Test] + public void Constructor_WithServiceProvider_ResolvesConfiguration() + { + var sc = new ServiceCollection(); + sc.AddSingleton(new ConfigurationBuilder().Build()); + using var sp = sc.BuildServiceProvider(); + + var invoker = new TestInvoker(sp); + invoker.Configuration.Should().NotBeNull(); + } + + [Test] + public void Defaults_ActivityKindIsInternal_TracingAndLoggingNotDisabled() + { + var invoker = new TestInvoker(); + invoker.ActivityKind.Should().Be(ActivityKind.Internal); + invoker.IsTracingDisabled.Should().BeFalse(); + invoker.IsLoggingDisabled.Should().BeFalse(); + } + + [Test] + public async Task InvokeAsync_WithResult_ReturnsValue() + { + var invoker = new TestInvoker(); + var result = await invoker.InvokeAsync(new object(), async (tracer, ct) => { await Task.Yield(); return 42; }); + result.Should().Be(42); + } + + [Test] + public async Task InvokeAsync_NoResult_Executes() + { + var invoker = new TestInvoker(); + var executed = false; + + await invoker.InvokeAsync(new object(), async tracer => { await Task.Yield(); executed = true; }); + + executed.Should().BeTrue(); + } + + [Test] + public async Task InvokeAsync_Success_FiresOnActivityStartAndComplete_NotException() + { + using var listener = CreateAllDataListener(); + + var invoker = new TestInvoker(); + var result = await invoker.InvokeAsync(new object(), async (tracer, ct) => { await Task.Yield(); return 42; }); + + result.Should().Be(42); + invoker.OnActivityStartCalled.Should().BeTrue(); + invoker.OnActivityCompleteCalled.Should().BeTrue(); + invoker.OnActivityExceptionCalled.Should().BeFalse(); + } + + [Test] + public async Task InvokeAsync_Exception_FiresOnActivityException_AndPropagates() + { + using var listener = CreateAllDataListener(); + + var invoker = new TestInvoker(); + Func act = () => invoker.InvokeAsync(new object(), async (tracer, ct) => { await Task.Yield(); throw new InvalidOperationException("boom"); }); + + await act.Should().ThrowAsync(); + invoker.OnActivityExceptionCalled.Should().BeTrue(); + invoker.OnActivityCompleteCalled.Should().BeFalse(); + } + + [Test] + public async Task InvokeAsyncWithArgs_PassesArgsThrough() + { + var invoker = new TestArgsInvoker(); + var result = await invoker.InvokeAsync(new object(), "hello", async (tracer, args, ct) => { await Task.Yield(); return args.Length; }); + + result.Should().Be(5); + } + + private static ActivityListener CreateAllDataListener() + { + var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + private class TestInvoker(IServiceProvider? serviceProvider = null) : InvokerBase(serviceProvider) + { + public bool OnActivityStartCalled { get; private set; } + public bool OnActivityCompleteCalled { get; private set; } + public bool OnActivityExceptionCalled { get; private set; } + + protected override void OnActivityStart(InvokerTracer tracer) + { + OnActivityStartCalled = true; + base.OnActivityStart(tracer); + } + + protected override void OnActivityComplete(InvokerTracer tracer) + { + OnActivityCompleteCalled = true; + base.OnActivityComplete(tracer); + } + + protected override void OnActivityException(InvokerTracer tracer, Exception exception) + { + OnActivityExceptionCalled = true; + base.OnActivityException(tracer, exception); + } + } + + private class TestArgsInvoker : InvokerBase + { + } +} diff --git a/tests/CoreEx.Test.Unit/Invokers/InvokerNameAttributeTests.cs b/tests/CoreEx.Test.Unit/Invokers/InvokerNameAttributeTests.cs new file mode 100644 index 00000000..6e3bd069 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Invokers/InvokerNameAttributeTests.cs @@ -0,0 +1,39 @@ +using CoreEx.Invokers; + +namespace CoreEx.Test.Unit.Invokers; + +[TestFixture] +public class InvokerNameAttributeTests +{ + [InvokerName("Custom.Invoker.Name")] + private class NamedType { } + + private class UnnamedType { } + + [Test] + public void GetName_WithAttribute_ReturnsAttributeName() + => InvokerNameAttribute.GetName().Should().Be("Custom.Invoker.Name"); + + [Test] + public void GetName_WithoutAttribute_ReturnsNamespaceFormattedName() + => InvokerNameAttribute.GetName().Should().Be(typeof(UnnamedType).Namespace + "." + nameof(UnnamedType)); + + [Test] + public void GetName_ByType_MatchesGenericOverload() + => InvokerNameAttribute.GetName(typeof(NamedType)).Should().Be(InvokerNameAttribute.GetName()); + + [Test] + public void GetName_IsCachedAndConsistentAcrossCalls() + { + var first = InvokerNameAttribute.GetName(); + var second = InvokerNameAttribute.GetName(); + first.Should().Be(second); + } + + [Test] + public void Constructor_NullOrEmptyName_Throws() + { + Action act = () => new InvokerNameAttribute(string.Empty); + act.Should().Throw(); + } +} diff --git a/tests/CoreEx.Test.Unit/Invokers/InvokerTests.cs b/tests/CoreEx.Test.Unit/Invokers/InvokerTests.cs new file mode 100644 index 00000000..8b466faa --- /dev/null +++ b/tests/CoreEx.Test.Unit/Invokers/InvokerTests.cs @@ -0,0 +1,74 @@ +using CoreEx.Invokers; + +namespace CoreEx.Test.Unit.Invokers; + +[TestFixture] +public class InvokerTests +{ + [Test] + public void Default_HasLoggingAndTracingDisabled() + { + Invoker.Default.IsLoggingDisabled.Should().BeTrue(); + Invoker.Default.IsTracingDisabled.Should().BeTrue(); + } + + [Test] + public void RunSync_Action_ExecutesSynchronously() + { + var executed = false; + Invoker.RunSync(() => + { + executed = true; + return Task.CompletedTask; + }); + + executed.Should().BeTrue(); + } + + [Test] + public void RunSync_AlreadyCompletedTask_ReturnsWithoutBlocking() + { + var executed = false; + Invoker.RunSync(() => + { + executed = true; + return Task.CompletedTask; // Already completed - exercises the fast-path. + }); + + executed.Should().BeTrue(); + } + + [Test] + public void RunSync_WithResult_ReturnsValue() + { + var result = Invoker.RunSync(() => Task.FromResult(42)); + result.Should().Be(42); + } + + [Test] + public async Task RunSync_WithAsyncWork_WaitsForCompletionAndReturnsValue() + { + var result = Invoker.RunSync(async () => + { + await Task.Delay(10); + return "done"; + }); + + result.Should().Be("done"); + await Task.CompletedTask; + } + + [Test] + public void RunSync_PropagatesException() + { + Action act = () => Invoker.RunSync(() => throw new InvalidOperationException("boom")); + act.Should().Throw().WithMessage("boom"); + } + + [Test] + public void RunSync_WithResult_PropagatesException() + { + Action act = () => Invoker.RunSync(() => throw new InvalidOperationException("boom")); + act.Should().Throw().WithMessage("boom"); + } +} diff --git a/tests/CoreEx.Test.Unit/Mapping/Converters/EncodedStringToUInt32ConverterTests.cs b/tests/CoreEx.Test.Unit/Mapping/Converters/EncodedStringToUInt32ConverterTests.cs index 7d6360a3..9f646731 100644 --- a/tests/CoreEx.Test.Unit/Mapping/Converters/EncodedStringToUInt32ConverterTests.cs +++ b/tests/CoreEx.Test.Unit/Mapping/Converters/EncodedStringToUInt32ConverterTests.cs @@ -22,6 +22,26 @@ public void ConvertToDestination_Null_ReturnsZero() _converter.ConvertToDestination((string?)null).Should().Be(0u); } + [Test] + public void ConvertToDestination_DecodedValueLongerThanFourBytes_ThrowsFormatException_InsteadOfSilentlyTruncating() + { + var eightByteValue = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var base64 = Convert.ToBase64String(eightByteValue); + + Action act = () => _converter.ConvertToDestination(base64); + act.Should().Throw(); + } + + [Test] + public void ConvertToDestination_DecodedValueShorterThanFourBytes_ThrowsFormatException() + { + var twoByteValue = new byte[] { 1, 2 }; + var base64 = Convert.ToBase64String(twoByteValue); + + Action act = () => _converter.ConvertToDestination(base64); + act.Should().Throw(); + } + [Test] public void ConvertToSource_ValidUInt32_ReturnsBase64String() { diff --git a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs index b1135b01..f88b0105 100644 --- a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -165,6 +165,19 @@ public void AreEqual_IEnumerable_NonCollection_RightLonger_ReferenceType_IsNotEq RuntimeMetadata.AreEqual(LazyEntities(bob, jen), LazyEntities(new EntityA { Name = "Bob" }, new EntityA { Name = "Jen" })).Should().BeTrue(); } + [Test] + public void AreEqual_LeftIsCollection_RightIsNotCollection_ReturnsFalse_DoesNotThrow() + { + // left (List) implements ICollection; right (lazy iterator) does not. The unconditional cast of + // right to ICollection previously threw InvalidCastException in this mismatched-type scenario. + IEnumerable left = [1, 2, 3]; + IEnumerable right = LazyInts(1, 2, 3); + + Action act = () => RuntimeMetadata.AreEqual(left, right); + act.Should().NotThrow(); + RuntimeMetadata.AreEqual(left, right).Should().BeFalse(); + } + [Test] public void AreEqual_Dictionary() { From 27d12b2b6b6044919250130569b8957c586d5ec6 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 5 Aug 2026 13:03:06 -0700 Subject: [PATCH 3/5] Add comprehensive unit tests; fix ETag weak substring bug Added extensive unit tests for CoreEx framework components: EntitiesExtensions, DataExtensions, ETag, IdentifierGenerator, BiDirectionMapper, IntoMapper, AuthenticationUser, Runtime, HostedServiceManager, SynchronizedTimerHostedServiceBase, HybridCacheSynchronizer, and ProblemDetailsException. Tests cover standard, edge, and error cases. Fixed an off-by-one bug in ETag.ParseETag for weak ETags. Updated test usages to direct AuthenticationUser/AuthenticationType types. Ensured AuthenticationUser static state is reset between tests. No production logic changed except ETag fix. --- src/CoreEx/Entities/ETag.cs | 2 +- .../Data/DataExtensionsTests.cs | 173 ++++++++++++ tests/CoreEx.Test.Unit/Entities/ETagTests.cs | 166 +++++++++++ .../Entities/EntitiesExtensionsTests.cs | 129 +++++++++ .../Entities/IdentifierGeneratorTests.cs | 144 ++++++++++ .../CoreEx.Test.Unit/ExecutionContextTests.cs | 9 +- .../ExtendedExceptionExtensionsTests.cs | 92 ++++++ .../Hosting/HostedServiceManagerTests.cs | 261 ++++++++++++++++++ .../HybridCacheSynchronizerTests.cs | 145 ++++++++++ ...SynchronizedTimerHostedServiceBaseTests.cs | 228 +++++++++++++++ .../Hosting/Work/WorkOrchestratorTests.cs | 3 +- .../Http/ProblemDetailsExceptionTests.cs | 113 ++++++++ .../Mapping/BiDirectionMapperTests.cs | 90 ++++++ .../Mapping/IntoMapperTests.cs | 110 ++++++++ tests/CoreEx.Test.Unit/RuntimeTests.cs | 52 ++++ .../Security/AuthenticationUserTests.cs | 96 +++++++ 16 files changed, 1807 insertions(+), 6 deletions(-) create mode 100644 tests/CoreEx.Test.Unit/Data/DataExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/ETagTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/EntitiesExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Entities/IdentifierGeneratorTests.cs create mode 100644 tests/CoreEx.Test.Unit/ExtendedExceptionExtensionsTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/HostedServiceManagerTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs create mode 100644 tests/CoreEx.Test.Unit/Hosting/SynchronizedTimerHostedServiceBaseTests.cs create mode 100644 tests/CoreEx.Test.Unit/Http/ProblemDetailsExceptionTests.cs create mode 100644 tests/CoreEx.Test.Unit/Mapping/BiDirectionMapperTests.cs create mode 100644 tests/CoreEx.Test.Unit/Mapping/IntoMapperTests.cs create mode 100644 tests/CoreEx.Test.Unit/RuntimeTests.cs create mode 100644 tests/CoreEx.Test.Unit/Security/AuthenticationUserTests.cs diff --git a/src/CoreEx/Entities/ETag.cs b/src/CoreEx/Entities/ETag.cs index e609e958..6c531885 100644 --- a/src/CoreEx/Entities/ETag.cs +++ b/src/CoreEx/Entities/ETag.cs @@ -118,7 +118,7 @@ public static string ParseETag(ReadOnlySpan etag) return etag[1..^1].ToString(); if (etag.StartsWith("W/\"") && etag[^1] == '\"') - return etag[2..^1].ToString(); + return etag[3..^1].ToString(); return etag.ToString(); } diff --git a/tests/CoreEx.Test.Unit/Data/DataExtensionsTests.cs b/tests/CoreEx.Test.Unit/Data/DataExtensionsTests.cs new file mode 100644 index 00000000..e547cbf1 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/DataExtensionsTests.cs @@ -0,0 +1,173 @@ +using CoreEx.Data; + +namespace CoreEx.Test.Unit.Data; + +[TestFixture] +public class DataExtensionsTests +{ + private class Item + { + public string? Name { get; set; } + } + + private static IQueryable Numbers => new List { 1, 2, 3, 4, 5 }.AsQueryable(); + + private static IQueryable Items => new List + { + new() { Name = "Bob Smith" }, + new() { Name = "Alice Jones" }, + new() { Name = null } + }.AsQueryable(); + + [Test] + public void WhereWhen_True_AppliesPredicate() + => Numbers.WhereWhen(true, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWhen_False_ReturnsSourceUnfiltered() + => Numbers.WhereWhen(false, i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_DefaultValue_ReturnsSourceUnfiltered() + => Numbers.WhereWith(0, i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_NonDefaultValue_AppliesPredicate() + => Numbers.WhereWith(5, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWith_EmptyEnumerableWith_ReturnsSourceUnfiltered() + => Numbers.WhereWith(Array.Empty(), i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_NonEmptyEnumerableWith_AppliesPredicate() + => Numbers.WhereWith(new[] { 1 }, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWildcard_Contains_FiltersMatchingItems() + => Items.WhereWildcard(x => x.Name, "*Smith*").Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + + [Test] + public void WhereWildcard_StartsWith_FiltersMatchingItems() + => Items.WhereWildcard(x => x.Name, "Bob*").Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + + [Test] + public void WhereWildcard_EndsWith_FiltersMatchingItems() + => Items.WhereWildcard(x => x.Name, "*Jones").Should().ContainSingle().Which.Name.Should().Be("Alice Jones"); + + [Test] + public void WhereWildcard_Equal_FiltersExactMatch() + => Items.WhereWildcard(x => x.Name, "Bob Smith").Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + + [Test] + public void WhereWildcard_IgnoreCase_MatchesRegardlessOfCase() + => Items.WhereWildcard(x => x.Name, "*smith*", ignoreCase: true).Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + + [Test] + public void WhereWildcard_NullPattern_ReturnsAllItems() + => Items.WhereWildcard(x => x.Name, null).Should().BeEquivalentTo(Items); + + [Test] + public void WhereWildcard_NullSelector_Throws() + { + Action act = () => Items.WhereWildcard(null!, "*Bob*").ToList(); + act.Should().Throw(); + } + + [Test] + public void WhereWildcard_NonMemberExpressionSelector_Throws() + { + Action act = () => Items.WhereWildcard(x => x.Name!.ToUpper(), "*BOB*").ToList(); + act.Should().Throw(); + } + + [Test] + public void WithPaging_NullPaging_UsesDefault() + => Numbers.WithPaging().ToList().Should().BeEquivalentTo(Numbers); + + [Test] + public void WithPaging_SkipAndTake_ReturnsSubset() + => Numbers.WithPaging(PagingArgs.Create(skip: 1, take: 2)).ToList().Should().BeEquivalentTo([2, 3], o => o.WithStrictOrdering()); + + [Test] + public void WithPaging_None_ReturnsSourceUnfiltered() + => Numbers.WithPaging(PagingArgs.None).ToList().Should().BeEquivalentTo(Numbers); + + [Test] + public void WithTotalCount_NotRequested_DoesNotSetTotalCount() + { + // Invoked via explicit static syntax to bypass PagingResult's own like-named instance method and exercise the DataExtensions.WithTotalCount(long) guard directly. + var result = DataExtensions.WithTotalCount(new PagingResult(PagingArgs.Create()), 100L); + result.TotalCount.Should().BeNull(); + } + + [Test] + public void WithTotalCount_Requested_SetsTotalCount() + { + var result = DataExtensions.WithTotalCount(new PagingResult(PagingArgs.CreateWithCount()), 100L); + result.TotalCount.Should().Be(100); + } + + [Test] + public void WithTotalCount_Func_Requested_SetsTotalCount() + { + var result = new PagingResult(PagingArgs.CreateWithCount()).WithTotalCount(() => 42); + result.TotalCount.Should().Be(42); + } + + [Test] + public void WithTotalCount_Func_Requested_ExceptionSwallowed_LeavesTotalCountNull() + { + long ThrowingFunc() => throw new InvalidOperationException("boom"); + + var result = new PagingResult(PagingArgs.CreateWithCount()); + Action act = () => result.WithTotalCount(ThrowingFunc); + + act.Should().NotThrow(); + result.TotalCount.Should().BeNull(); + } + + [Test] + public async Task WithTotalCountAsync_Requested_SetsTotalCount() + { + var result = await new PagingResult(PagingArgs.CreateWithCount()).WithTotalCountAsync(() => Task.FromResult(77)); + result.TotalCount.Should().Be(77); + } + + [Test] + public async Task WithTotalCountAsync_NotRequested_DoesNotInvokeFuncOrSetTotalCount() + { + var invoked = false; + var result = await new PagingResult(PagingArgs.Create()).WithTotalCountAsync(() => + { + invoked = true; + return Task.FromResult(77); + }); + + invoked.Should().BeFalse(); + result.TotalCount.Should().BeNull(); + } + + [Test] + public async Task WithTotalCountAsync_Requested_ExceptionSwallowed_LeavesTotalCountNull() + { + var result = await new PagingResult(PagingArgs.CreateWithCount()).WithTotalCountAsync(() => throw new InvalidOperationException("boom")); + result.TotalCount.Should().BeNull(); + } + + [Test] + public async Task WithTotalCountAsync_WithCancellationToken_PassesTokenThrough() + { + using var cts = new CancellationTokenSource(); + CancellationToken? received = null; + + var result = await new PagingResult(PagingArgs.CreateWithCount()).WithTotalCountAsync(ct => + { + received = ct; + return Task.FromResult(9); + }, cts.Token); + + received.Should().Be(cts.Token); + result.TotalCount.Should().Be(9); + } +} diff --git a/tests/CoreEx.Test.Unit/Entities/ETagTests.cs b/tests/CoreEx.Test.Unit/Entities/ETagTests.cs new file mode 100644 index 00000000..b7213651 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/ETagTests.cs @@ -0,0 +1,166 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class ETagTests +{ + private sealed class Etaggable(string? etag) : IReadOnlyETag + { + public string? ETag { get; } = etag; + } + + [Test] + public void TryCompare_Strings_MatchAndMismatch() + { + ETag.TryCompare("abc", "abc").Should().BeTrue(); + ETag.TryCompare("abc", "def").Should().BeFalse(); + ETag.TryCompare((string?)null, (string?)null).Should().BeTrue(); + ETag.TryCompare("abc", null).Should().BeFalse(); + } + + [Test] + public void TryCompare_ReadOnlyETag_DelegatesToStringOverload() + { + ETag.TryCompare(new Etaggable("abc"), new Etaggable("abc")).Should().BeTrue(); + ETag.TryCompare(new Etaggable("abc"), new Etaggable("def")).Should().BeFalse(); + ETag.TryCompare((IReadOnlyETag?)null, (IReadOnlyETag?)null).Should().BeTrue(); + } + + [Test] + public void Compare_Strings_Match_DoesNotThrow() + { + Action act = () => ETag.Compare("abc", "abc"); + act.Should().NotThrow(); + } + + [Test] + public void Compare_Strings_Mismatch_ThrowsConcurrencyException() + { + Action act = () => ETag.Compare("abc", "def"); + act.Should().Throw(); + } + + [Test] + public void Compare_Mismatch_InvokesAdjuster() + { + var adjusted = false; + Action act = () => ETag.Compare("abc", "def", adjuster: _ => adjusted = true); + act.Should().Throw(); + adjusted.Should().BeTrue(); + } + + [Test] + public void Compare_ReadOnlyETag_DelegatesToStringOverload() + { + Action act = () => ETag.Compare(new Etaggable("abc"), new Etaggable("def")); + act.Should().Throw(); + } + + [Test] + public void CompareWithResult_Match_ReturnsSuccess() + { + var result = ETag.CompareWithResult("abc", "abc"); + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void CompareWithResult_Mismatch_ReturnsConcurrencyError() + { + var result = ETag.CompareWithResult("abc", "def"); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public void CompareWithResult_ReadOnlyETag_DelegatesToStringOverload() + { + var result = ETag.CompareWithResult(new Etaggable("abc"), new Etaggable("abc")); + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void FormatETag_Null_ReturnsNull() => ETag.FormatETag(null).Should().BeNull(); + + [Test] + public void FormatETag_AlreadyQuoted_ReturnsUnchanged() => ETag.FormatETag("\"abc\"").Should().Be("\"abc\""); + + [Test] + public void FormatETag_WeakPrefixed_StripsWeakPrefix() => ETag.FormatETag("W/\"abc\"").Should().Be("\"abc\""); + + [Test] + public void FormatETag_PlainValue_AddsQuotes() => ETag.FormatETag("abc").Should().Be("\"abc\""); + + [Test] + public void ParseETag_Null_ReturnsNull() => ETag.ParseETag((string?)null).Should().BeNull(); + + [Test] + public void ParseETag_Empty_ReturnsEmpty() => ETag.ParseETag(string.Empty).Should().Be(string.Empty); + + [Test] + public void ParseETag_Quoted_StripsQuotes() => ETag.ParseETag("\"abc\"").Should().Be("abc"); + + [Test] + public void ParseETag_WeakPrefixed_StripsWeakPrefixAndQuotes() => ETag.ParseETag("W/\"abc\"").Should().Be("abc"); + + [Test] + public void ParseETag_Unquoted_ReturnsUnchanged() => ETag.ParseETag("abc").Should().Be("abc"); + + [Test] + public void Generate_NullValue_ReturnsNull() => ETag.Generate(null).Should().BeNull(); + + [Test] + public void Generate_Value_ReturnsTwelveCharHash() + { + var etag = ETag.Generate(new { Id = 1, Name = "test" }); + etag.Should().NotBeNullOrEmpty(); + etag!.Length.Should().Be(12); + } + + [Test] + public void Generate_SameValue_ReturnsSameHash() + { + var etag1 = ETag.Generate(new { Id = 1, Name = "test" }); + var etag2 = ETag.Generate(new { Id = 1, Name = "test" }); + etag1.Should().Be(etag2); + } + + [Test] + public void Generate_DifferentParts_ReturnsDifferentHash() + { + var etag1 = ETag.Generate(new { Id = 1 }, parts: ["a"]); + var etag2 = ETag.Generate(new { Id = 1 }, parts: ["b"]); + etag1.Should().NotBe(etag2); + } + + [Test] + public void Generate_Parts_NullOrEmpty_ReturnsNull() + { + ETag.Generate((string[])null!).Should().BeNull(); + ETag.Generate().Should().BeNull(); + } + + [Test] + public void Generate_Parts_SinglePart_ReturnsTwelveCharHash() + { + var etag = ETag.Generate("abc"); + etag.Should().NotBeNullOrEmpty(); + etag!.Length.Should().Be(12); + } + + [Test] + public void Generate_Parts_MultipleParts_ReturnsConsistentHash() + { + var etag1 = ETag.Generate("abc", "def"); + var etag2 = ETag.Generate("abc", "def"); + etag1.Should().Be(etag2); + } + + [Test] + public void Generate_Parts_DifferentOrder_ReturnsDifferentHash() + { + var etag1 = ETag.Generate("abc", "def"); + var etag2 = ETag.Generate("def", "abc"); + etag1.Should().NotBe(etag2); + } +} diff --git a/tests/CoreEx.Test.Unit/Entities/EntitiesExtensionsTests.cs b/tests/CoreEx.Test.Unit/Entities/EntitiesExtensionsTests.cs new file mode 100644 index 00000000..eda853ed --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/EntitiesExtensionsTests.cs @@ -0,0 +1,129 @@ +using CoreEx.Data; +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class EntitiesExtensionsTests +{ + private class Item + { + public string? Name { get; set; } + } + + private static List Numbers => [1, 2, 3, 4, 5]; + + [Test] + public void WhereWhen_True_AppliesPredicate() + => Numbers.WhereWhen(true, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWhen_False_ReturnsSourceUnfiltered() + => Numbers.WhereWhen(false, i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_DefaultValue_ReturnsSourceUnfiltered() + => Numbers.WhereWith(0, i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_NonDefaultValue_AppliesPredicate() + => Numbers.WhereWith(5, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWith_NonDefaultString_AppliesPredicate() + => Numbers.WhereWith("abc", i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWith_NullWith_ReturnsSourceUnfiltered() + => Numbers.WhereWith((string?)null, i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_EmptyEnumerableWith_ReturnsSourceUnfiltered() + => Numbers.WhereWith(Array.Empty(), i => i > 2).Should().BeEquivalentTo(Numbers); + + [Test] + public void WhereWith_NonEmptyEnumerableWith_AppliesPredicate() + => Numbers.WhereWith(new[] { 1 }, i => i > 2).Should().BeEquivalentTo([3, 4, 5]); + + [Test] + public void WhereWildcard_Contains_FiltersMatchingItems() + { + var items = new[] { new Item { Name = "Bob Smith" }, new Item { Name = "Alice Jones" } }; + var result = items.WhereWildcard(x => x.Name, "*Smith*"); + result.Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + } + + [Test] + public void WhereWildcard_NullSelectorValue_CheckForNullTrue_ExcludesNull() + { + var items = new[] { new Item { Name = null }, new Item { Name = "Bob Smith" } }; + var result = items.WhereWildcard(x => x.Name, "*Smith*", checkForNull: true); + result.Should().ContainSingle().Which.Name.Should().Be("Bob Smith"); + } + + [Test] + public void WhereWildcard_NullPattern_ReturnsAllItems() + { + var items = new[] { new Item { Name = "Bob" }, new Item { Name = "Alice" } }; + var result = items.WhereWildcard(x => x.Name, null); + result.Should().BeEquivalentTo(items); + } + + [Test] + public void WhereWildcard_NullSelector_Throws() + { + var items = new[] { new Item { Name = "Bob" } }; + Action act = () => items.WhereWildcard(null!, "*Bob*").ToList(); + act.Should().Throw(); + } + + [Test] + public void WithPaging_NullPaging_UsesDefault() + { + var result = Numbers.WithPaging().ToList(); + result.Should().BeEquivalentTo(Numbers); + } + + [Test] + public void WithPaging_SkipAndTake_ReturnsSubset() + { + var result = Numbers.WithPaging(PagingArgs.Create(skip: 1, take: 2)).ToList(); + result.Should().BeEquivalentTo([2, 3], o => o.WithStrictOrdering()); + } + + [Test] + public void WithPaging_None_ReturnsSourceUnfiltered() + { + var result = Numbers.WithPaging(PagingArgs.None).ToList(); + result.Should().BeEquivalentTo(Numbers); + } + + [TestCase(FeatureSupport.NotSupported, true, false, false, false)] + [TestCase(FeatureSupport.ReadOnly, false, true, false, true)] + [TestCase(FeatureSupport.Mutable, false, false, true, true)] + public void FeatureSupport_Flags_AreCorrect(FeatureSupport support, bool isNone, bool isReadOnly, bool isMutable, bool isSupported) + { + support.IsNone.Should().Be(isNone); + support.IsReadOnly.Should().Be(isReadOnly); + support.IsMutable.Should().Be(isMutable); + support.IsSupported.Should().Be(isSupported); + } + + private interface IMutableFeature { } + private interface IReadOnlyFeature { } + private class MutableThing : IMutableFeature, IReadOnlyFeature { } + private class ReadOnlyThing : IReadOnlyFeature { } + private class UnsupportedThing { } + + [Test] + public void Determine_ImplementsMutable_ReturnsMutable() + => EntitiesExtensions.Determine().Should().Be(FeatureSupport.Mutable); + + [Test] + public void Determine_ImplementsReadOnlyOnly_ReturnsReadOnly() + => EntitiesExtensions.Determine().Should().Be(FeatureSupport.ReadOnly); + + [Test] + public void Determine_ImplementsNeither_ReturnsNotSupported() + => EntitiesExtensions.Determine().Should().Be(FeatureSupport.NotSupported); +} diff --git a/tests/CoreEx.Test.Unit/Entities/IdentifierGeneratorTests.cs b/tests/CoreEx.Test.Unit/Entities/IdentifierGeneratorTests.cs new file mode 100644 index 00000000..186b6448 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/IdentifierGeneratorTests.cs @@ -0,0 +1,144 @@ +using CoreEx.Entities; +using CoreEx.Entities.Extended; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class IdentifierGeneratorTests +{ + private class GuidEntity : IIdentifier + { + public Guid Id { get; set; } + } + + private class StringEntity : IIdentifier + { + public string Id { get; set; } = null!; + } + + private class IntEntity : IIdentifier + { + public int Id { get; set; } + } + + private class PlainEntity { } + + [TearDown] + public void TearDown() => ExecutionContext.Reset(); + + [Test] + public void GenerateGuid_ReturnsNonEmptyGuid() + => new IdentifierGenerator().GenerateGuid().Should().NotBe(Guid.Empty); + + [Test] + public void GenerateGuid_ReturnsDistinctValues() + { + var gen = new IdentifierGenerator(); + gen.GenerateGuid().Should().NotBe(gen.GenerateGuid()); + } + + [Test] + public async Task GenerateIdentifierAsync_String_ReturnsGuidFormattedString() + { + var id = await new IdentifierGenerator().GenerateIdentifierAsync(); + Guid.TryParse(id, out _).Should().BeTrue(); + } + + [Test] + public async Task GenerateIdentifierAsync_Guid_ReturnsNonEmptyGuid() + { + var id = await new IdentifierGenerator().GenerateIdentifierAsync(); + id.Should().NotBe(Guid.Empty); + } + + [Test] + public void GenerateIdentifierAsync_UnsupportedType_ThrowsNotSupportedException() + { + Action act = () => new IdentifierGenerator().GenerateIdentifierAsync(); + act.Should().Throw().WithMessage("*Int32*"); + } + + [Test] + public async Task GenerateIdentifierAsync_WithFor_DelegatesToGenerateIdentifierAsync() + { + var id = await new IdentifierGenerator().GenerateIdentifierAsync(); + Guid.TryParse(id, out _).Should().BeTrue(); + } + + [Test] + public async Task AssignIdentifierAsync_StringEntity_NullId_AssignsGeneratedId() + { + var entity = new StringEntity { Id = null! }; + await new IdentifierGenerator().AssignIdentifierAsync(entity); + + entity.Id.Should().NotBeNullOrEmpty(); + Guid.TryParse(entity.Id, out _).Should().BeTrue(); + } + + [Test] + public async Task AssignIdentifierAsync_StringEntity_ExistingId_DoesNotOverwrite() + { + var entity = new StringEntity { Id = "existing-id" }; + await new IdentifierGenerator().AssignIdentifierAsync(entity); + + entity.Id.Should().Be("existing-id"); + } + + [Test] + public async Task AssignIdentifierAsync_GuidEntity_EmptyId_AssignsGeneratedId() + { + var entity = new GuidEntity { Id = Guid.Empty }; + await new IdentifierGenerator().AssignIdentifierAsync(entity); + + entity.Id.Should().NotBe(Guid.Empty); + } + + [Test] + public async Task AssignIdentifierAsync_GuidEntity_ExistingId_DoesNotOverwrite() + { + var existing = Guid.NewGuid(); + var entity = new GuidEntity { Id = existing }; + await new IdentifierGenerator().AssignIdentifierAsync(entity); + + entity.Id.Should().Be(existing); + } + + [Test] + public async Task AssignIdentifierAsync_UnsupportedIdType_ThrowsNotSupportedException() + { + var entity = new IntEntity { Id = 0 }; + Func act = () => new IdentifierGenerator().AssignIdentifierAsync(entity); + + await act.Should().ThrowAsync().WithMessage("*Int32*"); + } + + [Test] + public async Task AssignIdentifierAsync_NotAnIdentifier_NoOp() + { + var entity = new PlainEntity(); + Func act = () => new IdentifierGenerator().AssignIdentifierAsync(entity); + + await act.Should().NotThrowAsync(); + } + + [Test] + public void Current_NoExecutionContextService_ReturnsDefaultInstance() + { + ExecutionContext.Reset(); + IdentifierGenerator.Current.Should().NotBeNull(); + } + + [Test] + public void Current_WithRegisteredService_ReturnsRegisteredInstance() + { + var custom = new IdentifierGenerator(); + var sc = new ServiceCollection(); + sc.AddSingleton(custom); + using var sp = sc.BuildServiceProvider(); + + ExecutionContext.SetCurrent(new ExecutionContext { ServiceProvider = sp }); + + IdentifierGenerator.Current.Should().BeSameAs(custom); + } +} diff --git a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs index 263b67eb..10e956e7 100644 --- a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs +++ b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs @@ -1,5 +1,6 @@ using CoreEx.Entities; using CoreEx.Localization; +using CoreEx.Security; using Microsoft.Extensions.DependencyInjection; using System.Globalization; @@ -29,11 +30,11 @@ public void UserName_GetSet() { var ec = new ExecutionContext { - User = new Security.AuthenticationUser { Type = Security.AuthenticationType.AccountUser, UserName = "user1" } + User = new AuthenticationUser { Type = AuthenticationType.AccountUser, UserName = "user1" } }; ec.User.Should().NotBeNull(); ec.User.UserName.Should().Be("user1"); - ec.User.Type.Should().Be(Security.AuthenticationType.AccountUser); + ec.User.Type.Should().Be(AuthenticationType.AccountUser); } [Test] @@ -111,7 +112,7 @@ public void CreateCopy_CopiesPropertiesAndSharesMessagesAndAttributes() { var ec = new ExecutionContext { - User = new Security.AuthenticationUser { Type = Security.AuthenticationType.AccountUser, UserName = "user" }, + User = new AuthenticationUser { Type = AuthenticationType.AccountUser, UserName = "user" }, TenantId = "tenant", UICulture = new CultureInfo("en-US"), OperationType = OperationType.Update, @@ -122,7 +123,7 @@ public void CreateCopy_CopiesPropertiesAndSharesMessagesAndAttributes() var copy = ec.CreateCopy(); copy.User.Should().NotBeNull(); copy.User.UserName.Should().Be("user"); - copy.User.Type.Should().Be(Security.AuthenticationType.AccountUser); + copy.User.Type.Should().Be(AuthenticationType.AccountUser); copy.TenantId.Should().Be("tenant"); copy.UICulture.Should().Be(new CultureInfo("en-US")); copy.OperationType.Should().Be(OperationType.Update); diff --git a/tests/CoreEx.Test.Unit/ExtendedExceptionExtensionsTests.cs b/tests/CoreEx.Test.Unit/ExtendedExceptionExtensionsTests.cs new file mode 100644 index 00000000..0b7cfadd --- /dev/null +++ b/tests/CoreEx.Test.Unit/ExtendedExceptionExtensionsTests.cs @@ -0,0 +1,92 @@ +using System.Net; + +namespace CoreEx.Test.Unit; + +[TestFixture] +public class ExtendedExceptionExtensionsTests +{ + [Test] + public void WithErrorCode_SetsErrorCode() + { + var ex = new BusinessException(null, null).WithErrorCode("ERR001"); + ex.ErrorCode.Should().Be("ERR001"); + } + + [Test] + public void WithErrorType_SetsErrorType() + { + var ex = new BusinessException(null, null).WithErrorType("custom-type"); + ex.ErrorType.Should().Be("custom-type"); + } + + [Test] + public void WithStatusCode_SetsStatusCode() + { + var ex = new BusinessException(null, null).WithStatusCode(HttpStatusCode.Conflict); + ex.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Test] + public void WithDetail_SetsDetail() + { + var ex = new BusinessException(null, null).WithDetail("more info"); + ex.Detail.Should().Be("more info"); + } + + [Test] + public void WithKey_SetsExtensionUnderKeyName() + { + var ex = new BusinessException(null, null).WithKey(123); + ex.Extensions.Should().ContainKey("key").WhoseValue.Should().Be(123); + } + + [Test] + public void WithExtension_SetsNamedExtension() + { + var ex = new BusinessException(null, null).WithExtension("custom", "value"); + ex.Extensions.Should().ContainKey("custom").WhoseValue.Should().Be("value"); + ex.HasExtensions.Should().BeTrue(); + } + + [Test] + public void WithExtension_NullOrEmptyName_Throws() + { + Action act = () => new BusinessException(null, null).WithExtension(string.Empty, "value"); + act.Should().Throw(); + } + + [Test] + public void AsTransient_SetsIsTransientAndDefaultRetryAfter() + { + var ex = new BusinessException(null, null).AsTransient(); + ex.IsTransient.Should().BeTrue(); + ex.RetryAfter.Should().Be(TransientException.DefaultRetryAfter); + } + + [Test] + public void AsTransient_WithExplicitRetryAfter_UsesIt() + { + var retry = TimeSpan.FromSeconds(30); + var ex = new BusinessException(null, null).AsTransient(retry); + ex.RetryAfter.Should().Be(retry); + } + + [Test] + public void FluentChaining_CombinesMultipleBuilders() + { + var ex = new BusinessException("msg") + .WithErrorCode("E1") + .WithErrorType("custom") + .WithStatusCode(HttpStatusCode.BadRequest) + .WithDetail("detail") + .WithKey("k1") + .AsTransient(); + + ex.ErrorCode.Should().Be("E1"); + ex.ErrorType.Should().Be("custom"); + ex.StatusCode.Should().Be(HttpStatusCode.BadRequest); + ex.Detail.Should().Be("detail"); + ex.Extensions["key"].Should().Be("k1"); + ex.IsTransient.Should().BeTrue(); + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/HostedServiceManagerTests.cs b/tests/CoreEx.Test.Unit/Hosting/HostedServiceManagerTests.cs new file mode 100644 index 00000000..df350171 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/HostedServiceManagerTests.cs @@ -0,0 +1,261 @@ +using CoreEx.Hosting; +using CoreEx.Results; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class HostedServiceManagerTests +{ + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + private static ServiceProvider CreateServiceProvider(params HostedServiceBase[] services) + { + var sc = new ServiceCollection(); + sc.AddSingleton(new ConfigurationBuilder().Build()); + foreach (var s in services) + sc.AddSingleton(s); + + return sc.BuildServiceProvider(); + } + + [Test] + public async Task GetAllStatusesAsync_ReturnsStatusPerService() + { + using var sp = CreateServiceProvider(); + var alpha = new AlphaService(sp, NullLogger.Instance); + var beta = new BetaService(sp, NullLogger.Instance); + await alpha.StartAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha, beta)); + var result = await manager.GetAllStatusesAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainKey("AlphaService").WhoseValue.Should().Be(ServiceStatus.Running); + result.Value.Should().ContainKey("BetaService").WhoseValue.Should().Be(ServiceStatus.Initializing); + } + + [Test] + public async Task GetAllStatusesAsync_AmbiguousServiceName_ReturnsValidationError() + { + using var innerSp = CreateServiceProvider(); + var alpha1 = new AlphaService(innerSp, NullLogger.Instance); + var alpha2 = new AlphaService(innerSp, NullLogger.Instance); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha1, alpha2)); + var result = await manager.GetAllStatusesAsync(); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + result.Error!.Message.Should().Contain("ambiguous"); + } + + [Test] + public async Task GetAllStatusesAsync_PreCheckFails_ReturnsFailureAndSkipsServices() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)) + { + PreCheckAsync = (_, _) => Task.FromResult(Result.ValidationError("blocked")) + }; + + var result = await manager.GetAllStatusesAsync(); + + result.IsFailure.Should().BeTrue(); + result.Error!.Message.Should().Be("blocked"); + } + + [Test] + public void PreCheckAsync_SetNull_Throws() + { + var manager = new HostedServiceManager(CreateServiceProvider()); + Action act = () => manager.PreCheckAsync = null!; + act.Should().Throw(); + } + + [Test] + public async Task PreCheckAsync_InvokedWithEmptyKey_ForAllOperations() + { + string? captured = "not-called"; + var manager = new HostedServiceManager(CreateServiceProvider()) + { + PreCheckAsync = (key, _) => + { + captured = key; + return Result.SuccessTask; + } + }; + + await manager.GetAllStatusesAsync(); + + captured.Should().Be(string.Empty); + } + + [Test] + public async Task PreCheckAsync_InvokedWithServiceKey_ForSingleServiceOperations() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance); + string? captured = null; + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)) + { + PreCheckAsync = (key, _) => + { + captured = key; + return Result.SuccessTask; + } + }; + + await manager.GetStatusAsync("AlphaService"); + + captured.Should().Be("AlphaService"); + } + + [Test] + public async Task PauseAllAsync_PausesAllSupportedServices() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await alpha.StartAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)); + var result = await manager.PauseAllAsync(); + + result.IsSuccess.Should().BeTrue(); + await WaitUntilAsync(() => alpha.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task ResumeAllAsync_ResumesAllSupportedServices() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await alpha.StartAsync(CancellationToken.None); + await alpha.PauseAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)); + var result = await manager.ResumeAllAsync(); + + result.IsSuccess.Should().BeTrue(); + await WaitUntilAsync(() => alpha.Status == ServiceStatus.Running, TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task GetStatusAsync_UnknownKey_ReturnsNotFoundError() + { + var manager = new HostedServiceManager(CreateServiceProvider()); + var result = await manager.GetStatusAsync("Missing"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public async Task GetStatusAsync_KnownKey_ReturnsStatus() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance); + await alpha.StartAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)); + var result = await manager.GetStatusAsync("AlphaService"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(ServiceStatus.Running); + } + + [Test] + public async Task GetStatusAsync_AmbiguousKey_ReturnsValidationError() + { + using var innerSp = CreateServiceProvider(); + var alpha1 = new AlphaService(innerSp, NullLogger.Instance); + var alpha2 = new AlphaService(innerSp, NullLogger.Instance); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha1, alpha2)); + var result = await manager.GetStatusAsync("AlphaService"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public async Task PauseAsync_KnownKey_EventuallyPauses() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await alpha.StartAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)); + var result = await manager.PauseAsync("AlphaService"); + + result.IsSuccess.Should().BeTrue(); + await WaitUntilAsync(() => alpha.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task ResumeAsync_KnownKey_EventuallyResumes() + { + using var innerSp = CreateServiceProvider(); + var alpha = new AlphaService(innerSp, NullLogger.Instance) { SupportsPauseAndResume = true }; + await alpha.StartAsync(CancellationToken.None); + await alpha.PauseAsync(CancellationToken.None); + + var manager = new HostedServiceManager(CreateServiceProvider(alpha)); + var result = await manager.ResumeAsync("AlphaService"); + + result.IsSuccess.Should().BeTrue(); + await WaitUntilAsync(() => alpha.Status == ServiceStatus.Running, TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task PauseAsync_UnknownKey_ReturnsNotFoundError() + { + var manager = new HostedServiceManager(CreateServiceProvider()); + var result = await manager.PauseAsync("Missing"); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + private class AlphaService(IServiceProvider serviceProvider, ILogger logger) : HostedServiceBase(serviceProvider, logger) + { + public bool SupportsPauseAndResume + { + get => ArePauseAndResumeSupported; + set => ArePauseAndResumeSupported = value; + } + + protected override Task OnStartAsync(CancellationToken cancellationToken) => Task.FromResult(ServiceStatus.Running); + protected override Task OnStopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override Task OnPauseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override Task OnResumeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) => HealthCheckResult.Healthy(); + } + + private class BetaService(IServiceProvider serviceProvider, ILogger logger) : HostedServiceBase(serviceProvider, logger) + { + protected override Task OnStartAsync(CancellationToken cancellationToken) => Task.FromResult(ServiceStatus.Running); + protected override Task OnStopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override Task OnPauseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override Task OnResumeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) => HealthCheckResult.Healthy(); + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs b/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs new file mode 100644 index 00000000..664dd102 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs @@ -0,0 +1,145 @@ +using CoreEx.Caching; +using CoreEx.Hosting.Synchronization; + +namespace CoreEx.Test.Unit.Hosting.Synchronization; + +[TestFixture] +public class HybridCacheSynchronizerTests +{ + private class FakeHybridCache : IHybridCache + { + private readonly Dictionary _store = []; + + public ICacheKeyProvider KeyProvider { get; } = new DefaultCacheKeyProvider(); + + public bool ContainsKey(string key) => _store.ContainsKey(key); + + public Task<(bool Exists, T? Value)> TryGetByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(_store.TryGetValue(key, out var entry) ? (true, (T?)entry.Value) : (false, default)); + + public Task GetOrDefaultByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(_store.TryGetValue(key, out var entry) ? (T?)entry.Value : default); + + public Task SetByKeyAsync(string key, T value, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + _store[key] = (value, options?.Tags ?? []); + return Task.CompletedTask; + } + + public async Task GetOrCreateByKeyAsync(string key, Func> factory, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + if (_store.TryGetValue(key, out var entry)) + return (T)entry.Value!; + + var result = await factory(cancellationToken); + _store[key] = (result, options?.Tags ?? []); + return result; + } + + public Task RemoveByKeyAsync(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + _store.Remove(key); + return Task.CompletedTask; + } + + public Task RemoveByTagAsync(string tag, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + foreach (var k in _store.Where(kv => kv.Value.Tags.Contains(tag)).Select(kv => kv.Key).ToList()) + _store.Remove(k); + + return Task.CompletedTask; + } + + public async Task RemoveByTagAsync(IEnumerable tags, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) + { + foreach (var tag in tags) + await RemoveByTagAsync(tag, options, cancellationToken); + } + } + + [Test] + public async Task EnterAsync_FirstCaller_ReturnsTrue() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + var result = await synchronizer.EnterAsync(); + result.Should().BeTrue(); + } + + [Test] + public async Task EnterAsync_AlreadyEntered_ReturnsFalse() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + (await synchronizer.EnterAsync()).Should().BeTrue(); + (await synchronizer.EnterAsync()).Should().BeFalse(); + } + + [Test] + public async Task EnterAsync_DifferentNames_AreTrackedIndependently() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + (await synchronizer.EnterAsync("a")).Should().BeTrue(); + (await synchronizer.EnterAsync("b")).Should().BeTrue(); + } + + [Test] + public async Task ExitAsync_AfterEnter_AllowsReentry() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + await synchronizer.EnterAsync(); + + await synchronizer.ExitAsync(); + + (await synchronizer.EnterAsync()).Should().BeTrue(); + } + + [Test] + public void ExitAsync_NotEntered_ThrowsInvalidOperationException() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + Func act = () => synchronizer.ExitAsync(); + act.Should().ThrowAsync(); + } + + [Test] + public async Task ExitAsync_TwiceForSameEntry_SecondThrows() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + await synchronizer.EnterAsync(); + await synchronizer.ExitAsync(); + + Func act = () => synchronizer.ExitAsync(); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task DisposeAsync_CleansUpUnexitedLock_AllowsReentryViaNewSynchronizer() + { + var cache = new FakeHybridCache(); + var synchronizer = new HybridCacheSynchronizer(cache); + await synchronizer.EnterAsync(); + + await synchronizer.DisposeAsync(); + + var other = new HybridCacheSynchronizer(cache); + (await other.EnterAsync()).Should().BeTrue(); + } + + [Test] + public async Task DisposeAsync_WithNoActiveLocks_DoesNotThrow() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + Func act = async () => await synchronizer.DisposeAsync(); + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task EnterAsync_UsesCustomOptions_WhenSet() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()) + { + Options = new HybridCacheEntryOptions { LocalExpiration = TimeSpan.FromMinutes(5) } + }; + + (await synchronizer.EnterAsync()).Should().BeTrue(); + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/SynchronizedTimerHostedServiceBaseTests.cs b/tests/CoreEx.Test.Unit/Hosting/SynchronizedTimerHostedServiceBaseTests.cs new file mode 100644 index 00000000..14050d0a --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/SynchronizedTimerHostedServiceBaseTests.cs @@ -0,0 +1,228 @@ +using CoreEx.Hosting; +using CoreEx.Hosting.Synchronization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class SynchronizedTimerHostedServiceBaseTests +{ + private static ServiceProvider CreateServiceProvider(TestSynchronizer synchronizer, IConfiguration? configuration = null) + { + var sc = new ServiceCollection(); + sc.AddSingleton(configuration ?? new ConfigurationBuilder().Build()); + sc.AddExecutionContext(); + sc.AddSingleton(synchronizer); + return sc.BuildServiceProvider(); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + [Test] + public async Task SynchronizedExecuteAsync_IsInvoked_WhenEnterSucceeds() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = true }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) { Interval = TimeSpan.FromMilliseconds(20), FirstInterval = TimeSpan.FromMilliseconds(5) }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => svc.ExecuteCount > 0, TimeSpan.FromSeconds(5)); + svc.ExecuteCount.Should().BeGreaterThan(0); + synchronizer.EnterCount.Should().BeGreaterThan(0); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task SynchronizedExecuteAsync_NotInvoked_WhenEnterFails() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = false }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) { Interval = TimeSpan.FromMilliseconds(20), FirstInterval = TimeSpan.FromMilliseconds(5) }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => synchronizer.EnterCount > 0, TimeSpan.FromSeconds(5)); + await Task.Delay(100); // Give any (incorrect) execution a chance to occur. + svc.ExecuteCount.Should().Be(0); + synchronizer.ExitCount.Should().Be(0); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task ExitAsync_IsCalled_EvenWhenSynchronizedExecuteThrows() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = true }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) + { + Interval = TimeSpan.FromMilliseconds(20), + FirstInterval = TimeSpan.FromMilliseconds(5), + ThrowOnExecute = true + }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => synchronizer.ExitCount > 0, TimeSpan.FromSeconds(5)); + synchronizer.ExitCount.Should().BeGreaterThan(0); + synchronizer.EnterCount.Should().Be(synchronizer.ExitCount); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task SynchronizerName_DefaultsToNull() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = true }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) { Interval = TimeSpan.FromMilliseconds(20), FirstInterval = TimeSpan.FromMilliseconds(5) }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => synchronizer.EnterCount > 0, TimeSpan.FromSeconds(5)); + synchronizer.LastEnterName.Should().BeNull(); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task SynchronizerName_WhenSet_IsPassedToEnterAndExit() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = true }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) + { + Interval = TimeSpan.FromMilliseconds(20), + FirstInterval = TimeSpan.FromMilliseconds(5), + SynchronizerNameOverride = "custom-name" + }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => synchronizer.ExitCount > 0, TimeSpan.FromSeconds(5)); + synchronizer.LastEnterName.Should().Be("custom-name"); + synchronizer.LastExitName.Should().Be("custom-name"); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task EnterAndExit_UseSelfTypeAsLockType() + { + var synchronizer = new TestSynchronizer { ShouldEnterSucceed = true }; + using var sp = CreateServiceProvider(synchronizer); + var svc = new TestSynchronizedTimerService(sp, NullLogger.Instance) { Interval = TimeSpan.FromMilliseconds(20), FirstInterval = TimeSpan.FromMilliseconds(5) }; + + await svc.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => synchronizer.ExitCount > 0, TimeSpan.FromSeconds(5)); + synchronizer.LastEnterType.Should().Be(typeof(TestSynchronizedTimerService)); + synchronizer.LastExitType.Should().Be(typeof(TestSynchronizedTimerService)); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + private class TestSynchronizer : ISynchronizer + { + private int _enterCount; + private int _exitCount; + + public bool ShouldEnterSucceed { get; set; } + + public int EnterCount => _enterCount; + + public int ExitCount => _exitCount; + + public string? LastEnterName { get; private set; } + + public string? LastExitName { get; private set; } + + public Type? LastEnterType { get; private set; } + + public Type? LastExitType { get; private set; } + + public Task EnterAsync(string? name = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _enterCount); + LastEnterName = name; + LastEnterType = typeof(T); + return Task.FromResult(ShouldEnterSucceed); + } + + public Task ExitAsync(string? name = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _exitCount); + LastExitName = name; + LastExitType = typeof(T); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private class TestSynchronizedTimerService(IServiceProvider serviceProvider, ILogger logger) + : SynchronizedTimerHostedServiceBase(serviceProvider, logger) + { + private int _executeCount; + + public int ExecuteCount => _executeCount; + + public bool ThrowOnExecute { get; set; } + + public string? SynchronizerNameOverride + { + get => SynchronizerName; + set => SynchronizerName = value; + } + + protected override Task SynchronizedExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _executeCount); + + if (ThrowOnExecute) + throw new InvalidOperationException("Test failure."); + + return Task.FromResult(false); + } + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs b/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs index f0088c9c..6a632f06 100644 --- a/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs +++ b/tests/CoreEx.Test.Unit/Hosting/Work/WorkOrchestratorTests.cs @@ -1,5 +1,6 @@ using CoreEx.Hosting.Work; using CoreEx.Caching; +using CoreEx.Security; namespace CoreEx.Test.Unit.Hosting.Work; @@ -89,7 +90,7 @@ public async Task Orchestrate_End_To_End() ws.Should().BeNull(); // Get correct type with same id, but different user. - ExecutionContext.Current.User = Security.AuthenticationUser.Anonymous; + ExecutionContext.Current.User = AuthenticationUser.Anonymous; ws = await o.GetWithTypeAsync("Test-Work", "abc"); ws.Should().BeNull(); } diff --git a/tests/CoreEx.Test.Unit/Http/ProblemDetailsExceptionTests.cs b/tests/CoreEx.Test.Unit/Http/ProblemDetailsExceptionTests.cs new file mode 100644 index 00000000..c1004fa6 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Http/ProblemDetailsExceptionTests.cs @@ -0,0 +1,113 @@ +using CoreEx.Http; +using CoreEx.Http.Abstractions; +using System.Net; + +namespace CoreEx.Test.Unit.Http; + +[TestFixture] +public class ProblemDetailsExceptionTests +{ + [Test] + public void Constructor_UsesTitleOrDetailAsMessage() + { + var pdWithTitle = new ProblemDetails { Title = "My Title", Detail = "My Detail" }; + var ex1 = new ProblemDetailsException(pdWithTitle, null); + ex1.Message.Should().Be("My Title"); + + var pdNoTitle = new ProblemDetails { Detail = "Only Detail" }; + var ex2 = new ProblemDetailsException(pdNoTitle, null); + ex2.Message.Should().Be("Only Detail"); + } + + [Test] + public void ToException_MapsStandardProperties() + { + var pd = new ProblemDetails + { + Title = "err", + Detail = "some detail", + Status = (int)HttpStatusCode.Conflict, + ErrorType = "custom-type", + ErrorCode = "CODE1" + }; + var pde = new ProblemDetailsException(pd, null); + + var ex = pde.ToException(); + + ex.Detail.Should().Be("some detail"); + ex.StatusCode.Should().Be(HttpStatusCode.Conflict); + ex.ErrorType.Should().Be("custom-type"); + ex.ErrorCode.Should().Be("CODE1"); + } + + [Test] + public void ToException_ExtensionsDictionary_MapsErrorCodeAndTypeAndOthers() + { + var pd = new ProblemDetails + { + Title = "err", + Extensions = new Dictionary + { + { HttpNames.ErrorCodeName, "FROM-EXT-CODE" }, + { HttpNames.ErrorTypeName, "from-ext-type" }, + { "custom", "value" } + } + }; + var pde = new ProblemDetailsException(pd, null); + + var ex = pde.ToException(); + + ex.ErrorCode.Should().Be("FROM-EXT-CODE"); + ex.ErrorType.Should().Be("from-ext-type"); + ex.Extensions.Should().ContainKey("custom").WhoseValue.Should().Be("value"); + ex.Extensions.Should().NotContainKey(HttpNames.ErrorCodeName); + ex.Extensions.Should().NotContainKey(HttpNames.ErrorTypeName); + } + + [Test] + public void TryGetBusinessException_WhenErrorTypeMatches_ReturnsTrueAndException() + { + var pd = new ProblemDetails { Title = "biz error", ErrorType = BusinessException.BusinessErrorType }; + var pde = new ProblemDetailsException(pd, null); + + var result = pde.TryGetBusinessException(out var ex); + + result.Should().BeTrue(); + ex.Should().NotBeNull(); + ex!.Message.Should().Be("biz error"); + } + + [Test] + public void TryGetBusinessException_WhenErrorTypeDoesNotMatch_ReturnsFalse() + { + var pd = new ProblemDetails { Title = "other error", ErrorType = "validation" }; + var pde = new ProblemDetailsException(pd, null); + + var result = pde.TryGetBusinessException(out var ex); + + result.Should().BeFalse(); + ex.Should().BeNull(); + } + + [Test] + public void ThrowOnBusinessException_WhenBusinessError_Throws() + { + var pd = new ProblemDetails { Title = "biz error", ErrorType = BusinessException.BusinessErrorType }; + var pde = new ProblemDetailsException(pd, null); + + Action act = () => pde.ThrowOnBusinessException(); + + act.Should().Throw().WithMessage("biz error"); + } + + [Test] + public void ThrowOnBusinessException_WhenNotBusinessError_ReturnsSelf_NoThrow() + { + var pd = new ProblemDetails { Title = "other", ErrorType = "validation" }; + var pde = new ProblemDetailsException(pd, null); + + var result = pde.ThrowOnBusinessException(); + + result.Should().BeSameAs(pde); + } +} diff --git a/tests/CoreEx.Test.Unit/Mapping/BiDirectionMapperTests.cs b/tests/CoreEx.Test.Unit/Mapping/BiDirectionMapperTests.cs new file mode 100644 index 00000000..078633dd --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/BiDirectionMapperTests.cs @@ -0,0 +1,90 @@ +using CoreEx.Entities; +using CoreEx.Mapping; + +namespace CoreEx.Test.Unit.Mapping; + +[TestFixture] +public class BiDirectionMapperTests +{ + private class Person : IIdentifier, IETag + { + public Guid Id { get; set; } + public string? Name { get; set; } + public string? ETag { get; set; } + } + + private class PersonDto : IIdentifier, IETag + { + public Guid Id { get; set; } + public string? FullName { get; set; } + public string? ETag { get; set; } + } + + private class TestBiDirectionMapper : BiDirectionMapper + { + protected override PersonDto OnMap(Person source) => new() { FullName = source.Name }; + protected override Person OnMap(PersonDto source) => new() { Name = source.FullName }; + } + + [Test] + public void To_MapsCustomAndStandardProperties() + { + var mapper = new TestBiDirectionMapper(); + var person = new Person { Id = Guid.NewGuid(), Name = "Bob", ETag = "etag1" }; + + var dto = mapper.To.Map(person); + + dto!.FullName.Should().Be("Bob"); + dto.Id.Should().Be(person.Id); + dto.ETag.Should().Be("etag1"); + } + + [Test] + public void From_MapsCustomAndStandardProperties() + { + var mapper = new TestBiDirectionMapper(); + var dto = new PersonDto { Id = Guid.NewGuid(), FullName = "Alice", ETag = "etag2" }; + + var person = mapper.From.Map(dto); + + person!.Name.Should().Be("Alice"); + person.Id.Should().Be(dto.Id); + person.ETag.Should().Be("etag2"); + } + + [Test] + public void To_NullSource_ReturnsNull() + { + var mapper = new TestBiDirectionMapper(); + mapper.To.Map(null).Should().BeNull(); + } + + [Test] + public void From_NullSource_ReturnsNull() + { + var mapper = new TestBiDirectionMapper(); + mapper.From.Map(null).Should().BeNull(); + } + + [Test] + public void To_NonGenericMapperBridge_MapsValue() + { + var mapper = new TestBiDirectionMapper(); + var person = new Person { Id = Guid.NewGuid(), Name = "Charlie" }; + + var result = ((IMapper)mapper.To).Map(person); + + result.Should().BeOfType(); + ((PersonDto)result!).FullName.Should().Be("Charlie"); + } + + [Test] + public void SourceAndDestinationTypes_AreCorrect() + { + var mapper = new TestBiDirectionMapper(); + ((IMapperBase)mapper.To).SourceType.Should().Be(); + ((IMapperBase)mapper.To).DestinationType.Should().Be(); + ((IMapperBase)mapper.From).SourceType.Should().Be(); + ((IMapperBase)mapper.From).DestinationType.Should().Be(); + } +} diff --git a/tests/CoreEx.Test.Unit/Mapping/IntoMapperTests.cs b/tests/CoreEx.Test.Unit/Mapping/IntoMapperTests.cs new file mode 100644 index 00000000..0dc01b3c --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/IntoMapperTests.cs @@ -0,0 +1,110 @@ +using CoreEx.Entities; +using CoreEx.Mapping; + +namespace CoreEx.Test.Unit.Mapping; + +[TestFixture] +public class IntoMapperTests +{ + private class Person : IIdentifier, IETag + { + public Guid Id { get; set; } + public string? Name { get; set; } + public string? ETag { get; set; } + } + + private class PersonDto : IIdentifier, IETag + { + public Guid Id { get; set; } + public string? FullName { get; set; } + public string? ETag { get; set; } + } + + private class TestIntoMapper : IntoMapper + { + protected override void OnMapInto(Person source, PersonDto destination) => destination.FullName = source.Name; + } + + private class NoStandardIntoMapper : IntoMapper + { + protected override bool UseMapStandardInto => false; + + protected override void OnMapInto(Person source, PersonDto destination) => destination.FullName = source.Name; + } + + [Test] + public void MapInto_MapsCustomAndStandardProperties() + { + var mapper = new TestIntoMapper(); + var person = new Person { Id = Guid.NewGuid(), Name = "Bob", ETag = "etag1" }; + var dto = new PersonDto(); + + mapper.MapInto(person, dto); + + dto.FullName.Should().Be("Bob"); + dto.Id.Should().Be(person.Id); + dto.ETag.Should().Be("etag1"); + } + + [Test] + public void MapInto_UseMapStandardIntoFalse_SkipsStandardProperties() + { + var mapper = new NoStandardIntoMapper(); + var person = new Person { Id = Guid.NewGuid(), Name = "Bob", ETag = "etag1" }; + var dto = new PersonDto(); + + mapper.MapInto(person, dto); + + dto.FullName.Should().Be("Bob"); + dto.Id.Should().Be(Guid.Empty); + dto.ETag.Should().BeNull(); + } + + [Test] + public void MapInto_NullSource_Throws() + { + var mapper = new TestIntoMapper(); + Action act = () => mapper.MapInto(null!, new PersonDto()); + act.Should().Throw(); + } + + [Test] + public void MapInto_NullDestination_Throws() + { + var mapper = new TestIntoMapper(); + Action act = () => mapper.MapInto(new Person(), null!); + act.Should().Throw(); + } + + [Test] + public void NonGenericMapperBridge_MapsValue() + { + var mapper = new TestIntoMapper(); + var person = new Person { Id = Guid.NewGuid(), Name = "Charlie" }; + var dto = new PersonDto(); + + ((IIntoMapper)mapper).MapInto(person, dto); + + dto.FullName.Should().Be("Charlie"); + } + + [Test] + public void SourceAndDestinationTypes_AreCorrect() + { + var mapper = new TestIntoMapper(); + ((IMapperBase)mapper).SourceType.Should().Be(); + ((IMapperBase)mapper).DestinationType.Should().Be(); + } + + [Test] + public void MapperCreateInto_CreatesOneOffIntoMapper() + { + var mapper = Mapper.CreateInto((s, d) => d.FullName = s.Name); + var person = new Person { Name = "Dana" }; + var dto = new PersonDto(); + + mapper.MapInto(person, dto); + + dto.FullName.Should().Be("Dana"); + } +} diff --git a/tests/CoreEx.Test.Unit/RuntimeTests.cs b/tests/CoreEx.Test.Unit/RuntimeTests.cs new file mode 100644 index 00000000..b927b9e7 --- /dev/null +++ b/tests/CoreEx.Test.Unit/RuntimeTests.cs @@ -0,0 +1,52 @@ +namespace CoreEx.Test.Unit; + +[TestFixture] +public class RuntimeTests +{ + [TearDown] + public void TearDown() => ExecutionContext.Reset(); + + [Test] + public void UtcNow_NoCurrentExecutionContext_ReturnsSystemTime() + { + ExecutionContext.HasCurrent.Should().BeFalse(); + + var before = DateTimeOffset.UtcNow; + var result = global::CoreEx.Runtime.UtcNow; + var after = DateTimeOffset.UtcNow; + + result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + } + + [Test] + public void UtcNow_WithCurrentExecutionContext_ReturnsItsTimestamp() + { + var fixedTime = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero); + var ec = new ExecutionContext { Timestamp = fixedTime }; + ExecutionContext.SetCurrent(ec); + + global::CoreEx.Runtime.UtcNow.Should().Be(fixedTime); + } + + [Test] + public void NewGuid_ReturnsNonEmptyGuid() + { + var guid = global::CoreEx.Runtime.NewGuid(); + guid.Should().NotBe(Guid.Empty); + } + + [Test] + public void NewGuid_ReturnsDistinctValues() + { + var g1 = global::CoreEx.Runtime.NewGuid(); + var g2 = global::CoreEx.Runtime.NewGuid(); + g1.Should().NotBe(g2); + } + + [Test] + public void NewId_ReturnsGuidFormattedString() + { + var id = global::CoreEx.Runtime.NewId(); + Guid.TryParse(id, out _).Should().BeTrue(); + } +} diff --git a/tests/CoreEx.Test.Unit/Security/AuthenticationUserTests.cs b/tests/CoreEx.Test.Unit/Security/AuthenticationUserTests.cs new file mode 100644 index 00000000..b8da7e08 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Security/AuthenticationUserTests.cs @@ -0,0 +1,96 @@ +using CoreEx.Entities; +using CoreEx.Security; + +namespace CoreEx.Test.Unit.Security; + +[TestFixture] +public class AuthenticationUserTests +{ + [TearDown] + public void TearDown() + { + // These statics are settable; reset to defaults to avoid cross-test leakage. + AuthenticationUser.Unknown = new AuthenticationUser { Type = AuthenticationType.Unknown, UserName = nameof(AuthenticationUser.Unknown) }; + AuthenticationUser.Anonymous = new AuthenticationUser { Type = AuthenticationType.Unauthenticated, UserName = nameof(AuthenticationUser.Anonymous) }; + } + + [Test] + public void Unknown_HasExpectedDefaults() + { + AuthenticationUser.Unknown.Type.Should().Be(AuthenticationType.Unknown); + AuthenticationUser.Unknown.UserName.Should().Be("Unknown"); + AuthenticationUser.Unknown.Id.Should().BeNull(); + } + + [Test] + public void Anonymous_HasExpectedDefaults() + { + AuthenticationUser.Anonymous.Type.Should().Be(AuthenticationType.Unauthenticated); + AuthenticationUser.Anonymous.UserName.Should().Be("Anonymous"); + } + + [Test] + public void EnvironmentUser_HasExpectedDefaults() + { + AuthenticationUser.EnvironmentUser.Type.Should().Be(AuthenticationType.AccountUser); + AuthenticationUser.EnvironmentUser.UserName.Should().NotBeNullOrEmpty(); + AuthenticationUser.EnvironmentUser.Id.Should().Be(AuthenticationUser.EnvironmentUser.UserName); + } + + [Test] + public void Statics_AreSettable_AndOverridable() + { + var custom = new AuthenticationUser { Type = AuthenticationType.SystemUser, UserName = "svc-account" }; + AuthenticationUser.Unknown = custom; + + AuthenticationUser.Unknown.Should().BeSameAs(custom); + } + + [Test] + public void ToString_ReturnsUserName() + { + var user = new AuthenticationUser { Type = AuthenticationType.AccountUser, UserName = "jdoe" }; + user.ToString().Should().Be("jdoe"); + } + + [Test] + public void UserName_NullOrEmpty_Throws() + { + Action act = () => new AuthenticationUser { Type = AuthenticationType.AccountUser, UserName = null! }; + act.Should().Throw(); + + Action act2 = () => new AuthenticationUser { Type = AuthenticationType.AccountUser, UserName = string.Empty }; + act2.Should().Throw(); + } + + [Test] + public void RecordEquality_IsStructural() + { + var user1 = new AuthenticationUser { Type = AuthenticationType.AccountUser, Id = "1", UserName = "jdoe" }; + var user2 = new AuthenticationUser { Type = AuthenticationType.AccountUser, Id = "1", UserName = "jdoe" }; + var user3 = new AuthenticationUser { Type = AuthenticationType.AccountUser, Id = "2", UserName = "jdoe" }; + + user1.Should().Be(user2); + user1.Should().NotBe(user3); + } + + [Test] + public void IsReadOnlyIdentifier_ExposesIdAndEntityKey() + { + IReadOnlyIdentifier user = new AuthenticationUser { Type = AuthenticationType.AccountUser, Id = "abc", UserName = "jdoe" }; + + user.Id.Should().Be("abc"); + ((IEntityKey)user).EntityKey.Should().Be(CompositeKey.Create("abc")); + } + + [TestCase(AuthenticationType.Unknown)] + [TestCase(AuthenticationType.Unauthenticated)] + [TestCase(AuthenticationType.ApplicationUser)] + [TestCase(AuthenticationType.AccountUser)] + [TestCase(AuthenticationType.SystemUser)] + public void AuthenticationType_AllValues_AssignableToUser(AuthenticationType type) + { + var user = new AuthenticationUser { Type = type, UserName = "test" }; + user.Type.Should().Be(type); + } +} From f028ae8a3fd1c87df2e5a01529108fed37ef183a Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 5 Aug 2026 13:34:54 -0700 Subject: [PATCH 4/5] Make reference data collections thread-safe & add tests Refactored ReferenceDataCollectionCore to use ConcurrentDictionary for thread-safe mappings. Updated GetById/GetByCode to return null if not found. Improved ReferenceDataCodeCollection.Contains and CopyTo logic. Fixed method/variable names in ReferenceDataHybridCache.TypedInvoker. Added comprehensive unit tests for collections, context, hybrid cache, and DI extensions, covering concurrency, mapping, cache registration, and health checks. Enhanced edge case and error handling coverage. --- .../ReferenceDataCollectionCore.cs | 10 +- .../ReferenceDataCodeCollection.cs | 15 +- .../ReferenceDataHybridCache.TypedInvoker.cs | 12 +- .../CoreExReferenceDataExtensionsTests.cs | 155 ++++++++++++++++++ .../ReferenceDataCodeCollectionTests.cs | 152 +++++++++++++++++ .../ReferenceDataCollectionTests.cs | 77 +++++++++ .../ReferenceDataContextTests.cs | 100 +++++++++++ .../ReferenceDataHybridCacheTests.cs | 145 ++++++++++++++++ ...ferenceDataOrchestratorHealthCheckTests.cs | 27 +++ 9 files changed, 680 insertions(+), 13 deletions(-) create mode 100644 tests/CoreEx.RefData.Test.Unit/CoreExReferenceDataExtensionsTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataCodeCollectionTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataContextTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataHybridCacheTests.cs create mode 100644 tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorHealthCheckTests.cs diff --git a/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs index 6c4ad7e0..5ba2f7fc 100644 --- a/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs +++ b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs @@ -17,7 +17,7 @@ public abstract class ReferenceDataCollectionCore : IReferenceDataCol #endif private readonly ConcurrentDictionary _rdcId = new(); private readonly ConcurrentDictionary _rdcCode; - private Dictionary<(string, object?), TRef>? _mappingsDict; + private ConcurrentDictionary<(string, object?), TRef>? _mappingsDict; /// /// Initializes a new instance of the class. @@ -75,7 +75,7 @@ public void Add(TRef item) if (item.HasMappings) { - _mappingsDict ??= []; + _mappingsDict ??= new(); // Make sure there are no duplicates. foreach (var map in item.Mappings!) @@ -87,7 +87,7 @@ public void Add(TRef item) // Now add 'em in. foreach (var map in item.Mappings) { - _mappingsDict.Add((map.Key, map.Value), item); + _mappingsDict.TryAdd((map.Key, map.Value), item); } } @@ -156,7 +156,7 @@ public bool TryGetById(TId id, [NotNullWhen(true)] out TRef? item) } /// - public TRef? GetById(TId id) => id is null ? default : _rdcId[id]; + public TRef? GetById(TId id) => id is null ? default : _rdcId.TryGetValue(id, out var item) ? item : default; /// public bool ContainsCode(string code) => _rdcCode.ContainsKey(code); @@ -172,7 +172,7 @@ public bool TryGetByCode(string code, [NotNullWhen(true)] out TRef? item) } /// - public TRef? GetByCode(string code) => code is null ? default : _rdcCode[code]; + public TRef? GetByCode(string code) => code is null ? default : _rdcCode.TryGetValue(code, out var item) ? item : default; /// public bool ContainsMapping(string name, T value) where T : IComparable, IEquatable => _mappingsDict is not null && _mappingsDict.ContainsKey((name, value)); diff --git a/src/CoreEx.RefData/ReferenceDataCodeCollection.cs b/src/CoreEx.RefData/ReferenceDataCodeCollection.cs index f4076abb..09209d6c 100644 --- a/src/CoreEx.RefData/ReferenceDataCodeCollection.cs +++ b/src/CoreEx.RefData/ReferenceDataCodeCollection.cs @@ -55,10 +55,21 @@ namespace CoreEx.RefData; public void Clear() => _codes.Clear(); /// - public bool Contains(TRef item) => ((IList)_codes).Contains(item); + public bool Contains(TRef item) => _codes.Contains(item?.Code); /// - public void CopyTo(TRef[] array, int arrayIndex) => ((IList)_codes).CopyTo(array, arrayIndex); + public void CopyTo(TRef[] array, int arrayIndex) + { + array.ThrowIfNull(); + if (arrayIndex < 0 || arrayIndex + Count > array.Length) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + + var i = arrayIndex; + foreach (var item in this) + { + array[i++] = item; + } + } /// public IEnumerator GetEnumerator() diff --git a/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs b/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs index 349374e2..26bd0d32 100644 --- a/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs +++ b/src/CoreEx.RefData/ReferenceDataHybridCache.TypedInvoker.cs @@ -9,7 +9,7 @@ public partial class ReferenceDataHybridCache * This functionality is required as the underlying cache *may* leverage serialization, and as such, we have to get it in a typed manner as IReferenceDataCollection (interface) is not valid. */ - private static readonly MethodInfo TryGetByKeyAsync_OpenGeneric = typeof(IHybridCache).GetMethod(nameof(IHybridCache.TryGetByKeyAsync)) ?? throw new InvalidOperationException($"{nameof(IHybridCache)}.{nameof(IHybridCache.TryGetByKeyAsync)} public instance method not found."); + private static readonly MethodInfo _tryGetByKeyAsync_OpenGeneric = typeof(IHybridCache).GetMethod(nameof(IHybridCache.TryGetByKeyAsync)) ?? throw new InvalidOperationException($"{nameof(IHybridCache)}.{nameof(IHybridCache.TryGetByKeyAsync)} public instance method not found."); private static readonly ConcurrentDictionary _invokers = new(); private delegate Task<(bool Exists, object? Value)> TryGetByKeyInvoker(IHybridCache cache, string key, HybridCacheEntryOptions options, CancellationToken cancellationToken); @@ -20,7 +20,7 @@ public partial class ReferenceDataHybridCache private static TryGetByKeyInvoker GetInvokerForType(Type type) => _invokers.GetOrAdd(type, type => { // Close the generic: TryGetByKeyAsync - var closed = TryGetByKeyAsync_OpenGeneric.MakeGenericMethod(type); + var closed = _tryGetByKeyAsync_OpenGeneric.MakeGenericMethod(type); // Parameters: (cache, key, options, cancellationToken) => var cacheParam = Expression.Parameter(typeof(IHybridCache), "cache"); @@ -31,8 +31,8 @@ private static TryGetByKeyInvoker GetInvokerForType(Type type) => _invokers.GetO // Expression: cache.TryGetByKeyAsync(key, options, ct) var call = Expression.Call(cacheParam, closed, keyParam, optParam, ctParam); - // Build method body: ToTupleTask(call). - var method = typeof(ReferenceDataHybridCache).GetMethod(nameof(ToTupleTask), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type); + // Build method body: ToTupleAsync(call). + var method = typeof(ReferenceDataHybridCache).GetMethod(nameof(ToTupleAsync), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(type); var body = Expression.Call(method, call); var lambda = Expression.Lambda(body, cacheParam, keyParam, optParam, ctParam); return lambda.Compile(); @@ -41,5 +41,5 @@ private static TryGetByKeyInvoker GetInvokerForType(Type type) => _invokers.GetO /// /// Underlying method to invoke the typed . /// - private static async Task<(bool Exists, object? Value)> ToTupleTask(Task<(bool Exists, T? Value)> task) => await task.ConfigureAwait(false); -} \ No newline at end of file + private static async Task<(bool Exists, object? Value)> ToTupleAsync(Task<(bool Exists, T? Value)> task) => await task.ConfigureAwait(false); +} diff --git a/tests/CoreEx.RefData.Test.Unit/CoreExReferenceDataExtensionsTests.cs b/tests/CoreEx.RefData.Test.Unit/CoreExReferenceDataExtensionsTests.cs new file mode 100644 index 00000000..44907515 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/CoreExReferenceDataExtensionsTests.cs @@ -0,0 +1,155 @@ +using CoreEx.RefData.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace CoreEx.RefData.Test.Unit; + +public class CoreExReferenceDataExtensionsTests +{ + private class DummyProvider : IReferenceDataProvider + { + public IEnumerable<(Type, Type)> Types => []; + + public Task GetAsync(Type type, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private static ServiceCollection CreateBaseServices() + { + var sc = new ServiceCollection(); + sc.AddSingleton(Mock.Of>()); + return sc; + } + + private static ReferenceDataOrchestrator DefaultFactory(IServiceProvider sp) => new(sp, sp.GetRequiredService>()); + + [Test] + public void WithFactory_RegistersSingleton() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory); + using var sp = sc.BuildServiceProvider(); + + var o1 = sp.GetRequiredService(); + var o2 = sp.GetRequiredService(); + o1.Should().BeSameAs(o2); + } + + [Test] + public void FactoryReturnsNull_ThrowsOnResolve() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(_ => null!); + using var sp = sc.BuildServiceProvider(); + + Action act = () => sp.GetRequiredService(); + act.Should().Throw().WithMessage("*factory returned a null*"); + } + + [Test] + public void AutoRegistersDefaultQuery_WhenNoneRegistered() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory); + using var sp = sc.BuildServiceProvider(); + + var orch = sp.GetRequiredService(); + orch.HasRegisteredQuery.Should().BeTrue(); + } + + [Test] + public void DoesNotOverrideExistingQuery() + { + var sc = CreateBaseServices(); + var customQuery = new ReferenceDataQuery(); + sc.AddReferenceDataOrchestrator(sp => DefaultFactory(sp).RegisterQuery(customQuery)); + using var sp = sc.BuildServiceProvider(); + + var orch = sp.GetRequiredService(); + orch.HasRegisteredQuery.Should().BeTrue(); + } + + [Test] + public void HealthCheckTrue_RegistersHealthCheck() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory, healthCheck: true); + using var sp = sc.BuildServiceProvider(); + + var options = sp.GetRequiredService>().Value; + options.Registrations.Should().Contain(r => r.Name == "reference-data-orchestrator"); + } + + [Test] + public void HealthCheckFalse_DoesNotRegisterHealthCheck() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory, healthCheck: false); + using var sp = sc.BuildServiceProvider(); + + var options = sp.GetService>(); + (options?.Value.Registrations.Any(r => r.Name == "reference-data-orchestrator") ?? false).Should().BeFalse(); + } + + [Test] + public void CustomHealthCheckName_IsUsed() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory, healthCheckName: "custom-name"); + using var sp = sc.BuildServiceProvider(); + + var options = sp.GetRequiredService>().Value; + options.Registrations.Should().Contain(r => r.Name == "custom-name"); + } + + [Test] + public void NoHybridCacheRegistered_FallsBackToMemoryOnly() + { + var sc = CreateBaseServices(); + sc.AddReferenceDataOrchestrator(DefaultFactory); + using var sp = sc.BuildServiceProvider(); + using var scope = sp.CreateScope(); + + var cache = scope.ServiceProvider.GetRequiredService(); + cache.Should().BeOfType(); + ((ReferenceDataHybridCache)cache).Cache.Should().BeOfType(); + } + + [Test] + public void ExistingReferenceDataCache_IsNotOverridden() + { + var sc = CreateBaseServices(); + var customCache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + sc.AddScoped(_ => customCache); + sc.AddReferenceDataOrchestrator(DefaultFactory); + using var sp = sc.BuildServiceProvider(); + using var scope = sp.CreateScope(); + + var cache = scope.ServiceProvider.GetRequiredService(); + cache.Should().BeSameAs(customCache); + } + + [Test] + public void GenericProviderOverload_UsesRegisteredProvider() + { + var sc = CreateBaseServices(); + sc.AddScoped(); + sc.AddReferenceDataOrchestrator(); + using var sp = sc.BuildServiceProvider(); + + sp.GetRequiredService().Should().NotBeNull(); + } + + [Test] + public void NoProviderOverload_UsesRegisteredIReferenceDataProvider() + { + var sc = CreateBaseServices(); + sc.AddScoped(); + sc.AddReferenceDataOrchestrator(); + using var sp = sc.BuildServiceProvider(); + + sp.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataCodeCollectionTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCodeCollectionTests.cs new file mode 100644 index 00000000..7e088d60 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCodeCollectionTests.cs @@ -0,0 +1,152 @@ +namespace CoreEx.RefData.Test.Unit; + +public partial class ReferenceDataOrchestratorTests +{ + [Test] + public void Add_And_Count() + { + var coll = new ReferenceDataCodeCollection + { + new DummyRefData { Code = "A" }, + new DummyRefData { Code = "B" } + }; + + coll.Count.Should().Be(2); + } + + [Test] + public void Contains_ExistingCode_ReturnsTrue() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" } }; + coll.Contains(new DummyRefData { Code = "A" }).Should().BeTrue(); + } + + [Test] + public void Contains_MissingCode_ReturnsFalse() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" } }; + coll.Contains(new DummyRefData { Code = "Z" }).Should().BeFalse(); + } + + [Test] + public void CopyTo_PopulatesArrayWithResolvedItems() + { + var coll = new ReferenceDataCodeCollection + { + new DummyRefData { Code = "A" }, + new DummyRefData { Code = "B" } + }; + + var array = new DummyRefData[2]; + coll.CopyTo(array, 0); + + array.Select(x => x.Code).Should().BeEquivalentTo("A", "B"); + array.Select(x => x.Text).Should().BeEquivalentTo("Alpha", "Beta"); + } + + [Test] + public void CopyTo_ArrayTooSmall_Throws() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" }, new DummyRefData { Code = "B" } }; + var array = new DummyRefData[1]; + Action act = () => coll.CopyTo(array, 0); + act.Should().Throw(); + } + + [Test] + public void IndexOf_ReturnsCorrectIndex() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" }, new DummyRefData { Code = "B" } }; + coll.IndexOf(new DummyRefData { Code = "B" }).Should().Be(1); + } + + [Test] + public void Remove_RemovesByCode() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" }, new DummyRefData { Code = "B" } }; + coll.Remove(new DummyRefData { Code = "A" }).Should().BeTrue(); + coll.Count.Should().Be(1); + coll.ToCodeList().Should().BeEquivalentTo(["B"]); + } + + [Test] + public void GetEnumerator_ResolvesItemsViaOrchestrator() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" }, new DummyRefData { Code = "C" } }; + coll.Select(x => x.Text).Should().BeEquivalentTo("Alpha", "Charlie"); + } + + [Test] + public void HasInvalidItems_WhenCodeNotRegistered_ReturnsTrue() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "unknown-code" } }; + coll.HasInvalidItems.Should().BeTrue(); + } + + [Test] + public void HasInvalidItems_AllCodesValid_ReturnsFalse() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" } }; + coll.HasInvalidItems.Should().BeFalse(); + } + + [Test] + public void HasInactiveItems_WhenItemInactive_ReturnsTrue() + { + // DummyRefData Code "D" is registered as IsInactive = true. + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "D" } }; + coll.HasInactiveItems.Should().BeTrue(); + } + + [Test] + public void ToCodeList_ReturnsCodesInOrder() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" }, new DummyRefData { Code = "B" } }; + coll.ToCodeList().Should().Equal("A", "B"); + } + + [Test] + public void ToRefDataList_ReturnsResolvedItems() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" } }; + coll.ToRefDataList().Select(x => x.Code).Should().BeEquivalentTo("A"); + } + + [Test] + public void Constructor_WithItems_ExtractsCodes() + { + var items = new[] { new DummyRefData { Code = "A" }, new DummyRefData { Code = "B" } }; + var coll = new ReferenceDataCodeCollection(items); + coll.ToCodeList().Should().Equal("A", "B"); + } + + [Test] + public void Constructor_WithCodesParams_SetsCodes() + { + var coll = new ReferenceDataCodeCollection("A", "B"); + coll.ToCodeList().Should().Equal("A", "B"); + } + + [Test] + public void Constructor_WithRefListCodes_UsesExternalList() + { + List? codes = ["A", "B"]; + var coll = new ReferenceDataCodeCollection(ref codes); + coll.ToCodeList().Should().Equal("A", "B"); + } + + [Test] + public void IsReadOnly_IsFalse() + { + var coll = new ReferenceDataCodeCollection(); + coll.IsReadOnly.Should().BeFalse(); + } + + [Test] + public void Clear_RemovesAll() + { + var coll = new ReferenceDataCodeCollection { new DummyRefData { Code = "A" } }; + coll.Clear(); + coll.Count.Should().Be(0); + } +} diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs index 5d5cb506..1227a6a5 100644 --- a/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataCollectionTests.cs @@ -71,6 +71,83 @@ public void ContainsCode_And_GetByCode() coll.GetByCode("A").Should().Be(item); } + [Test] + public void GetById_NotFound_ReturnsNull() + { + var coll = new ReferenceDataCollection(); + coll.Add(new TestRefData { Id = "1", Code = "A" }); + coll.GetById("missing").Should().BeNull(); + } + + [Test] + public void GetByCode_NotFound_ReturnsNull() + { + var coll = new ReferenceDataCollection(); + coll.Add(new TestRefData { Id = "1", Code = "A" }); + coll.GetByCode("missing").Should().BeNull(); + } + + [Test] + public void ContainsMapping_TryGetByMapping_GetByMapping_Work() + { + var coll = new ReferenceDataCollection(); + var item = new TestRefData { Id = "1", Code = "A" }; + item.SetMapping("ext", 123); + coll.Add(item); + + coll.ContainsMapping("ext", 123).Should().BeTrue(); + coll.ContainsMapping("ext", 999).Should().BeFalse(); + + coll.TryGetByMapping("ext", 123, out var found).Should().BeTrue(); + found.Should().Be(item); + coll.TryGetByMapping("ext", 999, out var notFound).Should().BeFalse(); + notFound.Should().BeNull(); + + coll.GetByMapping("ext", 123).Should().Be(item); + coll.GetByMapping("ext", 999).Should().BeNull(); + } + + [Test] + public void ContainsMapping_NoMappingsRegistered_ReturnsFalse() + { + var coll = new ReferenceDataCollection(); + coll.Add(new TestRefData { Id = "1", Code = "A" }); + coll.ContainsMapping("ext", 123).Should().BeFalse(); + } + + [Test] + public async Task ConcurrentAdd_And_MappingReads_DoNotThrow() + { + var coll = new ReferenceDataCollection(); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + var writer = Task.Run(() => + { + var i = 0; + while (!cts.IsCancellationRequested) + { + i++; + var item = new TestRefData { Id = $"id-{i}", Code = $"code-{i}" }; + item.SetMapping("ext", i); + try { coll.Add(item); } catch (ArgumentException) { /* duplicate under race; ignore for this stress test */ } + } + }); + + var reader1 = Task.Run(() => + { + while (!cts.IsCancellationRequested) + coll.ContainsMapping("ext", 1); + }); + + var reader2 = Task.Run(() => + { + while (!cts.IsCancellationRequested) + coll.TryGetByMapping("ext", 1, out _); + }); + + await Task.WhenAll(writer, reader1, reader2); + } + [Test] public void TryGetById_And_TryGetByCode() { diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataContextTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataContextTests.cs new file mode 100644 index 00000000..ded15ce0 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataContextTests.cs @@ -0,0 +1,100 @@ +namespace CoreEx.RefData.Test.Unit; + +public class ReferenceDataContextTests +{ + private class TypeA { } + private class TypeB { } + + [Test] + public void Date_Default_ReturnsApproximatelyUtcNow() + { + var ctx = new ReferenceDataContext(); + var before = DateTimeOffset.UtcNow; + var date = ctx.Date; + var after = DateTimeOffset.UtcNow; + + date.Should().NotBeNull(); + date!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + } + + [Test] + public void Date_Default_IsStableOnceRead() + { + var ctx = new ReferenceDataContext(); + var first = ctx.Date; + var second = ctx.Date; + second.Should().Be(first); + } + + [Test] + public void Date_Set_ReturnsSetValue() + { + var ctx = new ReferenceDataContext(); + var fixedDate = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero); + ctx.Date = fixedDate; + ctx.Date.Should().Be(fixedDate); + } + + [Test] + public void Indexer_NoTypeSpecificDate_ReturnsDate() + { + var ctx = new ReferenceDataContext(); + var fixedDate = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero); + ctx.Date = fixedDate; + + ctx[typeof(TypeA)].Should().Be(fixedDate); + } + + [Test] + public void Indexer_TypeSpecificDateSet_ReturnsThatDate() + { + var ctx = new ReferenceDataContext { Date = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + var typeDate = new DateTimeOffset(2021, 6, 15, 0, 0, 0, TimeSpan.Zero); + ctx[typeof(TypeA)] = typeDate; + + ctx[typeof(TypeA)].Should().Be(typeDate); + ctx[typeof(TypeB)].Should().Be(ctx.Date); + } + + [Test] + public void Indexer_TypeSpecificDateSetToNull_FallsBackToDate() + { + var fixedDate = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + var ctx = new ReferenceDataContext { Date = fixedDate }; + ctx[typeof(TypeA)] = new DateTimeOffset(2021, 1, 1, 0, 0, 0, TimeSpan.Zero); + + ctx[typeof(TypeA)] = null; + + ctx[typeof(TypeA)].Should().Be(fixedDate); + } + + [Test] + public void Indexer_NullType_Throws() + { + var ctx = new ReferenceDataContext(); + Action act = () => _ = ctx[null!]; + act.Should().Throw(); + } + + [Test] + public void Reset_ClearsDateAndTypeSpecificDates() + { + var ctx = new ReferenceDataContext { Date = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + ctx[typeof(TypeA)] = new DateTimeOffset(2021, 1, 1, 0, 0, 0, TimeSpan.Zero); + + ctx.Reset(); + + var before = DateTimeOffset.UtcNow; + ctx.Date.Should().NotBeNull(); + ctx.Date!.Value.Should().BeOnOrAfter(before.AddSeconds(-2)); + ctx[typeof(TypeA)].Should().Be(ctx.Date); + } + + [Test] + public void ImplementsIReferenceDataContext() + { + IReferenceDataContext ctx = new ReferenceDataContext(); + ctx.Date = DateTimeOffset.UtcNow; + ctx.Date.Should().NotBeNull(); + } +} diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataHybridCacheTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataHybridCacheTests.cs new file mode 100644 index 00000000..fa696e0a --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataHybridCacheTests.cs @@ -0,0 +1,145 @@ +using CoreEx.Caching; +using CoreEx.RefData.Abstractions; + +namespace CoreEx.RefData.Test.Unit; + +public partial class ReferenceDataOrchestratorTests +{ + [Test] + public void Constructor_NullCache_Throws() + { + Action act = () => new ReferenceDataHybridCache(null!); + act.Should().Throw(); + } + + [Test] + public async Task GetOrCreateAsync_CacheMiss_InvokesFactoryAndCaches() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + var callCount = 0; + + Task Factory(Type t, CancellationToken ct) + { + callCount++; + return Task.FromResult(new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } }); + } + + var coll = await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), Factory); + + callCount.Should().Be(1); + coll.Should().BeOfType(); + } + + [Test] + public async Task GetOrCreateAsync_CacheHit_DoesNotInvokeFactoryAgain() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + var callCount = 0; + + Task Factory(Type t, CancellationToken ct) + { + callCount++; + return Task.FromResult(new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } }); + } + + var first = await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), Factory); + var second = await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), Factory); + + callCount.Should().Be(1); + second.Should().BeSameAs(first); + } + + [Test] + public async Task GetOrCreateAsync_FactoryReturnsNull_Throws() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + + Func act = () => cache.GetOrCreateAsync(typeof(DummyRefDataCollection), (t, ct) => Task.FromResult(null!)); + + await act.Should().ThrowAsync().WithMessage("*must not be null*"); + } + + [Test] + public async Task GetOrCreateAsync_ConcurrentCalls_FactoryInvokedOnce() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + var callCount = 0; + + async Task Factory(Type t, CancellationToken ct) + { + Interlocked.Increment(ref callCount); + await Task.Delay(50, ct); + return new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } }; + } + + var tasks = Enumerable.Range(0, 10).Select(_ => cache.GetOrCreateAsync(typeof(DummyRefDataCollection), Factory)); + var results = await Task.WhenAll(tasks); + + callCount.Should().Be(1); + results.Should().OnlyContain(r => ReferenceEquals(r, results[0])); + } + + [Test] + public void RegisterCacheEntryOptions_NotAReferenceDataCollectionType_Throws() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + Action act = () => cache.RegisterCacheEntryOptions(typeof(string), Caching.HybridCacheEntryOptions.CreateForName("x")); + act.Should().Throw(); + } + + [Test] + public void RegisterCacheEntryOptions_NullOptions_Throws() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + Action act = () => cache.RegisterCacheEntryOptions(null!); + act.Should().Throw(); + } + + [Test] + public void RegisterCacheEntryOptions_ValidType_ReturnsSameInstance_ForChaining() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + var result = cache.RegisterCacheEntryOptions(Caching.HybridCacheEntryOptions.CreateForName("x")); + result.Should().BeSameAs(cache); + } + + [Test] + public async Task RegisterCacheEntryOptions_RegisteredOptions_AreUsedByGetOrCreateAsync() + { + var cache = new ReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + var registered = Caching.HybridCacheEntryOptions.CreateForName("custom", TimeSpan.FromMinutes(42)); + cache.RegisterCacheEntryOptions(registered); + + await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), (t, ct) => Task.FromResult(new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } })); + + // OnCreateCacheEntry is only invoked for entries not already registered; since we pre-registered, it should not be overwritten by a default. + cache.RegisterCacheEntryOptions(registered).Should().BeSameAs(cache); + } + + private class TrackingReferenceDataHybridCache(IHybridCache cache) : ReferenceDataHybridCache(cache) + { + public readonly List CreatedFor = []; + + protected override void OnCreateCacheEntry(Type type, Caching.HybridCacheEntryOptions entry) => CreatedFor.Add(type); + } + + [Test] + public async Task OnCreateCacheEntry_InvokedOnce_ForNewType() + { + var cache = new TrackingReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + + await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), (t, ct) => Task.FromResult(new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } })); + await cache.GetOrCreateAsync(typeof(DummyRefDataCollection), (t, ct) => Task.FromResult(new DummyRefDataCollection { new DummyRefData { Id = 1, Code = "A" } })); + + cache.CreatedFor.Should().ContainSingle().Which.Should().Be(typeof(DummyRefDataCollection)); + } + + [Test] + public void OnCreateCacheEntry_NotInvoked_WhenOptionsPreRegistered() + { + var cache = new TrackingReferenceDataHybridCache(new Caching.MemoryOnlyHybridCache()); + cache.RegisterCacheEntryOptions(Caching.HybridCacheEntryOptions.CreateForName("pre-registered")); + + cache.CreatedFor.Should().BeEmpty(); + } +} diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorHealthCheckTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorHealthCheckTests.cs new file mode 100644 index 00000000..1fcfc905 --- /dev/null +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataOrchestratorHealthCheckTests.cs @@ -0,0 +1,27 @@ +using CoreEx.RefData.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CoreEx.RefData.Test.Unit; + +public partial class ReferenceDataOrchestratorTests +{ + [Test] + public async Task CheckHealthAsync_ReturnsHealthy_WithRegisteredTypes() + { + var orch = CreateOrchestrator(); + var healthCheck = new ReferenceDataOrchestratorHealthCheck(orch); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + + result.Status.Should().Be(HealthStatus.Healthy); + result.Data.Should().ContainKey("types"); + ((string[])result.Data["types"]).Should().BeEquivalentTo(nameof(DummyRefData), nameof(DummyRefData2)); + } + + [Test] + public void Constructor_NullOrchestrator_Throws() + { + Action act = () => new ReferenceDataOrchestratorHealthCheck(null!); + act.Should().Throw(); + } +} From 151567a870e1b64e9f2370d3e66586b60a03216b Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 5 Aug 2026 13:49:17 -0700 Subject: [PATCH 5/5] Address Copilot review comments on PR #180 - GetLoadableTypes: use OfType() instead of a null-forgiving Where filter. - IReferenceDataCollection.GetById(object?): avoid casting null/mismatched ids to default(TId); use an `is TId` pattern so a null or wrong-typed id returns null instead of silently querying id 0 (or similar) or throwing InvalidCastException. - HybridCacheSynchronizerTests: await the ThrowAsync assertion in ExitAsync_NotEntered_ThrowsInvalidOperationException so the test actually verifies the exception instead of always passing. Co-Authored-By: Claude Sonnet 5 --- src/CoreEx/CoreExExtensions.DependencyInjection.cs | 2 +- src/CoreEx/RefData/IReferenceDataCollectionT.cs | 2 +- .../Hosting/Synchronization/HybridCacheSynchronizerTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/CoreEx/CoreExExtensions.DependencyInjection.cs b/src/CoreEx/CoreExExtensions.DependencyInjection.cs index 42d6ecd0..b816770e 100644 --- a/src/CoreEx/CoreExExtensions.DependencyInjection.cs +++ b/src/CoreEx/CoreExExtensions.DependencyInjection.cs @@ -134,7 +134,7 @@ private static IEnumerable GetLoadableTypes(Assembly assembly) } catch (ReflectionTypeLoadException ex) { - return ex.Types.Where(t => t is not null)!; + return ex.Types.OfType(); } } diff --git a/src/CoreEx/RefData/IReferenceDataCollectionT.cs b/src/CoreEx/RefData/IReferenceDataCollectionT.cs index 8cea0cca..74add242 100644 --- a/src/CoreEx/RefData/IReferenceDataCollectionT.cs +++ b/src/CoreEx/RefData/IReferenceDataCollectionT.cs @@ -45,7 +45,7 @@ bool IReferenceDataCollection.TryGetByCode(string code, [NotNullWhen(true)] out } /// - IReferenceData? IReferenceDataCollection.GetById(object? id) => GetById((TId)(id ?? default(TId)!)); + IReferenceData? IReferenceDataCollection.GetById(object? id) => id is TId typedId ? GetById(typedId) : null; /// IReferenceData? IReferenceDataCollection.GetByCode(string code) => GetByCode(code); diff --git a/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs b/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs index 664dd102..5949b2bc 100644 --- a/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs +++ b/tests/CoreEx.Test.Unit/Hosting/Synchronization/HybridCacheSynchronizerTests.cs @@ -93,11 +93,11 @@ public async Task ExitAsync_AfterEnter_AllowsReentry() } [Test] - public void ExitAsync_NotEntered_ThrowsInvalidOperationException() + public async Task ExitAsync_NotEntered_ThrowsInvalidOperationException() { var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); Func act = () => synchronizer.ExitAsync(); - act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Test]