diff --git a/tests/Trax.Effect.Tests.Integration/Trax.Effect.Tests.Integration.csproj b/tests/Trax.Effect.Tests.Integration/Trax.Effect.Tests.Integration.csproj index 1499469..afc0e90 100644 --- a/tests/Trax.Effect.Tests.Integration/Trax.Effect.Tests.Integration.csproj +++ b/tests/Trax.Effect.Tests.Integration/Trax.Effect.Tests.Integration.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/Trax.Effect.Tests.Integration/UnitTests/Services/EffectCoverageGapTests.cs b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/EffectCoverageGapTests.cs new file mode 100644 index 0000000..124b71a --- /dev/null +++ b/tests/Trax.Effect.Tests.Integration/UnitTests/Services/EffectCoverageGapTests.cs @@ -0,0 +1,383 @@ +using System.Reflection; +using FluentAssertions; +using LanguageExt; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NUnit.Framework; +using Trax.Core.Exceptions; +using Trax.Core.Train; +using Trax.Effect.Configuration.TraxEffectBuilder; +using Trax.Effect.Data.InMemory.Extensions; +using Trax.Effect.Extensions; +using Trax.Effect.Models; +using Trax.Effect.Models.Metadata; +using Trax.Effect.Models.Metadata.DTOs; +using Trax.Effect.Services.EffectJunction; +using Trax.Effect.Services.EffectProvider; +using Trax.Effect.Services.EffectProviderFactory; +using Trax.Effect.Services.EffectRegistry; +using Trax.Effect.Services.JunctionEffectProvider; +using Trax.Effect.Services.JunctionEffectProviderFactory; +using Trax.Effect.Services.JunctionEffectRunner; +using Trax.Effect.Services.LifecycleHookRunner; +using Trax.Effect.Services.ServiceTrain; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Effect.Services.TrainLifecycleHookFactory; + +namespace Trax.Effect.Tests.Integration.UnitTests.Services; + +[TestFixture] +public class EffectCoverageGapTests +{ + #region EffectJunction.RailwayJunction + + [Test] + public async Task EffectJunction_RailwayJunction_NonServiceTrain_Throws() + { + var junction = new TestEffectJunction(); + var nonServiceTrain = new NonServiceTrain(); + + Func act = async () => + await junction.RailwayJunction( + Either.Right("in"), + nonServiceTrain + ); + + await act.Should().ThrowAsync().WithMessage("*non-ServiceTrain*"); + } + + [Test] + public async Task EffectJunction_RailwayJunction_NullMetadata_Throws() + { + var junction = new TestEffectJunction(); + var train = new TestTrain(); + // Leave Metadata null + + Func act = async () => + await junction.RailwayJunction(Either.Right("in"), train); + + await act.Should().ThrowAsync().WithMessage("*Metadata cannot be null*"); + } + + [Test] + public async Task EffectJunction_RailwayJunction_HappyPath_PopulatesMetadata() + { + var junction = new TestEffectJunction(); + var train = CreateTrain(); + + var result = await junction.RailwayJunction( + Either.Right("hello"), + train + ); + + result.IsRight.Should().BeTrue(); + result.IfRight(v => v.Should().Be("hello-out")); + junction.Metadata.Should().NotBeNull(); + junction.Metadata!.Name.Should().Be(nameof(TestEffectJunction)); + junction.Metadata.HasRan.Should().BeTrue(); + junction.Metadata.StartTimeUtc.Should().NotBeNull(); + junction.Metadata.EndTimeUtc.Should().NotBeNull(); + } + + [Test] + public async Task EffectJunction_RailwayJunction_WithEffectRunner_CallsBeforeAndAfter() + { + var runner = Substitute.For(); + var junction = new TestEffectJunction(); + var train = CreateTrain(runner); + + await junction.RailwayJunction(Either.Right("hi"), train); + + await runner + .Received(1) + .BeforeJunctionExecution( + Arg.Any>(), + Arg.Any>(), + Arg.Any() + ); + await runner + .Received(1) + .AfterJunctionExecution( + Arg.Any>(), + Arg.Any>(), + Arg.Any() + ); + } + + #endregion + + #region JunctionEffectRunner.Before/AfterJunctionExecution + + [Test] + public async Task JunctionEffectRunner_BeforeAndAfter_DispatchToActiveProviders() + { + var provider = Substitute.For(); + var factory = Substitute.For(); + factory.Create().Returns(provider); + + var registry = Substitute.For(); + registry.IsEnabled(Arg.Any()).Returns(true); + + using var runner = new JunctionEffectRunner(new[] { factory }, registry); + + var junction = new TestEffectJunction(); + var train = CreateTrain(); + var ct = CancellationToken.None; + + await runner.BeforeJunctionExecution(junction, train, ct); + await runner.AfterJunctionExecution(junction, train, ct); + + await provider.Received(1).BeforeJunctionExecution(junction, train, ct); + await provider.Received(1).AfterJunctionExecution(junction, train, ct); + } + + #endregion + + #region LifecycleHookRunner.OnStateChanged exception path + + [Test] + public async Task LifecycleHookRunner_OnStateChanged_HookThrows_LogsAndContinues() + { + var hook1 = Substitute.For(); + hook1 + .OnStateChanged(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("hook1 fail")); + var hook2 = Substitute.For(); + + var f1 = Substitute.For(); + f1.Create().Returns(hook1); + var f2 = Substitute.For(); + f2.Create().Returns(hook2); + + var registry = Substitute.For(); + registry.IsEnabled(Arg.Any()).Returns(true); + + using var runner = new LifecycleHookRunner(new[] { f1, f2 }, registry); + + var metadata = CreateMetadata(); + + // Should not throw — hook1 fails, hook2 still runs. + await runner.OnStateChanged(metadata, CancellationToken.None); + + await hook2.Received(1).OnStateChanged(metadata, Arg.Any()); + } + + #endregion + + #region ServiceExtensions — AddEffect / AddJunctionEffect / AddLifecycleHook overloads + + private static IServiceCollection ServicesWithEffectsConfigured( + Action configure + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTrax(trax => + trax.AddEffects(effects => + { + effects.UseInMemory(); + configure(effects); + return effects; + }) + ); + return services; + } + + [Test] + public void AddEffect_WithFactoryInstance_RegistersFactoryAndRegistry() + { + var fakeFactory = new FakeEffectProviderFactory(); + + var services = ServicesWithEffectsConfigured(b => b.AddEffect(fakeFactory)); + + services.Should().Contain(d => d.ServiceType == typeof(IEffectProviderFactory)); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + registry.IsEnabled(typeof(FakeEffectProviderFactory)).Should().BeTrue(); + registry.IsToggleable(typeof(FakeEffectProviderFactory)).Should().BeTrue(); + } + + [Test] + public void AddJunctionEffect_WithIAndConcreteFactory_RegistersAllThreeServiceTypes() + { + var instance = new FakeJunctionEffectProviderFactory(); + + var services = ServicesWithEffectsConfigured(b => + b.AddJunctionEffect< + IFakeJunctionEffectProviderFactory, + FakeJunctionEffectProviderFactory + >(instance) + ); + + services.Should().Contain(d => d.ServiceType == typeof(FakeJunctionEffectProviderFactory)); + services.Should().Contain(d => d.ServiceType == typeof(IJunctionEffectProviderFactory)); + services.Should().Contain(d => d.ServiceType == typeof(IFakeJunctionEffectProviderFactory)); + } + + [Test] + public void AddJunctionEffect_TypeOnly_RegistersConcreteAndInterface() + { + var services = ServicesWithEffectsConfigured(b => + b.AddJunctionEffect() + ); + + services.Should().Contain(d => d.ServiceType == typeof(FakeJunctionEffectProviderFactory)); + services.Should().Contain(d => d.ServiceType == typeof(IJunctionEffectProviderFactory)); + } + + [Test] + public void AddJunctionEffect_TypeOnly_WithFactoryInstance_RegistersInterface() + { + var instance = new FakeJunctionEffectProviderFactory(); + + var services = ServicesWithEffectsConfigured(b => b.AddJunctionEffect(instance)); + + services.Should().Contain(d => d.ServiceType == typeof(IJunctionEffectProviderFactory)); + } + + [Test] + public void AddLifecycleHook_WithIAndConcreteFactory_RegistersAllThreeServiceTypes() + { + var factory = new FakeLifecycleHookFactory(); + + var services = ServicesWithEffectsConfigured(b => + b.AddLifecycleHook(factory) + ); + + services.Should().Contain(d => d.ServiceType == typeof(FakeLifecycleHookFactory)); + services.Should().Contain(d => d.ServiceType == typeof(ITrainLifecycleHookFactory)); + services.Should().Contain(d => d.ServiceType == typeof(IFakeLifecycleHookFactory)); + } + + [Test] + public void AddLifecycleHook_WithFactoryInstance_RegistersFactory() + { + var factory = new FakeLifecycleHookFactory(); + + var services = ServicesWithEffectsConfigured(b => b.AddLifecycleHook(factory)); + + services.Should().Contain(d => d.ServiceType == typeof(ITrainLifecycleHookFactory)); + } + + [Test] + public void AddLifecycleHook_TypeIsNeitherHookNorFactory_Throws() + { + Action act = () => ServicesWithEffectsConfigured(b => b.AddLifecycleHook()); + + act.Should() + .Throw() + .WithMessage("*ITrainLifecycleHook or ITrainLifecycleHookFactory*"); + } + + [Test] + public void AddScopedTraxJunction_RuntimeTypes_RegistersInterfaceAsScoped() + { + var services = new ServiceCollection(); + services.AddScopedTraxJunction(typeof(IFakeRoute), typeof(FakeRoute)); + + services + .Should() + .Contain(d => + d.ServiceType == typeof(IFakeRoute) && d.Lifetime == ServiceLifetime.Scoped + ); + } + + [Test] + public void AddTransientTraxJunction_RuntimeTypes_RegistersInterfaceAsTransient() + { + var services = new ServiceCollection(); + services.AddTransientTraxJunction(typeof(IFakeRoute), typeof(FakeRoute)); + + services + .Should() + .Contain(d => + d.ServiceType == typeof(IFakeRoute) && d.Lifetime == ServiceLifetime.Transient + ); + } + + [Test] + public void AddSingletonTraxJunction_RuntimeTypes_RegistersInterfaceAsSingleton() + { + var services = new ServiceCollection(); + services.AddSingletonTraxJunction(typeof(IFakeRoute), typeof(FakeRoute)); + + services + .Should() + .Contain(d => + d.ServiceType == typeof(IFakeRoute) && d.Lifetime == ServiceLifetime.Singleton + ); + } + + #endregion + + #region Test helpers / fakes + + private static TestTrain CreateTrain(IJunctionEffectRunner? runner = null) + { + var train = new TestTrain(); + if (runner is not null) + train.JunctionEffectRunner = runner; + + var metadataProp = typeof(ServiceTrain).GetProperty( + "Metadata", + BindingFlags.Public | BindingFlags.Instance + ); + metadataProp!.SetValue(train, CreateMetadata()); + + return train; + } + + private static Metadata CreateMetadata() => + Metadata.Create( + new CreateMetadata + { + Name = "TestTrain", + ExternalId = Guid.NewGuid().ToString("N"), + Input = null, + } + ); + + private class TestTrain : ServiceTrain + { + protected override Task> RunInternal(string input) => + Task.FromResult>(input); + } + + private class TestEffectJunction : EffectJunction + { + public override Task Run(string input) => Task.FromResult(input + "-out"); + } + + private class NonServiceTrain : Train + { + protected override Task> RunInternal(string input) => + Task.FromResult>(input); + } + + private class FakeEffectProviderFactory : IEffectProviderFactory + { + public IEffectProvider Create() => Substitute.For(); + } + + public interface IFakeJunctionEffectProviderFactory : IJunctionEffectProviderFactory; + + public class FakeJunctionEffectProviderFactory : IFakeJunctionEffectProviderFactory + { + public IJunctionEffectProvider Create() => Substitute.For(); + } + + public interface IFakeLifecycleHookFactory : ITrainLifecycleHookFactory; + + public class FakeLifecycleHookFactory : IFakeLifecycleHookFactory + { + public ITrainLifecycleHook Create() => Substitute.For(); + } + + public class RandomType { } + + public interface IFakeRoute; + + public class FakeRoute : IFakeRoute; + + #endregion +}