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/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/CoreExExtensions.DependencyInjection.cs b/src/CoreEx/CoreExExtensions.DependencyInjection.cs index 37d3a9ba..b816770e 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.OfType(); + } + } + /// /// Adds a singleton service for the internal . /// 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/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/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.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/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 8a412386..e2bb68f6 100644 --- a/src/CoreEx/Hosting/HostedServiceBase.cs +++ b/src/CoreEx/Hosting/HostedServiceBase.cs @@ -323,14 +323,12 @@ 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); } - 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/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/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 f3d717b2..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; } @@ -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/RefData/IReferenceDataCollectionT.cs b/src/CoreEx/RefData/IReferenceDataCollectionT.cs index 3d99b1a0..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(id); + IReferenceData? IReferenceDataCollection.GetById(object? id) => id is TId typedId ? GetById(typedId) : null; /// IReferenceData? IReferenceDataCollection.GetByCode(string code) => GetByCode(code); 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.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(); + } +} 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/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/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/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/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 9bfe5a58..10e956e7 100644 --- a/tests/CoreEx.Test.Unit/ExecutionContextTests.cs +++ b/tests/CoreEx.Test.Unit/ExecutionContextTests.cs @@ -1,5 +1,7 @@ using CoreEx.Entities; using CoreEx.Localization; +using CoreEx.Security; +using Microsoft.Extensions.DependencyInjection; using System.Globalization; namespace CoreEx.Test.Unit; @@ -28,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] @@ -110,18 +112,22 @@ 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") + UICulture = new CultureInfo("en-US"), + OperationType = OperationType.Update, + IncludeRelatedText = true }; ec.AddInfoMessage(new LText("msg")); ec.Attributes["k"] = "v"; 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); + copy.IncludeRelatedText.Should().BeTrue(); copy.Messages.Should().BeSameAs(ec.Messages); copy.Attributes.Should().NotBeNull(); copy.Attributes["k"].Should().Be("v"); @@ -143,6 +149,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/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/HostedServiceBaseTests.cs b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs new file mode 100644 index 00000000..c833fa7e --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/HostedServiceBaseTests.cs @@ -0,0 +1,240 @@ +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 HostedServiceBaseTests +{ + private static ServiceProvider CreateServiceProvider(IConfiguration? configuration = null) + { + var sc = new ServiceCollection(); + 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() + { + 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); + } + + [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; } + + 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) + { + StatusDuringStop = Status; + await StopGate.Task; + } + + 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/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..5949b2bc --- /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 async Task ExitAsync_NotEntered_ThrowsInvalidOperationException() + { + var synchronizer = new HybridCacheSynchronizer(new FakeHybridCache()); + Func act = () => synchronizer.ExitAsync(); + await 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/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/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/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/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/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/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/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/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/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/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/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..f88b0105 100644 --- a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -131,6 +131,53 @@ 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_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() { 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); + } +} 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 + } +}